Vue 响应式原理

只要涉及到 MVVM 框架就不得不提到双向绑定,毕竟它是 Vue 的三要素之一。

  • 响应式:例如如何监听数据变化,其中的实现方法就是双向绑定
  • 模板引擎:如何解析模板
  • 渲染:Vue 如何将监听到的数据变化和解析后的 HTML 进行渲染

目前业界分为两个大的流派,一个是以 React 为首的单向数据绑定,另一个是以 Vue、Angular 为主的双向数据绑定。其实三大框架都既可以双向绑定也可以单向绑定,比如 React 可以手动绑定 onChange 和 value 实现双向绑定,也可以调用一些双向绑定库。Vue 也加入了 props 这种单向流的 API,不过都并非其主要卖点。单向或双向的优劣也不在此文讨论范围内。

可以实现双向绑定的方法有很多:KnockoutJS 基于观察者模式的双向绑定,Ember 基于数据模型的双向绑定,Angular 基于脏检查的双向绑定。本篇将着重讲述 Vue 基于数据劫持的双向绑定

常见的基于数据劫持的双向绑定有两种实现,一个是目前 Vue 在用的 Object.defineProperty,另一个是 ES2015 中新增的 Proxy,而 Vue 将在 3.0 版本后用 Proxy 代替 Object.defineProperty。严格来讲 Proxy 应该被称为『代理』而非『劫持』,不过由于作用有很多相似之处,在下文中就不再做区分,统一叫『劫持』。

下图说明了双向绑定的基本体系。

image-20210815095258123

(一)数据劫持

利用 Object.defineProperty 劫持对象的访问器,在属性值发生变化时可以获取变化,然后根据变化进行后续响应。在 vue3.0 中通过 Proxy 代理对象进行类似的操作。

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
// 这是将要被劫持的对象
const data = {
name: '',
}

function say(name) {
if(name === '古天乐') {
console.log('给大家推荐一款超好玩的游戏');
} else if (name === '渣渣辉') {
console.log('戏我演过很多,可游戏我只玩贪玩蓝月');
} else {
console.log('来做我的兄弟');
}
}

// 遍历对象,对其属性值进行劫持
Object.keys(data).forEach(function(key) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function() {
console.log('get');
},
set: function(newVal) {
// 当属性值发生变化时我们可以进行额外操作
console.log(`大家好,我系${newVal}`);
say(newVal);
}
})
})

data.name = '渣渣辉';
//⼤家好,我系渣渣辉
//戏我演过很多,可游戏我只玩贪玩懒⽉

对比其他双向绑定的实现方法,数据劫持的优势在于:

  • 无需显式调用:例如 Vue 运用数据劫持 + 发布订阅,可以直接通知变化并驱动视图,上面的例子也是比较简单的实现 data.name = '渣渣辉' 后直接触发变更,而比如 Angular 的脏检测则需要显式调用 markForCheck (可以用 zone.js 避免显式调用,不展开),react 需要显式调用 setState
  • 可精确得知变化数据:我们劫持了属性的 setter,当属性值改变,可以精确获知变化的内容 newVal,因此在这部分不需要额外的 diff 操作,否则我们只知道数据发生了变化而不知道具体哪些数据变化了,这时需要大量 diff 来找出变化值,这是额外性能损耗。

(二)订阅发布

Observer「响应式」

Vue 中采用 Observer 类来管理上述响应式化 Object.defineProperty 的过程。示例代码如下,其中 this.data 为 Vue 中定义的需要进行响应式绑定的 data 属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Observer {
constructor() {
// 响应式绑定数据通过方法
observe(this.data);
}
}

export function observe (data) {
const keys = Object.keys(data);
for (let i = 0, len = keys.length; i < len; i++) {
// 将 data 中定义的每个属性进行响应式绑定
Object.defineProperty(obj, keys[i], {
// some codes
});
}
}

Dep「依赖管理」

通过 Observer 类将 data 中的数据进行响应式绑定后,虽然可以监听到数据的变化,那要怎么通知视图更新数据呢?Dep 就是专门用于收集【究竟要通知到哪里的】。

如下所示,虽然 data 中有 text 和 message 属性,但只有 message 会被渲染到页面上,因此这里仅对 message 进行收集。此时 message 的 Dep 就收集到了一个依赖,这个依赖就是用来管理 data 中 message 变化的。

1
2
3
<div>
<p> {{message}} </p>
</div>
1
2
3
4
data: {
text: 'hello world',
message: 'hello vue',
}

当使用 watch 属性监听 message 时,需要将 message 数据的变化通知给 watch 这个钩子,让它去执行回调函数。这时 message 的 Dep 就收集到了两个依赖,第二个依赖就是用来管理 watch 中 message 变化的。

1
2
3
4
5
watch: {
message: function (val, oldVal) {
console.log('new: %s, old: %s', val, oldVal)
},
}

当开发者定义依赖 message 变化的计算属性时,当 message 发生变化,我们也要通知到 computed,让它去执行回调函数。这时 message 的 Dep 就收集到了三个依赖,这个依赖是用来管理 computed 中 message 变化的。

1
2
3
4
5
computed: {
messageT() {
return this.message + '!';
}
}
image-20210814202658660

我们通过Object.defineProperty 的 getter/setter 知道 data 中的某个属性被使用了,因此可以在其中进行改造以收集依赖。

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
class Observer {
constructor() {
// 响应式绑定数据通过方法
observe(this.data);
}
}

export function observe (data) {
const keys = Object.keys(data);
for (let i = 0; i < keys.length; i++) {
// 将data中定义的每个属性进行响应式绑定
Object.defineProperty(obj, keys[i], {
enumerable: true,
configurable: true,
get: () => {
console.log('我被读了,我要不要做点什么好?');
// 被读取了,将这个依赖收集起来
Dep.depend(); // 本次新增
return val;
},
set: newVal => {
if (val === newVal) {
return;
}
val = newVal;
// 被改变了,通知依赖去更新
Dep.notify(); // 本次新增
console.log("数据被改变了,我要把新的值渲染到页面上去!");
}
});
}
}

那所谓的依赖究竟是什么呢?上图中已经暴露了答案,就是 Watcher。

Watcher「订阅者」

当 message 变化,就会通知 Watchers,它们会执行各自需要做的变化。Watcher 能知晓自己负责 data、watch 还是 computed 中的属性,它有统一的更新入口,只要你通知它,就会执行对应的更新方法。因此可以推测出 Watcher 必须要有的 2 个方法:一个是收到通知进行更新,另一个是被收集到 Dep 中。

1
2
3
4
5
6
7
8
class Watcher {
addDep() {
// 我这个 Watcher 要被塞到 Dep 里去了~~
},
update() {
// Dep 通知我更新呢~~
},
}

回顾一下,Vue 响应式原理的核心就是 Observer、Dep、Watcher。Observer 中进行响应式的绑定,在数据被读时触发 get 方法,执行 Dep 来收集依赖,或者说收集 Watcher。在数据被改时触发 set 方法,通过对应依赖 (Watcher) 去执行更新。比如 watch 和 computed 就执行开发者自定义的回调方法。

(三)双向绑定实现

数据劫持是双向绑定各种方案中比较流行的一种,最著名的实现就是 Vue。基于数据劫持的双向绑定依靠 ProxyObject.defineProperty 方法劫持对象或其属性,要实现一个完整的双向绑定需要注意以下几个要点。

  • 利用 ProxyObject.defineProperty 生成的 Observer 针对对象或对象的属性进行劫持,在属性发生变化后通知订阅者;
  • 解析器 Compile 解析模板中的 Directive (指令),收集指令所依赖的方法和数据,等待数据变化然后进行渲染;
  • Watcher 属于 Observer 和 Compile 桥梁,它将接收到的 Observer 产生的数据变化,并根据 Compile 提供的指令进行视图渲染,使得数据变化促使视图变化
image-20210326225951944

极简版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const obj = {};
Object.defineProperty(obj, 'text', {
get: function() {
console.log('get val');
},
set: function(newVal) {
console.log('set val:' + newVal);
document.getElementById('input').value = newVal;
document.getElementById('span').innerHTML = newVal;
}
});

const input = document.getElementById('input');
input.addEventListener('keyup', function(e){
obj.text = e.target.value;
})

存在的问题:

  • 我们只监听了一个属性,一个对象不可能只有一个属性,我们需要对对象每个属性进行监听。
  • 违反开放封闭原则,我们每次修改都需要进入方法内部,这是需要坚决杜绝的。
  • 代码耦合严重:数据、方法和 DOM 都耦合在一起,即传说中的面条代码。

改进版

改进版依照 Vue 加入了发布订阅模式,结合 Object.defineProperty 的劫持能力,实现了可用性较高的双向绑定。以发布订阅的角度来看极简版的代码,会发现它的监听、发布和订阅都是写在一起的,因此需要对其解耦。通过实现订阅发布中心 Dep,即消息或依赖管理员,来负责储存订阅者 Watcher 和消息的分发。不论是订阅者还是发布者都依赖于 Dep。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let uid = 0;
// 用于存储订阅者并发布消息
class Dep {
constructor() {
// 设置 id,用于区分 Watcher 和只改变属性值后新产生的 Watcher
this.id = uid++;
// 储存订阅者的数组
this.subs = [];
}
// 触发 target 上的 Watcher 中的 addDep 方法,参数为 dep 实例本身
depend() {
Dep.target.addDep(this);
}
// 添加订阅者
addSub(sub) {
this.subs.push(sub);
}
// 通知所有的订阅者 (Watcher),触发订阅者的相应逻辑处理
notify() {
this.subs.forEach(sub => sub.update());
}
}
// 为 Dep 类设置一个静态属性,默认为 Null
Dep.target = null;

接着实现监听者 Observer,用于监听属性值的变化。

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 监听者,监听对象属性值的变化
class Observer {
constructor(value) {
this.value = value;
this.walk(value);
}
// 遍历属性值并监听
walk(value) {
Object.keys(value).forEach(key => this.convert(key, value[key]));
}
// 执行监听的具体方法
convert(key, val) {
defineReactive(this.value, key, val);
}
}

function defineReactive(obj, key, val) {
const dep = new Dep();
// 给当前属性的值添加监听
let childOb = observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
// 如果 Dep 类存在 target 属性,将其添加到 dep 实例的 subs 数组中
// target 指向一个 Watcher 实例,每个 Watcher 都是一个订阅者
// Watcher 实例在实例化过程中,会读取 data 中的某个属性,从而触发当前 get 方法
if(Dep.target) {
dep.depend();
}
return val;
},
set: newVal => {
if(val === newVal) return;
val = newVal;
// 对新值进行监听
childOb = observe(newVal);
// 通知所有订阅者,数值被改变了
dep.notify();
},
});
}

function observe(value) {
// 当值不存在或不属于复杂数据类型时,不再继续深入监听
if(!value || typeof value !== 'object') {
return;
}
return new Observer(value);
}

接下来实现订阅者 Watcher。

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
36
class Watcher {
constructor(vm, expOrFn, cb) {
this.depIds = {}; // hash 储存订阅者的 id,避免重复的订阅者
this.vm = vm; // 被订阅的数据必须来自于当前 Vue 实例
this.cb = cb; // 当数据更新时所要进行的自定义操作
this.expOrFn = expOrFn; // 被订阅的数据
this.val = this.get(); // 维护更新之前的数据
}
// 对外暴露的接口,用于在订阅的数据被更新时,由订阅者管理员 (Dep) 调用
update() {
this.run();
}
addDep(dep) {
// 如果在 depIds 的 hash 中没有当前的 id,可以判断是新 Watcher,因此可以添加到 dep 的数组中储存
// 此判断是避免同 id 的 Watcher 被多次储存
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this);
this.depIds[dep.id] = dep;
}
}
run() {
const val = this.get();
if (val !== this.val) {
this.val = val;
this.cb.call(this.vm, val);
}
}
get() {
// 当前订阅者 (Watcher) 读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
Dep.target = this;
const val = this.vm._data[this.expOrFn];
// 置空,用于下一个 Watcher 使用
Dep.target = null;
return val;
}
}

最后将上述方法挂载在 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
25
26
class Vue {
constructor(options = {}) {
// 简化了$options 的处理
this.$options = options;
// 简化了对 data 的处理
let data = (this._data = this.$options.data);
// 将所有 data 最外层属性代理到 Vue 实例上
Object.keys(data).forEach(key => this._proxy(key));
// 监听数据
observe(data);
}
// 对外暴露调用订阅者的接口,内部主要在指令中使用订阅者
$watch(expOrFn, cb) {
new Watcher(this, expOrFn, cb);
}
_proxy(key) {
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
get: () => this._data[key],
set: val => {
this._data[key] = val;
},
});
}
}

Object.defineProperty 缺陷

改进版的双向绑定依然存在漏洞,比如将属性值改为数组。

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
let demo = new Vue({
data: {
list: [1],
},
});

const list = document.getElementById('list');
const btn = document.getElementById('btn');

btn.addEventListener('click', function() {
demo.list.push(1);
});

const render = arr => {
const fragment = document.createDocumentFragment();
for (let i = 0; i < arr.length; i++) {
const li = document.createElement('li');
li.textContent = arr[i];
fragment.appendChild(li);
}
list.appendChild(fragment);
};

// 监听数组,每次数组变化则触发渲染函数,然而...无法监听
demo.$watch('list', list => render(list));

setTimeout(
function() {
alert(demo.list);
},
5000,
);

Object.defineProperty 的第一个缺陷,无法监听数组变化。然而 Vue 可以检测到数组变化,但只有以下七种方法,像 vm.items[indexOfItem] = newValue 这种是无法检测的。

  • push()
  • pop()
  • shift()
  • unshift()
  • splice()
  • sort()
  • reverse()

其实作者在这里用了一些奇技淫巧,把无法监听数组的情况 hack 掉了。以下是方法示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const aryMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
const arrayAugmentations = [];
aryMethods.forEach((method)=> {
// 这里是原生 Array 的原型方法
let original = Array.prototype[method];
// 将 push, pop 等封装好的方法定义在对象 arrayAugmentations 的属性上
// 注意:是属性而非原型属性
arrayAugmentations[method] = function () {
console.log('我被改变啦!');
// 调用对应的原生方法并返回结果
return original.apply(this, arguments);
};
});
let list = ['a', 'b', 'c'];
// 将我们要监听的数组的原型指针指向上面定义的空数组对象
// 别忘了这个空数组的属性上定义了我们封装好的 push 等方法
list.__proto__ = arrayAugmentations;
list.push('d'); // 我被改变啦!4
// 这里的 list2 没有被重新定义原型指针,所以就正常输出
let list2 = ['a', 'b', 'c'];
list2.push('d'); // 4

由于只针对了八种方法进行了 hack,所以其他数组的属性也是检测不到的,其中的坑很多。我们应该注意到在上文中的实现里,多次用遍历方法遍历对象的属性,这就引出了 Object.defineProperty 的第二个缺陷,只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历,如果属性值也是对象那么需要深度遍历,显然能劫持一个完整的对象是更好的选择。

1
Object.keys(value).forEach(key => this.convert(key, value[key]));

Proxy 优点

Proxy 是 Object.defineProperty 的全方位加强版,在 ES2015 规范中被正式发布,它在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。

① Proxy 可以直接监听对象而非属性

以上文中用 Object.defineProperty 实现的极简版双向绑定为例,用 Proxy 进行改写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const input = document.getElementById('input');
const p = document.getElementById('p');
const obj = {};
const newObj = new Proxy(obj, {
get: function(target, key, receiver) {
console.log(`getting ${key}!`);
return Reflect.get(target, key, receiver);
},
set: function(target, key, value, receiver) {
console.log(target, key, value, receiver);
if (key === 'text') {
input.value = value;
p.innerHTML = value;
}
return Reflect.set(target, key, value, receiver);
},
});
input.addEventListener('keyup', function(e) {
newObj.text = e.target.value;
});

Proxy 直接劫持整个对象,并返回一个新对象,不管是操作便利程度还是底层功能上都远强于 Object.defineProperty

② Proxy 可以直接监听数组的变化

当对数组进行操作(push、shift、splice 等)时,会触发对应的方法名称和 length 的变化,我们可以借此进行操作,以上文中Object.defineProperty 无法生效的列表渲染为例。

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
36
37
38
39
40
41
42
43
44
45
const list = document.getElementById('list');
const btn = document.getElementById('btn');
// 渲染列表
const Render = {
// 初始化
init: function(arr) {
const fragment = document.createDocumentFragment();
for (let i = 0; i < arr.length; i++) {
const li = document.createElement('li');
li.textContent = arr[i];
fragment.appendChild(li);
}
list.appendChild(fragment);
},
// 我们只考虑了增加的情况,仅作为示例
change: function(val) {
const li = document.createElement('li');
li.textContent = val;
list.appendChild(li);
},
};
// 初始数组
const arr = [1, 2, 3, 4];
// 监听数组
const newArr = new Proxy(arr, {
get: function(target, key, receiver) {
console.log(key);
return Reflect.get(target, key, receiver);
},
set: function(target, key, value, receiver) {
console.log(target, key, value, receiver);
if (key !== 'length') {
Render.change(value);
}
return Reflect.set(target, key, value, receiver);
},
});
// 初始化
window.onload = function() {
Render.init(arr);
}
// push 数字
btn.addEventListener('click', function() {
newArr.push(6);
});

显然,Proxy 无需 hack 就能轻松监听数组的变化。

③ Proxy 的其他优势

  • Proxy 有多达 13 种拦截方法,比如 applyownKeysdeletePropertyhas 等,这些 Object.defineProperty 并不具备;

  • Proxy 返回的是一个新对象,因此可以只操作新对象达到目的,而 Object.defineProperty 只能遍历对象属性直接修改;

  • Proxy 作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利。

当然,Proxy 的劣势就是兼容性问题,且无法用 polyfill 磨平,因此 Vue 作者才声明需要等到下个大版本 (3.0) 才能用 Proxy 重写。