前言
距离我的上篇博客已经过去了20天,有一说一,这20天里我确实挺咸鱼,翻了翻《webpack入门,进阶,和调优》(但我还是只会照着文档配),一边摸鱼一边写了下工作室的项目,然后就没干啥了,最近几天才突然醒悟,于是结合寒假上半段学到的知识,模拟了一些vue的功能,实现了一个简易的mvvm框架。
然后实现的功能有数据响应式 + computed计算属性 + watch监听属性 + bind,on,model几个指令,使用方法如下
<body>
<div id="app" class="container" data-tar="sakura">
<div s-on:click="onDivClick">
a : {{a}}
b : {{b}}
</div>
<div>
<span>
obj.c : {{obj.c}}
</span>
</div>
<p>arr : {{arr}}</p>
<p>arr[1] : {{arr[1]}}</p>
<div>
<p>{{name}}</p>
<input s-model="name" type="text"/>
</div>
<p>{{sum}}</p>
<p s-if="flag">flag</p>
<p>{{doubleSum}}</p>
<img s-bind:src="imgSrc" alt="" width="400px" />
</div>
<script type="module">
import Sue from "./src/index.js";
import {setVal} from "./src/compiler/util.js";
window.vm = new Sue({
el : "#app",
data : {
a : 1,
b : 2,
obj : {
c : 3
},
name : "123",
age : 16,
arr : [1, 2, 3, 4],
imgSrc : "https://img.beixibaobao.cn/images/BS4Hq.jpg",
flag : false,
},
computed : {
doubleSum() {
return this.sum * 2;
},
sum () {
let a = this.a;
let b = this.b;
let c = this.obj.c;
return a + b + c;
},
},
watch : {
age : function (newAge, oldAge) {
console.log(newAge, oldAge, this);
}
},
method : {
onDivClick(event) {
console.log("点击");
}
},
mounted() {
console.log("挂载完成",this);
}
});
</script>
</body>
效果图

是不是效果和vue差不多?当然,我写这个框架的出发点只是总结下知识体系,所以只实现了一些基本原理,没有考虑太多复杂情况,如果你有什么想法,非常欢迎在下方留言。
源代码地址: https://github.com/sliyoxn/sue/
数据响应式原理
先简单说说什么是数据响应式,数据响应式指的是在修改数据模型时,会触发对应的视图更新。这里的数据模型,就是普通的JS对象。
在JS中,侦测数据变化的方法有两个,ES5的Object.defineProperty和ES6的Proxy,在vue2.x版本中,vue使用的是 Object.defineProperty,vue的作者尤大大说要在vue3中用Proxy重写这部分代码,因为Proxy相对于 Object.defineProperty 在数据变化侦测方面有诸多优势,简单提一下有两点
- 可以监听对象非已有的属性的添加和已有属性的删除
- 可以监听到直接通过修改数组下标和通过length改变数组的行为
这里我也赶一波潮流,用proxy来写数据响应式的部分。
实现数据响应式需要三个重要的角色,分别是数据变动监听器Observer,观察者Watcher,还有订阅器Dep,他们之间的关系是这样的,Dep订阅器中有一个列表用于保存Watcher,Watcher可以把自己保存到Dep中,当Observer监听到数据变动时,就通知对应的Dep,Dep再通知列表中的Watcher,触发响应的更新。
具体到某个数据属性来看,Proxy担任了Observe的角色,Proxy可以代理属性的set操作,从而得知数据何时发生变动。在set函数中通知Dep数据发生变化,就可以把数据修改的信息传达给Dep,最后Dep再通知Watcher。
好了,说完了大概的思想,然后我们来上代码,先说说Proxy的使用。
let data = {
a : 1,
b : 2,
c : 3
};
let proxy = new Proxy(data, {
/**
* 代理get
* @param target proxy代理的对象
* @param prop 本次访问的属性
* @param proxy proxy本身
* @returns {*}
*/
get(target, prop, proxy) {
console.log(`访问了data对象的${prop}属性, 属性的值是${target[prop]}`);
return target[prop];
},
/**
* 代理set
* @param target proxy代理的对象
* @param prop 本次访问的属性
* @param value 新value
* @param proxy proxy本身
*/
set(target, prop, value, proxy) {
console.log(`设置了data对象的${prop}属性, 属性的新值是${value}`);
target[prop] = value;
}
});
console.log(proxy.a);
proxy.a = 10;
console.log(proxy.a);
上面演示了proxy的基本使用,通过设置get和set函数,可以对对象的set的get行为进行代理,在get函数中返回被代理对象的数据就可以完成默认行为,在set函数中设置被代理对象的数据就可以完成默认行为,当然除了完成默认行为,你可以在get或者set函数中添加其他行为。值得注意的是,只有通过proxy对data进行操作才能让get和set函数生效,直接操作原来的对象不会触发get和set函数。
下面是Observe,Dep,Watcher的实现
Dep的作用就是缓存订阅者和通知订阅者,addSub方法用于添加订阅者,notify方法用于通知订阅者。
export default class Dep {
constructor() {
// 初始化订阅者列表
this.subs = new Set();
}
addSub(sub) {
// 添加订阅者
this.subs.add(sub);
}
notify() {
this.subs.forEach((sub) => {
// 通知订阅者
sub.update();
})
}
}
Observer没有对应的实体类,他的职责由Proxy完成,Proxy代理的set函数,可以监听到数据的变动,并且在set函数里通知Dep,在get函数里还进行了一些其他的处理,这里会和Watcher一起说明。
import {global, targetSymbol} from "../public/Variable.js";
import Dep from "./bean/Dep.js";
function observe(obj) {
if (typeof obj !== "object") return;
let proxy = getProxyObj(obj);
// proxy是浅代理
// 所以需要循环所有的属性,进行深代理,这样才能把整个对象都转化成响应式的
Object.keys(proxy).forEach((key) => {
if (typeof proxy[key] === "object") {
proxy[key] = observe(proxy[key]);
}
});
return proxy;
}
function getProxyObj(obj) {
// 用于存放属性的Dep, dep[key]就是这个属性的Dep
let depMap = {};
return new Proxy(obj, {
get(target, key, proxy) {
// 去指定位置上寻找Watcher
// 如果找到就把它缓存起来
if (global[targetSymbol]) {
let dep = depMap[key] ?
depMap[key] : (depMap[key] = new Dep());
dep.addSub(global[targetSymbol]);
}
return target[key];
},
set(target, key, value, proxy) {
target[key] = value;
// 通知Dep
depMap[key] && depMap[key].notify(value);
return true;
}
});
}
构建Watcher对象需要的参数有三个,vm(new Vue()得到的就是vm对象),exp是表达式,如果这个Watcher监听的是vm.obj.name这个属性,那么exp就应该是"obj.name",cb是回调函数,当Watcher被通知数据更新时,会把新旧数据传入cb并执行。
import {global, targetSymbol} from "../../public/Variable.js";
import {getVal} from "../../compiler/util.js";
/**
*
* @param vm Sue实例
* @param exp 要监听的属性的表达式
* @param cb
* @constructor
*/
export default class Watcher {
constructor(vm, exp, cb) {
this.cb = cb;
this.vm = vm;
this.exp = exp;
// 把自己添加到订阅器Dep里
this.value = this.get();
};
update() {
this.run();
}
run() {
let value = getVal(this.vm, this.exp);
let oldVal = this.value;
if (value !== oldVal) {
// 只有新旧值不一样时才触发回调
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
}
get() {
global[targetSymbol] = this;
let value = getVal(this.vm, this.exp);
global[targetSymbol] = null;
return value;
}
}
Watcher的get方法用于把自己加到对应的Dep里,get方法执行过程中,先把Watcher自身放到global[targetSymbol]上,然后调用getVal方法获取值,getVal是一个封装好的工具函数,只要传入对象和表达式就能获取相应的值,在获取值时,会执行Proxy的get方法,get方法会去 global[targetSymbol] 上寻找Watcher,如果找到就会把Watcher添加到Dep里,这样就完成了依赖收集的操作。在使用时,只要new Watcher,就可以调用get函数,只要传入proxy对象和表达式,就可以获取对应的值和完成依赖收集的操作。
另外,因为我们平时是用vm.xxx这样的方式来访问数据的,所以我们还需要在vm上设置一些访问器属性。通过设置访问器属性,可以把访问vm.xxx代理到访问proxy.xxx上,具体的方法如下。
使用Object.defineProperty可以完成访问器属性的设置
function proxyKeys(target, proxyTo) {
Object.keys(proxyTo).forEach((key) => {
Object.defineProperty(target, key, {
configurable : true,
enumerable : true,
get() {
return proxyTo[key];
},
set(v) {
proxyTo[key] = v;
}
})
})
}
// observe会返回一个proxy
// 把vm.xxx代理到proxy.xxx上
proxyKeys(vm, observe(data));
节点编译
其实说节点编译有点不是那么准确,但是好像找不出其他的词语了,所以暂时先用这个。这一步的主要目的是确定HTML中依赖了了哪些数据,然后建立依赖关系,实现代码如下。
function compiler(el, vm) {
let context = {
vm
};
domToVNode(el, context);
}
function domToVNode(el, context) {
let element = getDomEle(el);
if (!element) return;
return compilerElementNode(element, context);
}
function compilerElementNode(element, context) {
let tag = element.tagName;
let children = [];
let parent = stack[stack.length];
let elem = element;
let render = null;
let attrMap = {};
let childNode = element.childNodes;
let attrs = element.attributes;
for (let attr of attrs) {
attrMap[attr.name] = attr.value;
}
analysisDirective(attrMap, element, context);
childNode.forEach((node) => {
let vnode;
if (node.nodeType === 1) {
vnode = compilerElementNode(node, context);
} else if (node.nodeType === 3) {
vnode = compilerTextNode(node, context);
}
children.push(vnode);
});
return createElementVNode({
tag,
children,
parent,
elem,
render,
attr: attrMap
});
}
function compilerTextNode(node, context) {
let template = node.nodeValue;
let relyArr = getRely(template, context);
let renderFun = getRenderFun(context.vm, template);
let parent = null;
let textNode = createTextNode({
elem : node,
render : function () {
node.nodeValue = renderFun();
},
parent,
});
let watcherArr = [];
for (let i = 0; i < relyArr.length; i++) {
let watcher = new Watcher(context.vm, relyArr[i], textNode.render);
watcherArr.push(watcher);
}
textNode.render();
return textNode;
}
export {compiler}
我们一个个函数来看,compiler函数是编译的入口,在新建了一个上下文对象并把vm放入后,就调用domToVNode函数把Dom转化成一棵VNode树,VNode的作用是用JS对象描述Dom,确认Dom的依赖并构建依赖关系,完成依赖收集就是在这个函数中完成的。
domToVNode传入了两个参数,分别是new Sue(类似new Vue)时传入的el和context,我们需要先根据el获取真正的dom,然后根据节点类型调用对应的函数进行编译(nodeType为1是元素节点,nodeType为3是文本节点),然后直接把编译得到的Vnode返回即可。
接着我们来看看compilerElementNode和compilerTextNode这两个方法,先看 compilerTextNode,先通过nodeValue拿到文本节点的文本,作为文本节点重新渲染的模板,然后通过一个工具方法getRely获取模板中依赖了什么数据,比如说
a : {{a}} b : {{b}}
这个文本节点,通过getRely得到的结果是这样的

然后我们使用getRenderFun获得渲染函数, 实现如下
export function getRenderFun(vm, template) {
return function () {
return template.replace(/{{(.+?)}}/gm , function (target, name ,...arg) {
return `${getVal(vm, name)}`;
});
}
}
getRenderFun 是一个高阶函数,他会根据传入的vm和template生成一个新的渲染函数并返回,这个新的渲染函数执行时,会根据最新的数据,重新渲染出最新状态的字符串。
还是上面的文本节点,得到的渲染函数和渲染函数执行时返回的字符串如下

然后我们调用createTextNode创建一个text类型的VNode节点,这不是重点,就不过多讲解了。然后我们循环relyArr获取依赖的表达式,比如上面的文本节点就依赖了a和b,然后我们给每个依赖创建一个watcher,并且把更新文本节点的函数作为回调传入,这样在数据更新时,回调就会执行,文本节点就会更新。
接着我们讲compilerElementNode,这个函数一开始定义了一堆变量,这些变量都是之后生成VNode时要用的,第一步是先把建立一个属性的对象,就是把属性转化成key-value的方式,便于后续访问,然后调用analysisDirective用于解析属性上有没有一些指令(v-if,v-model这些),指令等等再说,先继续往下看,因为是元素节点,所以可能有子元素,所以我们遍历子节点来递归创建VNode,最后返回创建的VNode就行了。
然后我们讲讲analysisDirective,实现代码如下
import analysis_s_model from "./s-model.js";
import analysis_s_on from "./s-on.js";
import {analysis_s_bind} from "./s-bind.js";
import analysis_s_if from "./s-if.js";
const map = {
"s-model" : analysis_s_model,
"s-on" : analysis_s_on,
"s-bind" : analysis_s_bind,
"s-if" : analysis_s_if,
};
export function analysisDirective(attrMap, element, context) {
Object.keys(attrMap).forEach(directiveKey => {
let directiveValue = attrMap[directiveKey];
let directiveName = directiveKey.split(":")[0];
map[directiveName] && map[directiveName](directiveKey, directiveValue, element, context);
})
}
analysisDirective会遍历所有的属性,看看能不能匹配到map的属性,如果可以,就调用对应的函数去处理,我目前只实现了model,on,bind,if四个指令,下面来看看实现吧。
export default function analysis_s_model(directiveKey, directiveValue, element,context, payload = {}) {
if (element.tagName !== "INPUT") return;
element.value = getVal(context.vm, directiveValue);
element.addEventListener("input", function (event) {
let value = event.target.value;
setVal(context.vm, directiveValue, value);
});
element.removeAttribute(directiveKey);
}
model的代码很简单,先判断传入的element的是不是input元素,然后把element的value设置为绑定的数据的值,然后给element设置input事件,在input框的值更新时更新对应的数据的值,这样就完成了双向数据绑定的过程。
function analysis_s_on(directiveKey, directiveValue, element, context) {
let eventName = directiveKey.split(":")[1];
let vm = context.vm;
let cb = vm[directiveValue];
element.addEventListener(eventName, cb.bind(vm));
element.removeAttribute(directiveKey);
}
on只要获取要绑定的事件名和事件,然后使用addEventListener绑定即可。
function analysis_s_bind(directiveKey, directiveValue, element, context) {
let attrName = directiveKey.split(":")[1];
let vm = context.vm;
let watcher = new Watcher(vm, directiveValue, function (newVal) {
element.setAttribute(attrName, newVal);
}.bind(vm));
element.setAttribute(attrName, getVal(vm, directiveValue));
element.removeAttribute(directiveKey);
}
bind只要把依赖的数据解析出来,然后new Watcher进行依赖添加就可以了,回调函数的功能是更新属性的功能,最后把属性的值设置成对应数据的值就可以了。
其他功能
computed
我之前写过一篇computed大致原理的博客,现在的版本和之前的版本区别不大,只更新了computed之间可以相 互依赖的功能
const computedDepMap = {};
const depCollectFuncArr = [];
function initComputed(vm, computedObj) {
if (typeof computedObj !== "object") return;
Object.keys(computedObj).forEach((key) => {
if (typeof computedObj[key] === "function") {
wrapComputed(computedObj, key, vm);
}
});
for (let depCollectFunc of depCollectFuncArr) {
depCollectFunc();
}
while (depCollectFuncArr.length) {
depCollectFuncArr.pop()();
}
}
function wrapComputed(computedObj, key, vm) {
let valCache = undefined;
let originFun = computedObj[key];
let watcher = new Watcher(vm, key, function () {
valCache = originFun.call(this);
computedDepMap[key] && computedDepMap[key].notify();
}.bind(vm));
depCollectFuncArr.push((function () {
global[targetSymbol] = watcher;
valCache = originFun.call(vm);
global[targetSymbol] = null;
}));
Object.defineProperty(vm, key, {
get() {
if (global[targetSymbol]) {
let dep = computedDepMap[key] ? computedDepMap[key] : (computedDepMap[key] = new Dep());
dep.addSub(global[targetSymbol]);
}
return valCache;
},
set(v) {
valCache = v;
computedDepMap[key] && computedDepMap[key].notify();
}
});
}
export {initComputed}
computedDepMap是用于保存计算属性的Dep的,depCollectFuncArr用于缓存收集该计算属性的依赖的函数。在initComputed中,遍历设置计算属性时传入的函数,调用wrapComputed进行处理,在wrapComputed中,先创建一个Watcher,然后把收集这个计算属性依赖于哪些数据的函数添加进depCollectFuncArr中,然后用Object.defineProperty设置访问器属性,使得使用vm.xxx这种方式可以直接访问计算属性。在 wrapComputed 处理完后,把 depCollectFuncArr里的函数顺着执行一遍,再逆序执行一遍,逆序执行一遍的目的是,处理前一个计算属性依赖于后面的计算属性的情况。如下
computed : {
doubleSum() {
return this.sum * 2;
},
sum () {
let a = this.a;
let b = this.b;
let c = this.obj.c;
return a + b + c;
},
},
watch
import Watcher from "../observe/bean/Watcher.js";
function initWatch(vm, watchObj) {
Object.keys(watchObj).forEach((key) => {
let cb = watchObj[key];
let watcher = new Watcher(vm, key, cb);
});
}
export {initWatch}
water的实现比较简单,遍历设置的watch,然后new Wacther注册依赖即可。
method
import {proxyKeys} from "../observe/observe.js";
function initMethod(vm, methodObj = {}) {
proxyKeys(vm, methodObj);
}
export {initMethod};
method的实现也比较简单,直接把method的访问代理到vm上即可。
后记
那么到这里就大功告成了, 如果你有什么想法,欢迎在下方留言,如果你想要源代码,可以在这里获取。最后希望本文能对你有所帮助。