Vue双向绑定原理

参考文章-

摘要

Vue内部通过Obeject.defineProperty方法属性拦截的方式,把data对象里每个数据的读写转化为getter/setter,当数据变化时通知视图更新。

思路分析

MVVM数据双向绑定:数据变化时更新视图,视图变化时更新数据。
也就是说:

  • 输入框内容变化时,data中的数据同步变化。即view => model的变化
  • data中的数据变化时,文本节点的内容同步变化。即model => view 的变化 要实现这个过程,关键点在于数据变化时如何更新视图,因为视图变化更新数据我们可以通过事件的监听方式来实现。所以着重讨论数据变化如何更新视图。

数据变化的关键点在于是怎么知道数据发生了变化,知道数据在何时发生了变化,那我们就只需在数据变化的时候取更新视图即可。

使数据对象变得“可观测”

数据的每次读和写都能够被看见,即我们能够知道数据什么时候被读取了或者数据什么时候被改写了,这种特性被称为数据的“可观测”

要将数据变成可观测的,就需要借助Object.defineProperty方法了,MDN的介绍如下:

1
Object.defineProperty()方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回这个对象。

我们就可以使用这个方法让数据变得“可观测”
首先,我们定义一个数据对象car:

1
2
3
4
let car = {
'brand': 'BMW',
'price': 3000
}

我们定义了car的品牌brandBMW,价格price是3000.现在就可以通过car.brandcar.price直接读写这个car对应的属性值。但是当这个car的属性被读取或被修改时,我们并不知情。那么应该怎么让car主动告诉我们它被修改了呢。

使用object.defineProperty改写上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
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就已经可以主动告诉我们它的属性的读写情况了,这就意味着这个car对象已经是“可观测”的。

把car的所有属性都变得可观测的,可以编写如下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 把一个对象的每一项都转化成可观测对象
function observable (obj) {
if (!obj || typeof obj !== 'object') {
return false
}
let keys = Object.keys(obj)
keys.forEach(key => {
defineReactive(obj, key, obj[key])
})
return obj
}

// 使一个对象转化成可观测对象
function defineReactive(obj, key, val){
Object.defineProperty(obj, key, {
get() {
console.log(`${key}属性被读取了`)
return val
},
set(newVal) {
console.log(`${key}属性被修改了`)
val = newVal
}
})
}

这样就可以把car定义成为可观测的对象了

依赖收集

完成了数据的可观测,我们就可以知道数据在什么时候被读或写了,那么我们就可以在数据被读或者写的时候通知哪些依赖该数据的视图更新了。为了方便,我们需要先将所有的依赖对象收集起来,一旦数据发生变化,就统一通知更新。这就是典型的发布订阅者模式,数据变化为“发布者”,依赖对象为“订阅者”。

现在我们需要创建一个依赖收集容器,也就是消息订阅器Dep,用来容纳所有的“订阅者”。订阅器Dep主要负责收集订阅者,然后当数据变化的时候执行对应订阅者的更新函数。

创建消息订阅器Dep:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Dep {
constructor() {
this.subs = []
},
// 增加订阅者
addSub(sub){
this.subs.push(sub)
},
// 判断是否增加订阅者
depend() {
if(Dep.target) {
this.addSun(Dep.target)
}
},
// 通知订阅者更新
notify() {
this.subs.forEach(sub => {
sub.update()
})
}
}
Dep.target = null

有了订阅器,再将defineReactive函数进行改造一下,向其植入订阅器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function defineReactive (obj, key, val) {
let dep = new Dep()
Object.defineProperty(obj, key, {
get() {
dep.depend()
console.log(`${key}属性被读取了`)
return val
},
set(newVal) {
console.log(`${key}属性被修改了`)
val = newVal
dep.notify() // 数据变化时通知所有订阅者
}
})
}

我们设计了一个订阅器Dep类,该类里面定义了一些属性和方法,这里需要特别主义的是它有一个静态属性target,这是一个全局唯一的watcher,这是以恶搞非常巧妙的设计,因为在同一时间只能有一个全局的watcher被计算,另外它的subs自身属性也是watcher的数组。

将订阅器Dep添加订阅者的操作设计在getter里面,这是为了让watcher初始化时进行触发,因此需要判断是否添加订阅者。在setter函数里面,如果数据变化,就回去通知所有订阅者,订阅者们就会去执行对应的更新的函数。

到此,订阅器Dep设计完毕,接下来我们设计订阅者watcher。

订阅者watcher

订阅者watcher在初始化的时候需要将自己添加进订阅器Dep中,那么该如何添加呢?我们已经知道监听器Observer是在get函数执行了添加订阅者watcher的操作的,所以我们只要在订阅者watcher初始化的时候触发对应的get函数去执行添加订阅者操作即可,那要如何触发get函数呢?只要获取对应的属性值就可以触发了,核心原因就是因为我们使用了Object.defineProperty()进行数据监听。我们只要在订阅者watcher初始化的时候才需要添加订阅者。所以需要做一个判断操作,因此可以在订阅器上做一下手脚:在Dep.target上缓存下订阅者,添加成功后在未将其去掉就可以了。订阅者watcher的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Watcher {
constructor(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 oldVal = this.value
if (value !== oldVal) {
this.value = value
this.cb.call(this.vm, value, oldVal)
}
},
get() {
Dep.target = this //缓存自己
let value = this.vm.data[this.exp] //强制执行监听器里的get函数
Dep.target = null //释放自己
return value
}
}

订阅者是一个类,在他的构造函数中,定义了一些属性:

  • vm:一个Vue的实例对象
  • exp:时node节点的v-model或v-on:clock等指令的属性值。如v-model='name',exp就是name
  • cb: 时watcher绑定的更新函数

当我们去实例化一个渲染Watcher的时候,首先进入watcher的构造函数逻辑,就会执行它的this.get()方法,进入get函数,首先会执行:

1
Dep.target = this //缓存自己

实际上九十八Dep.target赋值为当前的渲染watcher,接着又执行了:

1
let value = this.vm.data[this.exp]// 强制执行监听器里的get函数

因为当前vm的数据依赖收集已经完成,那么对应的渲染Dep.target也需要改变。而update()函数是用来当数据发生变化时调用watcher自身的更新函数进行更新的操作。先通过let value = this.vm.data[this.exp]获取到最新的数据,然后将其与之前get()获得的旧数据进行比较,如果不一样,则调用cb进行更新。
至此,简单的订阅者设计完毕

总结

实现数据的双向绑定,首先要对数据进行劫持监听,所以需要设置一个监听器observer,用来监听所有属性。如果属性发生变化了,就需要告诉订阅者watcher看是否需要更新。因为订阅者是有很多个,所以我们需要有一个消息订阅器Dep来专门收集这些订阅者,然后在监听器observer和订阅者watcher之间进行统一管理的。

-------------本文结束感谢您的阅读-------------