一、Vue实现双向绑定的两大机制
Vue实现数据双向绑定主要利用的就是: 数据劫持和发布订阅模式。
所谓发布订阅模式就是,定义了对象间的一种一对多的关系,让多个观察者对象同时监听某一个主题对象,当一个对象发生改变时,所有依赖于它的对象都将得到通知。
所谓数据劫持,就是利用JavaScript的访问器属性,即Object.defineProperty()方法,当对对象的属性进行赋值时,Object.defineProperty就可以通过set方法劫持到数据的变化,然后通知发布者(主题对象)去通知所有观察者,观察者收到通知后,就会对视图进行更新。
如上图所示,View模板首先经过Compiler(编译器对象)进行编译,在编译的过程中,会分析模板中哪里使用到了Vue数据(Model中的数据),一旦使用到了Vue数据(Model中的数据),就会创建一个Water(观察者对象),并且将这个观察者对象添加到发布者对象的数组中,同时获取到Vue中的数据替换编译生成一个新的View视图。
在创建Vue实例的过程中,会对Vue data中的数据进行数据劫持操作,即将data上的属性都通过Object.definePropery()的方式代理到Vue实例上,当View视图或者Vue Model中发生数据变化的时候,就会被劫持,然后通知Dep发布者对象进行视图的更新,从而实现数据的双向绑定。
二、从零实现一个简易Vue
⓪ 项目初始化
// index.html
<body>
<div id="app">
<input type="text" v-model="scholl.name">
<div>{{scholl.name}} {{scholl.age}}</div>
</div>
</body>
<script src="./vue.js"></script>
<script>
let vm = new Vue({
el: "#app",
data : {
scholl : {
name: "zf",
age: 10
}
}
});
</script>
我们使用Vue的时候,是直接new一个Vue对象,并传入一个options配置对象,里面有el和data,先简单点只配置el和data两个属性,所以vue.js中存在一个Vue类,如:
// vue.js
class Vue {
constructor(options) {
this.$el = options.el; // 保存传递的el属性
this.$data = options.data; // 保存传入的data属性
}
}
① 编译模板
要实现一个简易Vue,第一步就是要编译模板,那么我们该何时发起模板的编译操作呢?我们应该在创建Vue实例的时候,在其构造函数中就应该开始发起模板编译操作,如:
// vue.js
class Vue {
constructor(options) {
this.$el = options.el; // 保存传递的el属性
this.$data = options.data; // 保存传入的data属性
new Complier(this.$el, this); // 在创建Vue实例的过程中立即发起模板编译操作
}
}
1.1 劫持模板内容到内存
从上面可以看出Compiler也是一个类,传入了el和Vue实例对象,编译的第一步就是将View模板中的内容全部转换为文档片段进行操作,因为模板可能会非常的复杂,而模板的编译是一个频繁操作DOM的过程,如果直接操作真实的DOM会非常影响页面性能,因为文档片段存在于内存中,并不在DOM树中,所以将子元素插入到文档片段时不会引起页面回流,从而可以提升页面性能,document.createDocumentFragment()方法可以创建文档片段,如:
class Complier {
constructor(el, vm) {
// 因为配置options.el的时候el可以传入选择器还可以直接传入DOM元素
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm; // 将Vue实例保存到编译器对象上
// 传入this.el,即el对应的DOM元素,也就是根节点DOM
let fragment = this.node2fragment(this.el);
// 编译模板,将真实DOM劫持到文档片段中后,就可以开始进行模板编译了,用Vue中的数据进行替换等
this.compile(fragment);
// 将编译好的模板添加回到页面中,以便在页面中显示出来
this.el.appendChild(fragment);
}
isElementNode(node) { // 判断是否是DOM元素节点
return node.nodeType === 1;
}
node2fragment(node) { // 将真实DOM劫持到内存中
let fragment = document.createDocumentFragment(); // 创建一个文档片段
let firstChild;
while(firstChild = node.firstChild) { // 遍历传入节点中的所有子节点,然后依次添加到文档片段中
// appendChild具有移动性,可以劫持页面中的真实DOM到内存中
fragment.appendChild(firstChild);
}
return fragment;
}
}
1.2 遍历节点,根据节点类型进行相应的编译
将el中的所以子节点劫持到内存中后,就可以开始在内存中进行编译操作了,从上面可以看到,是直接调用Compiler中的compile方法,所以接下来我们需要实现这个compile()方法,编译过程就是遍历文档片段中的所有子节点,然后根据子节点的类型进行区分,如果是元素节点,那么进行元素节点编译,如果是文本节点,那么进行文本节点编译,并且,如果是元素节点,那么还有对该元素节点继续递归编译,即继续遍历该元素节点的子节点,如:
class Compiler {
compile(node) {
let childNodes = node.childNodes; // 获取传递节点的所有子节点
[...childNodes].forEach((child) => { // 遍历传递节点的所有子节点
if (this.isElementNode(child)) { // 如果是元素节点
this.compileElement(child); // 编译元素节点,比如元素上面的指令等
this.compile(child); // 递归编译元素节点
} else {
this.compileText(child); // 编译文本节点,即{{}}mustache表达式
}
});
}
}
1.3 找到元素节点上的指令开始编译元素节点
接下来就是要实现对元素节点和文本节点的编译,即实现compileElement()和compileText()方法,对于元素节点,首先获取到元素节点上的所有属性,然后开始遍历属性,判断是否有带"v-"的属性,如果有那么就是一个指令,然后对指令进行处理,指令的作用就是操作DOM,所以需要传入DOM节点,vm、指令表达式,如:
// 在Complier中添加一个compileElement()方法
class Complier {
compileElement(node){
let attributes = node.attributes; // 取出元素节点上的所有属性
[...attributes].forEach((attr) => {
let {name, value:expr} = attr; // 获取到带v-的指令名和指令表达式
if (this.isDirective(name)) { // 如果该属性名是vue指令,即以v-开头
let [, directive] = name.split("-"); // 去除v-,获取带参数和修饰符的指令名
let [directiveName, eventName] = directive.split(":"); // 将指令名和事件名拆开,如v-on:click, 则分别为 on click
CompileUtil[directiveName](node, expr, this.vm, eventName); // 传递DOM元素和指令表达式以及vm进行指令处理
}
});
}
}
上面使用到了CompileUtil编译工具对象专门进行各种指令的具体处理,添加一个CompileUtil对象里面有各种工具方法,如model、text,由于指令的作用,主要就是操作DOM,所以里面主要就是根据指令表达式从vm中获取到数据,然后操作DOM进行值的设置,如:
// 添加一个CompileUtil工具对象
var CompileUtil = {
getVal(vm, expr) { // 根据vm和指令表达式从vm中获取数据
return expr.split(".").reduce((data, current) => {
return data[current];
}, vm.$data);
},
model(node, expr, vm) {
const value = this.getVal(vm, expr); // 获取表达式的值
node.value = value; // 对于v-model指令,直接给DOM的value属性赋值即可
}
}
这里主要理解getVal()方法即可,这里用到了reduce()进行累加操作,主要是因为表达式,如果是多个点的形式,如"scholl.name",那么可以以vm中的data作为最初数据,然后遍历每个属性名,进行"."的累加操作,即vm.$data.scholl.name进行获取值。
1.4 找到带mustache表达式的文本节点开始编译文本节点
可以通过/\{\{(.+?)\}\}/正则表达式检测是否存在{{}},然后对{{}}表达式进行替换即可,如:
// 在Complier中添加一个compileText()方法
class Complier {
compileText(node){
const content = node.textContent;
if(/\{\{(.+?)\}\}/.test(content)) { // 检测文本节点中是否含有{{}}表达式
CompileUtil["text"](node, content, this.vm);
}
}
}
将整个文本内容交给CompileUtil中的text方法进行处理,即将{{}}替换掉然后用替换后的值再替换DOM的文本内容,如:
var CompileUtil = {
text(node, expr, vm) {
let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
return this.getVal(vm, args[1]);
});
node.textContent = content; // 替换文本节点的内容
}
}
至此,编译已经完成,已经可以在页面上看到vue指令和{{}}表达式编译后的数据了。
② 数据劫持
此时模板虽然编译成功了,但是当vue中data里的数据发生变化的时候,整个Vue对象并不能检测到数据发生了变化,因为vue中的data还没有添加数据劫持,即还没有通过Object.defineProperty()方法进行重新定义,所以需要在编译模板前对vue中data进行观察即数据劫持。
class Vue {
constructor(options) {
this.$el = options.el; // 保存传递的el属性
this.$data = options.data; // 保存传入的data属性
// 添加数据劫持,将数据全部转化成Object.defineProperty()来定义
new Observer(this.$data);
new Complier(this.$el, this); // 在创建Vue实例的过程中立即发起模板编译操作
}
}
上面是直接创建Observer对象并传入data进行数据劫持的,所以需要创建一个Observer类,在其构造函数中进行数据劫持,如:
class Observer {
constructor(data) {
this.observer(data);
}
observer(data) {
if (data && typeof data \=== "object") { // 如果传入的data是一个对象,遍历data对象中的所有属性改成Object.defineProperty的形式
for (let key in data) {
this.defineReactive(data, key, data[key]);
}
}
}
defineReactive(obj, key, value) {
this.observer(value); // 递归观察数据,如果data中的某个属性的属性值为对象,则也要进行观察
Object.defineProperty(obj, key, {
get() {
return value;
},
set: (newValue) => {
if (newValue != value) {
this.observer(newValue); // 如果赋值的是对象那么也进行新数据监控
value = newValue;
}
}
});
}
}
这样,当vue中data数据发生变化的时候就会被get()和set()劫持到,从而可以进行视图的更新。
③ 发布订阅模式
此时虽然已经可以劫持到vue中data的数据变化了,但是还不能进行页面的更新,因为目前还不知道页面上有哪些地方用到了该数据,所以必须在编译的时候,如果发现有某个地方用到了vue中的数据,那么就注册一个Watcher观察者,然后检测到数据发生变化的时候,通过发布者去通知所有观察者,观察者收到通知后进行页面的更新即可实现数据的双向绑定。
// 添加Watcher观察者类
class Watcher {
constructor(vm, expr, cb) {
Dep.target = this; // 每次创建Watcher对象的时候,将创建的Watcher对象在获取值的时候添加到dep中
this.vm = vm;
this.expr = expr;
this.cb = cb;
// 默认先存放旧值
this.oldValue = this.get();
Dep.target = null; // 添加Watcher对象后清空,防止每次获取数据的时候都添加Watcher对象
}
get() {
let value = CompileUtil.getVal(this.vm, this.expr);
return value;
}
update() {
let newValue = CompileUtil.getVal(this.vm, this.expr);
if (newValue !== this.oldValue) {
this.cb(newValue);
}
}
}
// 添加Dep发布者类
class Dep {
constructor() {
this.subs = []; // 存放所有的watcher
}
// 订阅
addSub(watcher) { // 添加watcher
this.subs.push(watcher);
}
// 发布,遍历所有的观察者,调用观察者的update进行页面的更新
notify() {
this.subs.forEach((watcher) => {
watcher.update();
});
}
}
创建Watcher对象的时候,需要传递vm和表达式,为了获取到表达式的值,同时传递了一个回调函数,主要是为了把变化后的值传递出去以便更新视图。那么应该在什么时候创建Watcher对象呢?应该在模板编译的时候,当检测到元素上使用了vue指令绑定data中的数据或者使用mustache表达式绑定data中的数据的时候,就需要创建一个Watcher对象了,如:
CompileUtil = {
model(node, expr, vm) {
new Watcher(vm, expr, (newValue) => {
node.value = newValue;
});
const value = this.getVal(vm, expr); // 获取表达式的值
node.value = value; // 对于v-model指令,直接给DOM的value属性赋值即可
},
getContentValue(vm, expr) {
return expr.replace(/\{\{(.+?)\}\}/g,(...args) => {
return this.getVal(vm, args\[1\]); // 重新获取最新的值
});
},
text(node, expr, vm) {
let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
new Watcher(vm, args[1], () => { //每次匹配到一个就创建一个Watcher对象
node.textContent = this.getContentValue(vm, expr);
});
return this.getVal(vm, args[1]);
});
node.textContent = content; // 替换文本节点的内容
}
}
Watcher对象创建好之后,那么又需要在什么时候添加到对应的发布对象中呢?当Watcher对象创建好之后,会立即去获取对应的值,从而会触发对应数据的getter方法,所以在调用getter方法的时候将创建的Watcher对象添加到发布者对象中,如:
class Observer {
defineReactive(obj, key, value) { // 每个key对应一个发布者对象
let dep = new Dep(); // 为data中的每一个属性创建一个发布者对象
Object.defineProperty(obj, key, {
get() {
Dep.target && dep.addSub(Dep.target); // 将创建的Watcher对象添加到发布者中
}
});
}
}
至此,已经实现了Vue的数据双向绑定,但还不支持计算属性。
④ 实现Computed计算属性
比如有计算属性{{getNewName}}和普通表达式{{scholl.name}},那么二者有什么共同点呢?就是不给是计算属性还是普通表达式,都是要从vm.\$data中去取值,当我们给{{getNewName}}创建Watcher的时候,我们希望获取到vm.\$data.getNewName的值,要想从vm.\$data中获取到值,那么必须将getNewName代理到vm.$data,然后获取getNewName的值时,直接执行计算属性函数即可。如:
class Vue {
this.$el = options.el;
this.$data = options.data;
let computed = options.computed;
let methods = options.methods;
new Observer(this.$data);
for (let key in computed) { // 计算属性代理到data上
Object.defineProperty(this.$data, key, { // 需要从$data中取值,所以需要将计算属性定义到this.$data上而不是vm上
get: () => {
return computed[key].call(this);
}
}
}
for (let key in methods) { // 将methods上的数据代理到vm上
Object.defineProperty(this, key, {
get() {
return methods[key];
}
});
}
// 为了方便,把数据获取操作,将data上的数据都代理到vm上
this.proxyVm(this.$data);
proxyVm(data) {
for (let key in data) {
Object.defineProperty(this, key, {
get() {
return data[key];
},
set(newValue) {
data[key] = newValue;
}
});
}
}
}
三、总结
总之就是,在创建Vue实例的时候给传入的data进行数据劫持,同时视图编译的时候,对于使用到data中数据的地方进行创建Watcher对象,然后在数据劫持的getter中添加到发布者对象中,当劫持到数据发生变化的时候,就通过发布订阅模式以回调函数的方式通知所有观察者操作DOM进行更新,从而实现数据的双向绑定。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。