vue响应式数据原理

代理

下面这段代码是vue使用的典型方式:

1
2
3
4
<div id="app-5">
<p>{{ message }}</p>
<button v-on:click="reverseMessage">逆转消息</button>
</div>
1
2
3
4
5
6
7
8
9
10
11
var app5 = new Vue({
el: '#app-5',
data: {
message: 'Hello Vue.js!'
},
methods: {
reverseMessage: function () {
this.message = this.message.split('').reverse().join('')
}
}
})

base

我们可以看到当我们给this.message赋值时,视图会自动更新。这就是vue数据响应系统做的事情,当然vue做的事情远比这个复杂地多,但这是核心理念。原理也很简单,vue帮我们代理了数据的赋值操作,在数据赋值时进行了DOM的更新,这些对于vue使用者是不可见的,也是无需考虑的。

实现数据代理在js中有两种方式,一个是ES5的gettersetter方法;一个是ES6的proxyapi。vue2.5及以下采用的是gettersetter方法;即将出来的vue3.0全部改为proxy方式来实现。

getter和setter

Object提供一个方法definePropery可以让我们给一个对象的属性定义gettersetter,从而代理对象属性的取值和赋值操作,用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
var o = {};
Object.defineProperty(o, "b", {
get: function(){
alert('我在取值');
},
set: function(newValue){
alert('我在赋值');
},
enumerable : true,
configurable : true
});
var a = o.b; // 弹出“我在取值”
o.b=5; // 弹出“我在赋值”

有了这个特性,我们就可以实现最简单的双向数据绑定。比如vue中的v-model效果。

1
<input type="text" id="name"></input>
1
2
3
4
5
6
7
8
9
10
var data = {};
var nameDom = document.querySelector('#name');
Object.defineProperty(data, 'value', {
get: function(){
return nameDom.value;
},
set: function(value){
nameDom.value = value;
}
})

model
由此我们便实现了最简单的双向数据绑定。当然,vue实现响应式数据的思路和上面不一样,要复杂的多,但是基本理念就是通过劫持数据的赋值和取值操作来完成的。

观察者模式

我们要想实现vue的响应式数据,就要给vue初始化对象的data和props的所有属性设置setter和getter函数。vue源码src/core/instance/state.jsinitData函数有如下代码,其中observe都是循环遍历data对象,给每个属性都设置setter和getter。

1
2
3
// src/core/instance/state.js
// observe data
observe(data, true /* asRootData */)

我们可以知道的是setter函数中的操作肯定是要更新DOM的,那每个数据绑定的DOM不同,绑定的属性也不同,如果像我们上面那样把每个响应式数据和具体DOM的属性绑定起来,就太麻烦和复杂了。Vue采取的策略是虚拟DOM,每次数据setter操作,都会根据你写的template或者render生成虚拟DOM,然后和之前的虚拟DOM进行比较,如果有不同,则进行DOM更新,而且只更新有变化的部分;如果相同就不做操作。这种方式可以解决我们的问题,性能也没有问题。在vue实例mount的过程中会执行以下代码:

1
2
3
updateComponent = () => {
vm._update(vm._render(), hydrating)
}

这句就是用来执行DOM的更新渲染的,我们在响应式数据的setter中应该执行updateComponent就能达到我们的目的了。看起来很简单是吧,但是现在有两个问题:

  1. 我们在data和props中声明的属性不一定都绑定到Dom上了,如果是没有绑定到Dom上的数据,在进行setter的时候,也要DOM更新操作,虽然不会引起真正的DOM更新,但也是很浪费性能。
  2. 我们数据setter可能会不止有Dom更新的任务,比如watch了一个属性,那么这个属性就有Dom更新和watch绑定的回调两个任务。

数据setter绑定不同的任务,在数据改变时,执行所有绑定的任务,这个不就是观察者模式嘛。。。

观察者模式一个典型的例子就是DOM元素的事件绑定

1
2
3
4
var btnDom = document.getElementById('btn');
btnDom.addEventListener('click', function(){
console.log('click事件发生了,做点啥...');
});

我们给btnDom绑定click事件,就相当于在观察btnDom,当btnDom被点击,就会调用我们绑定的事件。现在我们的响应式数据(data和props)就是我们观察的对象,我们把需要执行的任务放入响应式数据的setter里,在响应式数据被赋值的时候,执行这些任务。观察者模式这个名字是和现实的一个类比,有的有观察者实例,有的直接注册回调函数,其实本质是一样的,这些观察者实例或者回调函数都在观察的动作中被放入被观察者的实例中的,在被观察者发生改变时,执行注册在自己身上的回调函数或者通知观察者。下面是一段典型的观察者模式实现:

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
26
27
28
29
30
31
32
33
34
35

//被观察者
class Subject{
constructor(){
this.observerList = [];
}

addObserver(observer){
this.observerList.push(observer);
}

removeObserver(observer){
const index = this.observerList.findIndex(item => item === observer);
if(index !== -1){
this.observerList.splice(index, 1);
}
}

notify(context){
const length = this.observerList.length;
for(let i = 0; i<length; i++){
const observer = this.observerList[i];
observer.notify(context);
}
}
}

//观察者
class Observer{
constructor(){
this.notify = function(){
// ...
};
}
}

Subject实例通过addObserverremoveObserver来添加和删除观察者,然后在合适的时机通过notify方法通知所有的观察者。vue也是类似的实现机制,不过Vue的设计比较巧妙,实现形式有所不同。vue有3个类是用来处理响应式数据的观察者模式:ObserverWatcherDep

  • Observer, 该类主要作用是用来定义属性的getter和setter方法
  • Watcher, 观察者类,且同时用于$watch实例方法和watch指令
  • Dep, 观察者容器,每一个响应式数据的属性都拥有一个自己独立的Dep实例,盛放自己的观察者

我们上面写的观察者模式或者是事件绑定,需要我们主动去添加观察者,那么在响应式数据这个模式当中我们应该在何时去收集属性自己的观察者那?答案是在响应式数据的getter中收集。因为被观察的数据在求值的时候肯定会触发getter函数,这是一个很好的时机,而且也能避免没有参与DOM更新的属性被绑定DOM更新的观察者。所以,vue采用的方式是在getter中收集观察者,在setter中通知观察者

Observer类型中有一个defineReactive函数,这个函数主要是用来定义属性的getter和setter方法,下面是一个简化版的defineReactive函数,去掉了一个边界情况的数据,只考虑对象这种响应式数据。

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
26
27
28
29
30
31
32
 /**
* Define a reactive property on an Object.
*/
export function defineReactive (
obj: Object,
key: string,
shallow?: boolean
) {
const dep = new Dep()
val = obj[key];
let childOb = !shallow && observe(val)
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: function reactiveGetter () {
if (Dep.target) {
dep.depend()
}
return val
},

set: function reactiveSetter (newVal) {
/* eslint-disable no-self-compare */
if (newVal === val || (newVal !== newVal && val !== val)) {
return
}
val = newVal
childOb = !shallow && observe(newVal)
dep.notify()
}
})
}

上面代码还是很清楚简单的,在getter中的dep.depend就是收集观察者;在setterdep.notify就是通知观察者。每一个响应式属性都拥有自己的闭包Dep实例,这个Dep实例中装载这所有该属性的观察者。那么Dep.target是什么东西那?它就是我们所要收集的观察者。这里Dep.target可能会有点迷糊,我们先来考虑一下vue的$watch实例方法或者watch指令是怎么用的:

1
vm.$watch(expOrFn, function(){...});

expOrFn的值发送变化时,执行回调函数。而$watch方法就是新建了一个Watcher实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Vue.prototype.$watch = function (
expOrFn: string | Function,
cb: any,
options?: Object
): Function {
const vm: Component = this
if (isPlainObject(cb)) { // 用于处理watch指令cb是一个对象,带有immediate或者deep参数的情况,createWatcher中是整理参数,创建Watcher实例
return createWatcher(vm, expOrFn, cb, options)
}
options = options || {}
const watcher = new Watcher(vm, expOrFn, cb, options)
if (options.immediate) {
cb.call(vm, watcher.value)
}
return function unwatchFn () {
watcher.teardown()
}
}

现在我们需要明确的一点就是,其实通过改变数据来更新DOM这个操作,其实就是创建了一个渲染函数的Watcher实例,跟我们使用$watch方法去观察一个数据是一样的,只不过这个操作是Vue主动做的,只不过它观察的是所有<template>或者render中的数据。下面这段代码就是创建渲染函数的Watcher实例:

1
2
3
4
5
6
7
new Watcher(vm, updateComponent, noop, {
before () {
if (vm._isMounted) {
callHook(vm, 'beforeUpdate')
}
}
}, true /* isRenderWatcher */)

updateComponent函数上面已经说过,是用来更新DOM操作的。我们先来看一下Watcher类构造函数的参数,Watcher(vm, expOrFn, cb, options)一共有4个:

  • vm, Vue实例
  • expOrFn, 被观察的表达式或者函数,用来求值同时触发被观察数据的getter函数,用于收集该观察者,所以我们上面说的Dep.target就是在expOrFn求值之前被赋值为该观察者实例的
  • cd,回调函数,被观察数据改变时,执行的回调函数
  • options, 一些参数设置
  • isRenderWatcher,是否为渲染函数的观察者

那我们看这个渲染函数的观察者实例的构造参数就有些奇怪,因为它的回调是noop,noop是定义了一个空函数。这不是很奇怪吗,在数据变化的时候什么都不做,怎么更新DOM?实际上在数据发生变化的时候,Watcher实例都要对expOrFn重新求一遍值,这样才能知道expOrFn的值有没有变化,进而决定是否要执行cb;而updateComponent这个更新DOM的函数同时满足触发getter和更新DOM的需求,所以在这里就不需要设置cb了,同样如果我们在编码时,有这样同时满足收集依赖和满足回调的函数,也可以这样用。

避免重复收集观察者

那每次数据发生变化的时候,观察者都对expOrFn求值,岂不是每次都会触发getter函数,造成依赖重复收集?的确会,而且即便在一次求值过程中,也可能触发同一个数据多次(比如同一个属性出现在模板多个地方),不过Vue已经实现了避免收集重复依赖的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Watcher{
addDep (dep: Dep) {
const id = dep.id
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id)
this.newDeps.push(dep)
if (!this.depIds.has(id)) {
dep.addSub(this)
}
}
}
}

class Dep{
addSub (sub: Watcher) {
this.subs.push(sub)
}

depend () {
if (Dep.target) {
Dep.target.addDep(this)
}
}
}

上述代码就是Vue用来避免收集重复依赖的,我们知道,在响应式数据的getter中,我们会调用该属性所拥有的Dep实例的depend方法来收集观察者。我们可以看到在depend方法中调用了观察者实例方法addDep,而在addDep方法中我们可以看到又调用了Dep的实例方法addSubDep这个容器加入观察者。有点绕,两个类来回调用,目的只有一个,就是避免收集重复的观察者

我们可以看到addDep方法中有3个值,决定了是否添加观察者,我们先来说明一下这3个值的作用:

  • newDepIds,本次求值所收集的Dep实例Id列表,用来避免本次求值的重复收集,在每次求值完成之后都会被赋值给depIds,然后被清空
  • depIds,上次求值所收集的Dep实例Id列表,用来避免两次求值之间的重复收集以及去除废弃的观察者
  • newDeps, 本次求值所收集的Dep实例列表, 在每次求值完成之后都会被赋值给deps,然后被清空

这样看代码的逻辑就很清楚了,先判断在本次求值中是否已经收集了该Dep实例,如果没有,则将该Dep的id添加到newDepIds,然后再判断,该Dep实例是否存在于上次求值的Dep实例Id列表,如果没有,则将该观察者放入Dep实例中,作为该Dep实例所属的响应式数据所拥有的观察者

在每次求值之后,都会执行cleanupDeps,用于给newDepIds赋值给depIds,清空newDepIds, 并且去除废弃的观察者,下面这段代码就是去除废弃的观察者

1
2
3
4
5
6
7
8
9
10
cleanupDeps () {
let i = this.deps.length
while (i--) {
const dep = this.deps[i]
if (!this.newDepIds.has(dep.id)) {
dep.removeSub(this)
}
}
// 省略...
}

凡是在上次求值过程中存在的Dep实例,在本次求值中不存在了,说明该Dep实例已经被废弃了,更直白的说法就是该Dep实例所属的响应式数据已经不在本次求值过程中了,需要把该观察者Dep实例中去除。

异步更新队列

在同一个js任务队列中,我们可能改变多个模板中的响应式数据,这样会造成多次触发渲染函数的观察者,造成多次重复地渲染DOM,造成性能浪费。解决这个问题的办法在于我们要有一个合适的时机统一处理一个js任务队列中所有被触发的观察者,对于重复的观察者只执行一次。这个合适的时机就是微任务,在js任务队列执行完之后,会立即执行在本次任务队列中产生的所有微任务。且在两次js任务队列之间会穿插着DOM更新,所以在微任务中把所有相关的数据更新,是最优的。下面queueWatcher是异步模式下观察者被通知时执行的操作,queue存放在本次任务队列中所有被通知的观察者nextTick是Vue实现的微任务机制(在不支持微任务的情况下,回退到宏任务),flushSchedulerQueue则是用来执行queue中的观察者,并清空queue

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
if (has[id] == null) {
has[id] = true
queue.push(watcher)
// queue the flush
if (!waiting) {
waiting = true

if (process.env.NODE_ENV !== 'production' && !config.async) {
flushSchedulerQueue()
return
}
nextTick(flushSchedulerQueue)
}
}
}