Vue2.x与3.x的变化侦测原理分析

Vue 的一大特性是可以由数据的变化引起DOM的变化,这得益于它设计精巧的响应式系统。一个 Vue 应用程序在运行时会因为数据的变化而不断进行重新渲染,Vue 的响应式系统就赋予了框架重新渲染的能力。

变化侦测是实现响应式系统的核心,实习结束,拿起去年看不懂的《深入浅出 Vue.js 》开始二刷,记录一下心得体会。

书中介绍的是 Vue 2 版本的变化侦测原理,本篇文章同时也会将 Vue 3版本的变化侦测原理补充进去。

Vue 2 的变化侦测

Vue 使用 “推” 的方式侦测状态变化,相比于 React 的“拉” 来说,拥有更细的粒度。“推”类型的变化侦测可以在状态变化时立刻得知变化内容,并且可以进行视图更新操作。

在 Vue 2 中,对 Object 和 Array 的变化侦测方式是不一样的。

Object 的变化侦测原理

Vue 2 中侦测 Object 变化的核心方法是Object.defineProperty,对数据的读取和写入做一层劫持,即每当从这个对象的一个键中读取一个值时,就触发这个组合上的 get 方法,每当设置一个新的键值对或者更改已有的键值对时,就触发这个组合上的 set 方法。在 Vue 中,这个操作由 defineReactive 方法封装。

首先我们需要能对一个对象中的一个属性进行劫持,即让这个属性的读写操作都必须经过一次我们自己设定的方法,这样我们才可以方便进行下一步操作。思考一下 Vue ,当在一个<template>中使用到了多个{{ name }},那么当 data 中的 name 变化时,我们必须让所有用到name的地方都进行变化,这其实就是我们劫持数据的目的,需要在变化时通知使用了这些数据的地方,那些地方就是我们的依赖

对于依赖,无非是两种操作:收集依赖和触发依赖。Vue 是这么做的:在 get 方法中收集依赖,在 set 方法中触发依赖。收集依赖的意思是找到所有用到这个数据的地方,触发依赖的意思是将所有找到的依赖进行更新。

你倒是可以直接在原来的代码基础上直接进行依赖收集操作,但不如把“依赖”这个东西直接抽象成一个类,因为会经常用到,这样减少一下耦合。依赖要怎么保存呢?首先想到的就是一个数组,收集依赖就是将多个依赖依次装入数组中,触发依赖则遍历数组,将每个依赖的值进行更新。

我们取依赖的单词 dependency,声明一个 Dep 类,并把添加依赖,删除依赖,通知依赖更新的操作都封装起来,然后只需要修改一下原有的defineReactive方法,在Object.defineProperty之前 new 一个 Dep 类,然后在 set 和 get 里面分别调用一下通知依赖更新和添加依赖就可以了。

等等,“通知依赖”这个操作是不是还有些不明确?我们是可以通知<template>中的{{ name }}了,但是不要忘了我们还有computedwatch等其他地方也有可能用到 name!所以,通知依赖还应该有一个专门的类去完成这个工作(为什么?因为直接写到 set 中属于耦合了)。在 Vue 中,有一个专门的 Watcher 类去处理这件事,可以把流程这样解释:

  • 依赖收集时,只收集 Watcher 提供的封装好的依赖
  • 通知依赖更新时,只通知 Watcher ,由 Watcher 再去通知其他地方。

有点代理模式的味道。

来模拟一下 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, callback) {
this.vm = vm;
this.getter = parsePath(expOrFn);
this.callback = callback;
this.value = this.get();
}
get() {
window.target = this; // 假设依赖都存放在window.target里
let value = this.getter.call(this.vm, this.vm);
window.target = undefined;
return value;
}
update() {
const oldVal = this.value;
this.value = this.get();
this.callback.call(this.vm, this.value, oldVal);
}
}

function parsePath(path) {
const bailRE = /[^\w.$]/;
if (bailRE.test(path)) {
return;
}
const segments = path.split('.');
return (obj) => {
for (let i = 0; i < segments.length; i++) {
if (!obj) {
return;
}
obj = obj[segments[i]];
}
return obj;
}
}

这个代码可以把自己主动添加到expOrFn所指的属性的 Dep 中去,具体原因是:

  • get 方法中会将window.target设置为当前 Watcher 实例,在构造函数中读取一下expOrFn中的内容,就会触发getter,然后就会触发依赖收集的逻辑。
  • 由于收集依赖是从window.target中读取依赖添加到 Dep 中,所以只需要先把 this 赋给 window.target,再读一下window.target的值,就可以触发 getter,把 this 主动添加到expOrFn的 Dep 中。
  • 这样,每当expOrFn所指的属性值发生变化时,就会让依赖列表中所有的依赖触发 Watcher 的 update 方法,update 方法又可以执行参数中的回调函数,把新值和旧值传回到回调中。

现在我们可以实现对某一个对象的属性的变化侦测了,那么还差最后一步,我们要把所有的对象属性都这样做一下侦测,要怎么做?

我们需要一个角色来帮助我们完成这个工作,把对象中所有的属性和子属性都侦测到(也就是转换成 getter / setter)的形式,Vue 中使用了一个 Observer 类来完成这个操作。

Observer 类用于将一个正常的 Object 转换成被侦测的 Object。这个类会附加到每一个被侦测的 Object 上,一旦附加成功,它就会把对象里面的所有属性都转换成 getter / setter 的形式。

Observer 类有一个 walk 方法,只有在数据类型为 Object 时才会调用,它首先使用Object.keys获取到所有可枚举的属性,然后遍历对象,对每个属性应用defineReactive方法。defineReactive也需要修改一下,即如果当前的值是 Object 属性,则递归建立 Observer。这样,只要定义响应式数据的data中的任何一个属性或者子属性发生改变,与这个属性的对应的依赖都会收到通知。

这种方式看起来没什么问题,但其实有一个缺点。由于数据变化是通过 getter / setter 来追踪的,它追踪的是数据的修改,无法对新增属性和删除属性做出响应。这就会导致实际使用 Vue 中出现的双向绑定失效问题。举个例子来说,我在 data 中定义了一个空对象,然后通过 methods 对这个对象添加一些属性,然后在<template>中打印出来,这样的数据是无法被响应式系统追踪到的,也就是数据变化后视图无法跟着一起变化。(这个坑我在实习时遇到过,接口数据存储到了一个空对象中,这样直接修改接口数据就不能触发视图更新)

下图简单描述了这个过程(Watcher那个线画错了,边框应该是实线。。

总体来说,data 通过 Observer 变成了带有 getter / setter 的形式追踪变化。外界通过 Watcher 读取数据时,会触发 getter 从而将 Watcher 添加到依赖中。当数据发生了变化时,会触发 setter,从而向 Dep 中的依赖(Watcher)发送通知。Watcher 接收到通知后,会向外界发送通知,变化通知到外界后触发视图更新等操作。

Array 的变化侦测原理

为什么 Array 的变化侦测要单独拿出来说呢?其实,当你使用 Array 的方法操作数组时,比如 push 和 pop,并不能触发 getter / setter,所以之前那种拦截的方法是行不通的。

那我们把 push,pop 等操作做个拦截不就可以了么?但挺可惜,ES6 之前的 JS 没有这种能力。这样就没有办法处理 Array 了吗?并不,我们有原型和原型链,我们可以把 Array.prototype 改写一下,也就是在原型上做一层拦截。之后我们每当使用 Array 上的方法时,其实使用的并不是原生方法,而是经过我们包装过的方法,它会做一些依赖收集和更新的操作,同时使用原生的 Array 方法完成原来的操作。

那么这个拦截器要如何实现呢?它其实也是一个 Object,和 Array.prototype 一模一样,但是其中的改变数组自身内容的方法都需要我们处理过。

引入一个结论:Array 原型中可以改变数组自身内容的方法有 7 个。它们分别是:

  • push,尾部插入
  • pop,尾部弹出
  • shift,头部弹出
  • unshift,头部插入
  • splice,删除,替换,或添加新元素
  • sort,排序
  • reverse,逆序

我们先从 Array.prototype 新建一个原型,再新建一个数组,存一下这七个操作的字符串。然后遍历一下,对每个操作做一次Object.defineProoerty,也就是对每个操作进行一次封装,将它的 value 属性设定为一个自定义的函数mutator,在这个函数里面就可以做一些其他事情,最后 return 一下原生的对应操作就可以了。

我们现在拥有了拦截器,那是不是可以直接改写掉 Array 的原型了?很显然是不可以的,这样会污染全局,拦截器应当只应用在被变化侦测的数组上。所以还要再改写一下原有的 Observer,当在构造函数中发现当前的 value 是 Array 时,就不再使用原来的遍历操作,而是直接使用value.__proto__ = arrMethods覆盖原型。还有一种很特殊的情况,如果有的浏览器不支持__proto__,那这样改写的方式就行不通,Vue 解决这个问题的方法是直接将拦截器中改写方法设置到被侦测的数组上,这样就可以让数组执行这些方法时直接使用这些新挂载的方法。

当访问一个对象上的方法时,只有它自身不存在这个方法,才会沿着原型链去它的原型上寻找同名方法。

接下来我们考虑下怎么让这个拦截器和 Dep 扯上关系,也就是怎么收集依赖。前面对 Object 收集依赖的过程是在 getter 中完成的,那数组怎么收集依赖呢?其实也是在 getter 中完成的。而与 Object 不同的是,触发依赖是在拦截器中完成的。

Vue 把 Array 的依赖保存在 Observer 中,因为需要让 getter 和拦截器都能访问到这个实例。在 Observer 中,对每个侦测了变化的数据,都标上了一个印记__ob__,并且把代表 Observer 实例的 this 保存在__ob__上,它的作用之一是用于标记已经侦测过的数据,避免重复侦测,另一方面可以很方便地通过数据取到__ob__,从而拿到 Observer 上保存的依赖。

除了侦测数据自身的变化外,数组元素发生的变化也要侦测。也就是说不仅要侦测数组元素本身,还要侦测一下它里面各种元素的变化。所以 Vue 中有一个 ObserveArray 函数,它可以对数组中的每一项内容都进行侦测(遍历每个元素并执行 observe 函数)。

此后,还要再侦测新增元素的变化,这需要扩展一下拦截器,把新增的元素拿过来,用 Observer 侦测一下。可以新增元素的操作只有 push、unshift、splice。通过获取它们的args就可以拿到新增的元素。接下来需要把新增的元素添加 Observer 侦测,使用的方法是observerArray,这个方法怎么从拦截器里拿到呢?其实,因为 Observer 会把自身的实例附加到 value 的__ob__属性上,这样只需要在拦截器的this里面就可以访问到__ob__了(所以这里不太适合用箭头函数。。)

这种拦截方式只能够拦截到数组操作的 API,对于直接对数组上某个元素使用下标访问修改的操作,它就无法拦截到了。

最后贴一下拦截器的代码演示:

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 arrProto = Array.prototype;
const arrMethods = Object.create(arrProto);

function def(obj, key, val, enumerable) {
Object.defineProperty(obj, key, {
value: val,
enumerable: !!enumerable,
writable: true,
configurable: true,
})
}

['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach((method) => {
const original = arrProto[method];
def(arrMethods, method, function mutator(...args) {
const result = original.apply(this, args);
const ob = this.__ob__;
let insert;
switch (method) {
case 'push':
case 'unshift':
insert = args;
break;
case 'splice':
insert = args.slice(2);
break;
}
if (insert) {
ob.observeArray(insert);
}
ob.dep.notify();
return result;
})
})

Vue 3 的变化侦测

Vue 3 相对于 Vue 2 来说,在变化侦测上最大的变化是采用了 Proxy 代替了 Object.defineProperty,Proxy 为 JavaScript 提供了元编程的能力,同时也不再需要数组那样需要拦截器的操作,Proxy 统一提供了 get 和 set 方法,它可以直接拦截并改变 JavaScript 底层引擎操作。

在 Vue 2 时代,原有的变化侦测方法无法侦测到属性的删除和新增,对于直接使用下标修改数组元素和修改 length 也无法侦测,这些问题在 Vue 3 时代全部都处理掉了。Vue 2 为了弥补这些缺陷,引入了this.$setthis.$delete等 API。并且由于依赖收集需要递归,其性能也是有限的。

Vue 3使用一个类似 React Hooks 的函数包裹需要侦测的数据,它提供了reactive方法用于将一个复杂的对象转化成响应式的。当你从 data 函数中返回一个对象时, Vue 会把这个对象包裹在一个含有 get 和 set 拦截器的 Proxy 中。

依赖保存、收集与触发其实与 Vue 2 相差不大,也是在 get 中收集依赖,在 set 中触发依赖。用 Vue 官方的解释说,叫做当一个值被读取时进行追踪,当某个值改变时进行检测,重新运行代码来读取原始值。Vue 2实现了 Observer 类, Dep 类和 Watcher 类用于依赖收集,但 Vue 3 中采用了 track 来收集依赖,并且使用 trigger 来触发更新,本质上使用的是 WeakMap,Map和Set。

如果对象是嵌套的,从响应式代理中访问这个嵌套的对象时,对象在被返回之前也会被转换成一个代理。

同时需要注意,Proxy 转换的对象和原始对象并不相等,它们使用===运算符运算是返回 false 的。最佳实践:永远不要持有对原始对象的引用。

Vue 3在响应式原理上还有很多新的特性,这些有机会时再来看一下,官方文档写的很详细。

打赏
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2018-2022 Shawn Zhou
  • Hexo 框架强力驱动 | 主题 - Ayer
  • 访问人数: | 浏览次数:

感谢打赏~

支付宝
微信