前言
每次谈到vue的双向数据绑定原理,大部分人都会说:Vue是通过Object.defineProperty方法属性拦截的方式,将data对象里面的每个数据都转化成gettter/setter,当数据变化的时候通知视图更新。虽然一句话把大概原理概括了,但是内部具体如何实现呢?
思路分析
MVVM双向数据绑定,主要是:视图变化更新数据,数据变化更新视图。
要实现这两个过程,关键在于数据变化更新视图。因为视图变化更新数据,我们可以通过事件监听的方式来实现。
数据变化更新视图的关键在于,**如何及时知道数据发生了变化“,这样的话我们只需要在数据变化的时候更新视图。
使数据对象变得”可观测“
数据的每次读写操作我们都能收到通知,将之称为”可观测“
要将数据变得”可观测“,就需要借助Object.defineProperty方法,关于该方法,MDN上写着:
Object.defineProperty()方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象
一个普通对象的读写操作,我们是不会收到通知的。比如
let car = {
brand: 'BWM',
price: 3000
}
当我们对car.brand、car.price进行读写的时候,并没有一个机制来通知我们。
如果用Object.defineProperty对该对象进行改写
let car = {};
let val = 3000;
Object.defineProperty(car, 'price', {
get(){
console.log('price属性被读取了');
return val;
},
set(newVal){
console.log('price属性被修改了');
val = newVal;
}
}
通过Object.defineProperty()方法给car定义了一个price属性,并把这个属性的读和写分别使用get()和set()进行拦截,每当改属性进行读或写操作的时候就会触发get()和set()
car对象变成了可观测的对象了。
/**
* 把一个对象的每一项都转化成可观测对象
* @param { Object } obj 对象
*/
function observable(obj){
if(!obj || typeof obj !== 'object') {
return;
}
let keys = Object.keys(obj);
keys.forEach((key) => {
defineReactive(obj, key, obj[key])
})
return obj;
}
/**
* 使一个对象转化成可观测对象
* @param { Object } obj 对象
* @param { String } key 对象的key
* @param { Any } val 对象的某个key的值
*/
function defineReactive(obj, key, val){
Object.defineProperty(obj, key, {
get(){
console.log(`${key}属性被读取了`);
return val;
},
set(newVal){
console.log(`${key}属性被读取了`);
val = newVal;
}
})
}
依赖收集
完成了数据的”可观测“,即我们知道了数据在什么时候被读或写了,那么可以在数据被读或者写的时候通知那些依赖该数据的视图更新了。为了方便,需要先将所有的依赖收集起来,一旦数据发生变化,就统一通知更新。这就是典型的”发布者订阅者“模式。数据变化为”发布者“,依赖对象为”订阅者“。
现在我们需要创建一个依赖收集容器,也就是消息订阅器Dep,用来容纳所有的”订阅者“。订阅者Dep主要负责收集订阅者,然后当数据变化的时候执行对应订阅者的更新函数。
class Dep{
constructor(){
this.sub = []
},
//增加订阅者
addSub(sub){
this.subs.push(sub);
},
//判断是否增加订阅者
depend(){
if(Dep.target){
this.addSub(Dep.target);
}
},
//通知订阅者更新
notify(){
this.subs.forEach((sub) => {
sub.update();
})
}
}
Dep.target = null;
有了订阅器,再将defineReactive函数改造一下,向其置入订阅器
function defineReactive(obj, key, val){
let dep = new Dep();
Object.defineProperty(obj, key, {
get(){
dep.depend();
console.log(`${key}属性被读取了`);
return val;
},
set(newVal){
val = newVal;
console.log(`${key}属性被修改了`);
dep.notify();
}
}
}
设计的订阅器Dep里面有一个静态属性target,这是唯一一个全局的Watcher。这是一个非常巧妙的设计,因为同一时间,只能有一个全局Watcher被计算,另外它的自身属性subs也是Watcher的数组。
订阅者Watcher
订阅者Watcher在初始化的时候需要将自己添加到订阅器Dep中。从上文中知道Observer在get函数中将Watcher添加到订阅器Dep中,那么我们只需要在订阅者Watcher初始化的时候,触发对应的get函数就可以。如何触发get函数呢?非常简单就是访问对应的属性值。
这里有一个细节需要处理,我们只需要在Watcher初始化的时候将它添加到Dep中,其余时候是不需要再重复添加,那么可以在订阅器中Dep.target做一个缓存,缓存当前要添加的订阅者,添加成功之后将其去掉。
Class Watcher{
contructor(vm, exp, cb){
this.vm = vm;
this.exp = exp;
this.cb = cb;
this.value = this.get();
},
update(){
let value = this.vm.data[this.exp];
let oldValue = this.value;
if(value !== oldValue){
this.value = value;
this.cb.call(this.vm, value, oldValue);
}
},
get(){
Dep.target = this;//缓存自己
let value = this.vm.data[this.exp];//强行执行监听器的get函数
Dep.target = null;//释放自己
return value;
}
}
订阅者Watcher是一个类,在它的构造函数中,定义了一些属性:
- vm,是一个vue实例对象
- exp,是node节点上v-model或者v:on等指令的属性值,比如v-model=“name”,那么exp=“name”
- cb,Watcher绑定的更新函数
当我们实例化一个Watcher的时候首先会调用它的get函数,进行一系列的初始化,包括将Watcher的注入到当前的Dep监听器中。这个过程中对vm上的数据访问,实际上就是为了触发数据对象的getter。
每个对象值得getter都持有一个dep,在触发getter的时候会调用dep.depend()方法,也就会执行this.addSub(Dep.target),==》这个操作是把当前的watcher订阅到这个数据持有的dep的subs中,这个目的是为后续数据变化时候能通知到哪些subs做准备。
总结
实现数据的双向绑定,首先要对数据进行劫持监听,所以需要一个监听器Observer,用来监听所有属性。如果属性发生变化,就要告诉订阅者Watcher是否需要更新。由于订阅者有很多,那么就需要一个消息订阅者Dep来专门收集这些订阅者,然后再监听器Observer和订阅者Watcher之间进行统一管理。