一、什么是 MVVM 数据双向绑定
MVVM 数据双向绑定主要是指:数据变化更新视图,视图变化更新数据,如下图所示:
image.png即:
- 输入框内容变化时,Data 中的数据同步变化。即 View => Data 的变化。
- Data 中的数据变化时,文本节点的内容同步变化。即 Data => View 的变化。
其中,View 变化更新 Data ,可以通过事件监听的方式来实现,所以我们本文主要讨论如何根据 Data 变化更新 View。
我们会通过实现以下 4 个步骤,来实现数据的双向绑定:
1、实现一个监听器 Observer ,用来劫持并监听所有属性,如果属性发生变化,就通知订阅者;
2、实现一个订阅器 Dep,用来收集订阅者,对监听器 Observer 和 订阅者 Watcher 进行统一管理;
3、实现一个订阅者 Watcher,可以收到属性的变化通知并执行相应的方法,从而更新视图;
4、实现一个解析器 Compile,可以解析每个节点的相关指令,对模板数据和订阅器进行初始化。
以上四个步骤的流程图表示如下:
二、监听器 Observer 实现
监听器 Observer 的实现,主要是指让数据对象变得“可观测”,即每次数据读或写时,我们能感知到数据被读取了或数据被改写了。要使数据变得“可观测”,Vue 2.0 源码中用到 Object.defineProperty() 来劫持各个数据属性的 setter / getter,Object.defineProperty 方法,在 MDN 上是这么定义的:
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。
Observer 实现
(1)字面量定义对象
首先,我们先看一下假设我们通过以下字面量的方式定义一个对象:
let person = {
name:'tom',
age:15
}
我们可以通过 person.name
和 person.age
直接读写这个 person 对应的属性值,但是,当这个 person 的属性被读取或修改时,我们并不知情。那么,应该如何定义一个对象,它的属性被读写时,我们能感知到呢?
(2)Object.defineProperty() 定义对象
假设我们通过 Object.defineProperty() 来定义一个对象:
let val = 'tom'
let person = {}
Object.defineProperty(person,'name',{
get(){
console.log('name属性被读取了...');
return val;
},
set(newVal){
console.log('name属性被修改了...');
val = newVal;
}
})
我们通过 object.defineProperty() 方法给 person 的 name 属性定义了 get() 和set()进行拦截,每当该属性进行读或写操作的时候就会触发get()和set() ,这样,当对象的属性被读写时,我们就能感知到了。测试结果图如下所示:
image.png(3)遍历数据对象,将每一个属性转化成getter/setter形式
通过第(2)步的方法,person 数据对象已经是“可观测”的了,能满足我们的需求了。但是如果数据对象的属性比较多的情况下,我们一个一个为属性去设置,代码会非常冗余,所以我们进行以下封装,从而让数据对象的所有属性都变得可观测:
/**
* 循环遍历数据对象的每个属性
*/
function observable(obj) {
if (!obj || typeof obj !== 'object') {
return;
}
let keys = Object.keys(obj);
keys.forEach((key) => {
defineReactive(obj, key, obj[key])
})
return obj;
}
/**
* 将对象的属性用 Object.defineProperty() 进行设置
*/
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
console.log(`${key}属性被读取了...`);
return val;
},
set(newVal) {
console.log(`${key}属性被修改了...`);
val = newVal;
}
})
}
通过以上方法封装,我们可以直接定义 person:
let person = observable({
name: 'tom',
age: 15
});
这样定义的 person 的 两个属性都是“可观测”的。
问题:defineReactive(obj, key, obj[key])方法已经知道obj和key了,为什么还要传obj[key]?
如果不传obj[key],后面val得写成obj[key]的形式,那就会再次触发getter和setter,会造成死循环。
三、订阅器 Dep 实现
完成了数据的'可观测',即我们知道了数据在什么时候被读或写了,那么,我们就可以在数据被读或写的时候通知那些依赖该数据的视图更新了,为了方便,我们需要先将所有依赖收集起来,一旦数据发生变化,就统一通知更新。其实,这就是前一节所说的“发布订阅者”模式,数据变化为“发布者”,依赖对象为“订阅者”。
现在,我们需要创建一个依赖收集容器,也就是消息订阅器 Dep,用来容纳所有的“订阅者”。订阅器 Dep 主要负责收集订阅者,然后当数据变化的时候后执行对应订阅者的更新函数。
创建消息订阅器 Dep:
function Dep () {
this.subs = [];
}
Dep.prototype = {
addSub: function(sub) { // 收集 Watcher
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) { // 调用 Watcer实例的update方法更新视图
sub.update();
});
}
};
Dep.target = null;
有了订阅器,我们再将 defineReactive 函数进行改造一下,向其植入订阅器:
defineReactive: function(data, key, val) {
var dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function getter () {
if (Dep.target) {
dep.addSub(Dep.target); // 属性被访问时进行 Watcher的收集
}
return val;
},
set: function setter (newVal) {
if (newVal === val) {
return;
}
val = newVal;
dep.notify(); // 属性被修改时通知 Watcher进行更新
}
});
}
从代码上看,我们设计了一个订阅器 Dep 类,该类里面定义了一些属性和方法,这里需要特别注意的是它有一个静态属性 Dep.target
,这是一个全局唯一 的Watcher,因为在同一时间只能有一个全局的 Watcher 被计算,另外它的自身属性 subs 也是 Watcher 的数组。
四、订阅者 Watcher 实现
订阅者 Watcher 在初始化的时候需要将自己添加进订阅器 Dep 中,那该如何添加呢?我们已经知道监听器Observer 是在 get 函数执行了添加订阅者 Wather 的操作的,所以我们只要在订阅者 Watcher 初始化的时候触发对应的 get 函数去执行添加订阅者操作即可,那要如何触发 get 的函数,再简单不过了,只要获取对应的属性值就可以触发了,核心原因就是因为我们使用了 Object.defineProperty( ) 进行数据监听。这里还有一个细节点需要处理,我们只要在订阅者 Watcher 初始化的时候才需要添加订阅者,所以需要做一个判断操作,因此可以在订阅器上做一下手脚:在 Dep.target 上缓存下订阅者,添加成功后再将其去掉就可以了。订阅者 Watcher 的实现如下:
function Watcher(vm, exp, cb) {
this.vm = vm;
this.exp = exp;
this.cb = cb;
this.value = this.get(); // 将自己添加到订阅器的操作
}
Watcher.prototype = {
update: function() {
this.run();
},
run: function() {
var value = this.vm.data[this.exp];
var oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
},
get: function() {
Dep.target = this; // 全局变量 订阅者 赋值
var value = this.vm.data[this.exp] // 访问data里的属性,触发getter,将 Wacther添加打subs订阅器数组
Dep.target = null; // 全局变量 订阅者 释放
return value;
}
};
订阅者 Watcher 分析如下:
订阅者 Watcher 是一个 类,在它的构造函数中,定义了一些属性:
vm:一个 Vue 的实例对象;
exp:是 node 节点的 v-model 等指令的属性值 或者插值符号中的属性。如 v-model="name",exp 就是name;
cb:是 Watcher 绑定的更新函数;
当我们去实例化一个渲染 watcher 的时候,首先进入 watcher 的构造函数逻辑,就会执行它的 this.get() 方法,进入 get 函数,首先会执行:
Dep.target = this; // 将自己赋值为全局的订阅者
实际上就是把 Dep.target 赋值为当前的渲染 watcher ,接着又执行了:
let value = this.vm.data[this.exp] // 强制执行监听器里的get函数
在这个过程中会对 vm 上的数据访问,其实就是为了触发数据对象的 getter。
每个对象值的 getter 都持有一个 dep,在触发 getter 的时候会调用 dep.depend() 方法,也就会执行this.addSub(Dep.target),即把当前的 watcher 订阅到这个数据持有的 dep 的 watchers 中,这个目的是为后续数据变化时候能通知到哪些 watchers 做准备。
这样实际上已经完成了一个依赖收集的过程。那么到这里就结束了吗?其实并没有,完成依赖收集后,还需要把 Dep.target 恢复成上一个状态,即:
Dep.target = null; // 释放自己
而 update() 函数是用来当数据发生变化时调用 Watcher 自身的更新函数进行更新的操作。先通过 let value = this.vm.data[this.exp]; 获取到最新的数据,然后将其与之前 get() 获得的旧数据进行比较,如果不一样,则调用更新函数 cb 进行更新。
至此,简单的订阅者 Watcher 设计完毕。
五、解析器 Compile 实现
通过监听器 Observer 订阅器 Dep 和订阅者 Watcher 的实现,其实就已经实现了一个双向数据绑定的例子,但是整个过程都没有去解析 dom 节点,而是直接固定某个节点进行替换数据的,所以接下来需要实现一个解析器 Compile 来做解析和绑定工作。解析器 Compile 实现步骤:
- 解析模板指令,并替换模板数据,初始化视图;
- 将模板指令对应的节点绑定对应的更新函数,初始化相应的订阅器;
我们下面对 '{{变量}}' 这种形式的指令处理的关键代码进行分析,感受解析器 Compile 的处理逻辑,关键代码如下:
compileText: function(node, exp) {
var self = this;
var initText = this.vm[exp]; // 获取属性值
this.updateText(node, initText); // dom 更新节点文本值
// 将这个指令初始化为一个订阅者,后续 exp 改变时,就会触发这个更新回调,从而更新视图
new Watcher(this.vm, exp, function (value) {
self.updateText(node, value);
});
}
网友评论