前言提示
实现过程非常啰嗦,几乎每行都有注释。
响应式原理脑图
vue是采用数据劫持配合发布订阅者模式,通过Object.definerProperty()来劫持各个属性的getter和setter,在数据变动时,发布消息给依赖收集器,去通知观察者,作出对应的回调函数,去更新视图。
MVVM作为绑定的入口,整合Observer,Compile和Watcher三者,通过Observer来监听model数据变化,通过Compile来解析编译模板指令,最终利用Watcher搭起了Observer,Compile之间的通信桥梁,达到数据变化 =》视图更新,视图交互变化 =》 数据model变更的双向绑定效果。
那么我们就从实现一个Compile指令解析器开始。
-------------------------------------------------------------------------------------------
源码实现
构造一个MVue类
在这个MVue类里,我们要实现一个Compile指令解析器,一个Observer数据观察者,然后通过 Watcher 去更新视图。
class MVue {
constructor(options) {
}
}
在MVue类里,我们会存放包括el,data,methods,components等。
class MVue {
constructor(options) {
this.$el = options.el;
this.$data = options.data;
this.$options = options;
if(this.$el) {
//1.实现一个Compile指令解析器
//把节点和这个实例作为参数
new Compile(this.$el,this)
//2.实现一个Oberver数据观察者
}
}
}
构造Compile 类
然后我们要实现一个Compile指令解析器,首先获取到页面中的所有节点,保存到文档碎片对象中,然后放入内存中,这样做可以减少页面的回流和重绘造成的页面性能损耗。
class Compile {
constructor(el,vm) {
//首先判断当前el是不是一个元素节点
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
//然后我们来获取文档碎片对象
const fragment = this.node2Fragment(this.el);
}
}
node2Fragment(el) {
//创建文档碎片
const f = document.createDocumentFragment();
//通过对el下所有子元素的遍历,把他们都放在"f"这个文档碎片对象中
let firstchild;
while (firstChild = el.firstChild) {
//如果子元素存在,就放进f里
f.appendChild(firstChild)
}
//然后我们把这个文档碎片对象"f"返回出去
return f;
}
定义文档碎片对象
我们获取到文档碎片对象后,我们要对它里面的所有节点进行编译。
class Compile {
constructor(el,vm) {
//首先判断当前el是不是一个元素节点
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
//然后我们来获取文档碎片对象
const fragment = this.node2Fragment(this.el);
//接着我们来对这个对象进行编译
this.compile(fragment)
}
}
//node2Fragment(el)这里是省略掉的获取文档碎片对象的方法
compile(fragment) {
//我们要对这个文档碎片对象的所有节点进行编译\
//获取子节点
const childNodes = fragment.childNodes;
[...childNodes].foreach(child => {
if(this.isElementNode(child)) {
//是元素节点,编译元素节点
this.compileElement(child)
}else {
//是文本节点,编译文本节点
this.compileText(child)
}
})
}
//接下来是对节点的具体编译
compileElement(node) {
}
compileText(node) {
}
编译元素节点
在上面我们要用这个节点编译方法对节点进行编译,并将这个子节点作为参数传入。
compileElement(node) {//node就是我们的节点,我们可以试试打印node看看
//comsole.log(node)
//然后我们要获得这个节点上的内联样式上的指令
const attributes = node.attributes;
//我们知道内联样式上都是name: value的形式,例如v-text = "msg",
//那么我们可以通过遍历attributes中的每一项来获得它上面的name和value
[...attributes].forEach((attr) => {
//解构赋值,把name和value放在这个attr对象中
const { name, value } = attr;
//接着我们就要判断,这个内联样式的name是不是一个指令
if(this.isDirective(name)) {
//是一个指令 v-text v-html v-model v-on:click
//我们用数组的split方法对指令进行处理,拿到v-后面的部分,赋值给directive
const [,directive] = name.split("-") //text html model on:click
//然后我们要注意处理on:click这类的事件,拿到:前面的部分赋值给dirName,拿到:后面的部分,赋值给eventName
const [dirName,eventName] = directive.split(":")
//我们拿到这个指令的关键字了,然后我们可以通过一个处理器对象来进行处理
//我们把上面获得的关键字作为参数传递给这个处理器对象
compileUtil[dirName](node,value,this.vm,eventName);
//把节点上的指令删除掉
node.removeAttribute("v-" + dirctive);
}
})
}
isDirective(attrName) {
//判断一下是不是 vue 指令
return attrName.startsWith("v-");
}
定义处理器对象compileUtil
处理v-text指令
现在我们拿到了节点上的指令,要根据指令来处理节点的渲染,我们来定义一个处理器对象。
//我们在Compile类的上面定义一下这个处理器对象
//在这个对象上有对应的text,html,model,on的方法,对应compileUtil[dirName]里的dirName
const compileUtil = {
text(node,expr,vm) {
//expr : msg
//我们要做v-text指令下完成的效果是把它的value渲染到我们的节点上
const value = vm.$data[expr];//获取到value
//然后我们通过updater对象上的方法改变这个节点上的textContent值
this.updater.textUpdater(node,value);
},
html(node,expr,vm){},
model(node,expr,vm){},
on(node,expr,vm){},
updater: {
textUpdater(node,value) {
node.textContent = value;
//做到这一步我们就已经把v-text="msg"这个指令初步渲染完成了
//我们把$data.msg的值渲染到这个节点上了
}
}
}
这样做可以实现 v-text = "msg" 这个指令,可是如果 v-text = "person.fav" 这样的形式,那么 vm.$data[expr] 获取到的 value 值应该是 person.fav ,那么就要进行处理。
//我们在Compile类的上面定义一下这个处理器对象
//在这个对象上有对应的text,html,model,on的方法,对应compileUtil[dirName]里的dirName
const compileUtil = {
//定义 getVal 方法对可能出现的 v-text = "person.fav" 这样形式的指令进行处理
getVal(expr, vm) {
return expr.spilt('.').reduce((data, currentVal) => { // 得到 [ person, fav ]
return data[currentVal]; // 通过 person[fav] 拿到值
}, vm.$data)
}
text(node,expr,vm) {
//expr : msg
//我们要做v-text指令下完成的效果是把它的value渲染到我们的节点上
const value = this.getVal(expr, vm);//获取到value
//然后我们通过updater对象上的方法改变这个节点上的textContent值
this.updater.textUpdater(node,value);
},
html(node,expr,vm){},
model(node,expr,vm){},
on(node,expr,vm){},
updater: {
textUpdater(node,value) {
node.textContent = value;
//做到这一步我们就已经把v-text="person.fav"这个指令初步渲染完成了
//我们把$data.person[fav] 的值渲染到这个节点上了
}
}
}
做好了 v-text 的渲染,接着来做剩下的。
处理v-html,v-model,v-on指令
//我们在Compile类的上面定义一下这个处理器对象
//在这个对象上有对应的text,html,model,on的方法,对应compileUtil[dirName]里的dirName
const compileUtil = {
//定义 getVal 方法对可能出现的 v-text = "person.fav" 这样形式的指令进行处理
getVal(expr, vm) {
return expr.spilt('.').reduce((data, currentVal) => { // 得到 [ person, fav ]
return data[currentVal]; // 通过 person[fav] 拿到值
}, vm.$data)
}
text(node,expr,vm) {
//expr : msg
//我们要做v-text指令下完成的效果是把它的value渲染到我们的节点上
const value = this.getVal(expr, vm);//获取到value
//然后我们通过updater对象上的方法改变这个节点上的textContent值
this.updater.textUpdater(node,value);
},
html(node,expr,vm){
const value = this.getVal(expr, vm);
this.updater.htmlUpdater(node, value);
},
model(node,expr,vm){
const value = this.getVal(expr, vm);
this.modelUpdater(node, value);
},
on(node,expr,vm){
let fn = vm.$options.methods && vm.$options.methods[expr];
node.addEventListener(eventName, fn.bind(vm), false);
//做到这一步我们就已经把 v-on = "handleClick" 这个指令实现了,可是我们知道,在vue的语法中,我们还可以用 @ 来代替 :on 绑定事件,所以我们还要对这个情况进行处理
},
updater: {
textUpdater(node,value) {
node.textContent = value;
//做到这一步我们就已经把v-text="person.fav"这个指令初步渲染完成了
//我们把$data.person[fav] 的值渲染到这个节点上了
},
htmlUpdater(node, value) {
node.innerHTML = value;
//做到这一步我们就已经把v-html = "msg" 这个指令初步渲染完成了
},
modelUpdater(node, value) {
node.value = value;
//做到这一步我们就已经把 v-model = "msg" 这个指令初步渲染完成了
}
}
}
我们已经把 v-on = "handleClick" 这个指令实现了,可是我们知道,在vue的语法中,我们还可以用 @ 来代替 :on 绑定事件,所以我们还要对这个情况进行处理。
compileElement(node) {
const attributes = node.attributes;
console.log(node.attributes);
[...attributes].forEach((attr) => {
//解构赋值
const { name, value } = attr;
if (this.isDirective(name)) {
//是一个指令 v-text v-html v-model v-on:click
const [, dirctive] = name.split("-"); //text html model on:click
const [dirName, eventName] = dirctive.split(":");
//更新数据 数据驱动视图
compileUtil[dirName](node, value, this.vm, eventName);
//删除有指令的标签上的属性
node.removeAttribute("v-" + dirctive);
} else if (this.isEventName(name)) {
//判断一下是不是@符号开头的
//@click="handleClick"
let [, eventName] = name.split("@");
//调用compileUtil类下的on方法进行编译
compileUtil["on"](node, value, this.vm, eventName);
}
});
}
isEventName(attrName) {
//判断一下是不是@符号开头的
return attrName.startsWith("@");
}
编译文本节点
现在我们处理完了文档碎片对象中的所有的元素节点,那么接着来处理 文档碎片对象上的文本节点。
compileText(node) {
// {{}} 处理 console.log(node.textContent)
const content = node.textContent;
//通过正则表达式拿到双大括号里的插值
if (/\{\{(.+?)\}\}/.test(content)) {
//console.log(content); // {{person.name}} -- {{person.age}}
compileUtil["text"](node, content, this.vm); //这里我们调用 compileUtil[text] 的时候,传入的 content 是有双大括号包裹的,所以在这个方法里还要进行判断
}
}
这里我们调用 compileUtil[text] 的时候,传入的 content 是有双大括号包裹的,所以在这个方法里还要进行判断
text(node,expr,vm) {
let value;
if(expr.indexOf('{{') !== -1) {
//处理传入的expr是带有{{}}形式的 {{person.fav}}
value = expr.replace(/\{\{(.+?)\}\}/g, (...args) => { //通过正则表达式分割字符串, ['{{person.fav}}', 'person.fav', 0, '{{person.fav}}']
return this.getVal(args[1], vm);
});
}else {
const value = this.getVal(expr, vm);
}
this.updater.textUpdater(node,value);
},
这样我们就完成了一个指令解析器,通过指令解析器就可以把我们的指令包括 [ 'v-text', 'v-html', 'v-model', 'v-on', '@' ] 和双括号的插值表达式里的数据都渲染到页面上了。