非原始值的响应式方案

实际上,实现响应式数据要比想象中难很多,并不是单纯地拦截get/set 操作即可。举例来说,如何拦截 for…in 循环?track 函数如何追踪拦截到的 for…in 循环?类似的问题还有很多。除此之外,我们还应该考虑如何对数组进行代理。Vue.js 3 还支持集合类型,如 Map、Set、WeakMap 以及 WeakSet 等,那么应该如何对集合类型进行代理呢?实际上,想要实现完善的响应式数据,我们需要深入语言规范。在揭晓答案的同时,也会从语言规范的层面来分析原因,让你对响应式数据有更深入的理解。

本帖引用 ECMA-262 规范,如不作特殊说明,皆指该规范的 2021 版本。

1、理解 Proxy 和 Reflect

既然 Vue.js 3 的响应式数据是基于 Proxy 实现的,那么我们就有必要了解 Proxy 以及与之相关联的 Reflect。什么是 Proxy 呢?简单地说,使用Proxy 可以创建一个代理对象。它能够实现对其他对象的代理,这里的关键词是其他对象,也就是说,Proxy 只能代理对象,无法代理非对象值,例如字符串、布尔值等。那么,代理指的是什么呢?所谓代理,指的是对一个对象基本语义的代理。它允许我们拦截并重新定义对一个对象的基本操作。这句话的关键词比较多,我们逐一解释。

什么是基本语义?给出一个对象 obj,可以对它进行一些操作,例如读取属性值、设置属性值:

01 obj.foo // 读取属性 foo 的值
02 obj.foo++ // 读取和设置属性 foo 的值

类似这种读取、设置属性值的操作,就属于基本语义的操作,即基本操作。既然是基本操作,那么它就可以使用 Proxy 拦截:

01 const p = new Proxy(obj, {
02   // 拦截读取属性操作
03   get() { /*...*/ },
04   // 拦截设置属性操作
05   set() { /*...*/ }
06 })

如以上代码所示,Proxy 构造函数接收两个参数。第一个参数是被代理的对象,第二个参数也是一个对象,这个对象是一组夹子(trap)。其中 get 函数用来拦截读取操作,set 函数用来拦截设置操作。

在 JavaScript 的世界里,万物皆对象。例如一个函数也是一个对象,所以调用函数也是对一个对象的基本操作:

01 const fn = (name) => {
02   console.log('我是:', name)
03 }
04
05 // 调用函数是对对象的基本操作
06 fn()

因此,我们可以用 Proxy 来拦截函数的调用操作,这里我们使用 apply 拦截函数的调用:

01 const p2 = new Proxy(fn, {
02   // 使用 apply 拦截函数调用
03   apply(target, thisArg, argArray) {
04     target.call(thisArg, ...argArray)
05   }
06 })
07
08 p2('hcy') // 输出:'我是:hcy'

上面两个例子说明了什么是基本操作。Proxy 只能够拦截对一个对象的基本操作。那么,什么是非基本操作呢?其实调用对象下的方法就是典型的非基本操作,我们叫它复合操作:

01 obj.fn()

实际上,调用一个对象下的方法,是由两个基本语义组成的。第一个基本语义是 get,即先通过 get 操作得到 obj.fn 属性。第二个基本语义是函数调用,即通过 get 得到 obj.fn 的值后再调用它,也就是我们上面说到的 apply。理解 Proxy 只能够代理对象的基本语义很重要,后续我们讲解如何实现对数组或Map、Set 等数据类型的代理时,都利用了 Proxy 的这个特点。

理解了 Proxy,我们再来讨论 Reflect。Reflect 是一个全局对象,其下有许多方法,例如:

01 Reflect.get()
02 Reflect.set()
03 Reflect.apply()
04 // ...

你可能已经注意到了,Reflect 下的方法与 Proxy 的拦截器方法名字相同,其实这不是偶然。任何在 Proxy 的拦截器中能够找到的方法,都能够在 Reflect 中找到同名函数,那么这些函数的作用是什么呢?其实它们的作用一点儿都不神秘。拿Reflect.get 函数来说,它的功能就是提供了访问一个对象属性的默认行为,例如下面两个操作是等价的:

01 const obj = { foo: 1 }
02
03 // 直接读取
04 console.log(obj.foo) // 1
05 // 使用 Reflect.get 读取
06 console.log(Reflect.get(obj, 'foo')) // 1

可能有的读者会产生疑问:既然操作等价,那么它存在的意义是什么呢?实际上 Reflect.get 函数还能接收第三个参数,即指定接收者 receiver,你可以把它理解为函数调用过程中的this,例如:

01 const obj = { foo: 1 }
02 console.log(Reflect.get(obj, 'foo', { foo: 2 }))  // 输出的是 2 而不是 1

在这段代码中,我们指定第三个参数 receiver 为一个对象 {foo: 2 },这时读取到的值是 receiver 对象的 foo 属性值。实际上,Reflect.* 方法还有很多其他方面的意义,但这里我们只关心并讨论这一点,因为它与响应式数据的实现密切相关。为了说明问题,回顾一下在上一节中实现响应式数据的代码:

01 const obj = { foo: 1 }
02
03 const p = new Proxy(obj, {
04   get(target, key) {
05     track(target, key)
06     // 注意,这里我们没有使用 Reflect.get 完成读取
07     return target[key]
08   },
09   set(target, key, newVal) {
10     // 这里同样没有使用 Reflect.set 完成设置
11     target[key] = newVal
12     trigger(target, key)
13   }
14 })

这是实现响应式数据的最基本的代码。在 get 和set 拦截函数中,我们都是直接使用原始对象 target 来完成对属性的读取和设置操作的,其中原始对象 target 就是上述代码中的 obj 对象。

那么这段代码有什么问题吗?我们借助 effect 让问题暴露出来。首先,我们修改一下 obj 对象,为它添加 bar 属性:

01 const obj = {
02   foo: 1,
03   get bar() {
04     return this.foo
05   }
06 }

可以看到,bar 属性是一个访问器属性,它返回了 this.foo 属性的值。接着,我们在 effect 副作用函数中通过代理对象 p 访问 bar 属性:

01 effect(() => {
02   console.log(p.bar) // 1
03 })

我们来分析一下这个过程发生了什么。当 effect 注册的副作用函数执行时,会读取 p.bar 属性,它发现 p.bar 是一个访问器属性,因此执行 getter 函数。由于在 getter 函数中通过this.foo 读取了 foo 属性值,因此我们认为副作用函数与属性foo 之间也会建立联系。当我们修改 p.foo 的值时应该能够触发响应,使得副作用函数重新执行才对。然而实际并非如此,当我们尝试修改 p.foo 的值时:

01 p.foo++

副作用函数并没有重新执行,问题出在哪里呢?
实际上,问题就出在 bar 属性的访问器函数 getter 里:

01 const obj = {
02   foo: 1,
03   get bar() {
04     // 这里的 this 指向的是谁?
05     return this.foo
06   }
07 }

当我们使用 this.foo 读取 foo 属性值时,这里的 this 指向的是谁呢?我们回顾一下整个流程。首先,我们通过代理对象 p 访问 p.bar,这会触发代理对象的 get 拦截函数执行:

01 const p = new Proxy(obj, {
02   get(target, key) {
03     track(target, key)
04     // 注意,这里我们没有使用 Reflect.get 完成读取
05     return target[key]
06   },
07   // 省略部分代码
08 })

在 get 拦截函数内,通过 target[key] 返回属性值。其中target 是原始对象 obj,而 key 就是字符串 ‘bar’,所以target[key] 相当于 obj.bar。因此,当我们使用 p.bar 访问bar 属性时,它的 getter 函数内的 this 指向的其实是原始对象obj,这说明我们最终访问的其实是 obj.foo。很显然,在副作用函数内通过原始对象访问它的某个属性是不会建立响应联系的,这等价于:

01 effect(() => {
02   // obj 是原始数据,不是代理对象,这样的访问不能够建立响应联系
03   obj.foo
04 })

因为这样做不会建立响应联系,所以出现了无法触发响应的问题。那么这个问题应该如何解决呢?这时 Reflect.get 函数就派上用场了。先给出解决问题的代码:

01 const p = new Proxy(obj, {
02   // 拦截读取操作,接收第三个参数 receiver
03   get(target, key, receiver) {
04     track(target, key)
05     // 使用 Reflect.get 返回读取到的属性值
06     return Reflect.get(target, key, receiver)
07   },
08   // 省略部分代码
09 })

如上面的代码所示,代理对象的 get 拦截函数接收第三个参数receiver,它代表谁在读取属性,例如:

01 p.bar // 代理对象 p 在读取 bar 属性

当我们使用代理对象 p 访问 bar 属性时,那么 receiver 就是p,你可以把它简单地理解为函数调用中的 this。接着关键的一步发生了,我们使用 Reflect.get(target, key, receiver) 代替之前的 target[key],这里的关键点就是第三个参数 receiver。我们已经知道它就是代理对象 p,所以访问器属性 bar 的 getter 函数内的 this 指向代理对象 p:

01 const obj = {
02   foo: 1,
03   get bar() {
04     // 现在这里的 this 为代理对象 p
05     return this.foo
06   }
07 }

可以看到,this 由原始对象 obj 变成了代理对象 p。很显然,这会在副作用函数与响应式数据之间建立响应联系,从而达到依赖收集的效果。如果此时再对 p.foo 进行自增操作,会发现已经能够触发副作用函数重新执行了。

正是基于上述原因,后文讲解中将统一使用 Reflect.* 方法。

2、JavaScript 对象及 Proxy 的工作原理

我们经常听到这样的说法:“JavaScript 中一切皆对象。”那么,到底什么是对象呢?这个问题需要我们查阅 ECMAScript 规范才能得到答案。实际上,根据 ECMAScript 规范,在JavaScript 中有两种对象,其中一种叫作常规对象(ordinary object),另一种叫作异质对象(exotic object)。这两种对象包含了 JavaScript 世界中的所有对象,任何不属于常规对象的对象都是异质对象。那么到底什么是常规对象,什么是异质对象呢?这需要我们先了解对象的内部方法和内部槽。

我们知道,在 JavaScript 中,函数其实也是对象。假设给出一个对象 obj,如何区分它是普通对象还是函数呢?实际上,在JavaScript 中,对象的实际语义是由对象的内部方法(internal method)指定的。所谓内部方法,指的是当我们对一个对象进行操作时在引擎内部调用的方法,这些方法对于 JavaScript 使用者来说是不可见的。举个例子,当我们访问对象属性时:

01 obj.foo

引擎内部会调用 [[Get]] 这个内部方法来读取属性值。这里补充说明一下,在 ECMAScript 规范中使用 [[xxx]] 来代表内部方法或内部槽。当然,一个对象不仅部署了 [[Get]] 这个内部方法,下表列出了规范要求的所有必要的内部方法:
非原始值的响应式方案_第1张图片
由上表可知,包括 [[Get]] 在内,一个对象必须部署 11 个必要的内部方法。除了上表所列的内部方法之外,还有两个额外的必要内部方法[插图]:[[Call]] 和 [[Construct]],如下表所示:
非原始值的响应式方案_第2张图片
如果一个对象需要作为函数调用,那么这个对象就必须部署内部方法 [[Call]]。现在我们就可以回答前面的问题了:如何区分一个对象是普通对象还是函数呢?一个对象在什么情况下才能作为函数调用呢?答案是,通过内部方法和内部槽来区分对象,例如函数对象会部署内部方法 [[Call]],而普通对象则不会。

内部方法具有多态性,这是什么意思呢?这类似于面向对象里多态的概念。这就是说,不同类型的对象可能部署了相同的内部方法,却具有不同的逻辑。例如,普通对象和 Proxy 对象都部署了 [[Get]] 这个内部方法,但它们的逻辑是不同的,普通对象部署的 [[Get]] 内部方法的逻辑是由 ECMA 规范的 10.1.8 节定义的,而 Proxy 对象部署的 [[Get]] 内部方法的逻辑是由ECMA 规范的 10.5.8 节来定义的。

了解了内部方法,就可以解释什么是常规对象,什么是异质对象了。满足以下三点要求的对象就是常规对象:

  • 对于上表列出的内部方法,必须使用 ECMA 规范 10.1.x 节给出的定义实现;
  • 对于内部方法 [[Construct]],必须使用 ECMA 规范 10.2.2节给出的定义实现。
  • 对于内部方法 [[Call]],必须使用 ECMA 规范 10.2.1 节给出的定义实现;

而所有不符合这三点要求的对象都是异质对象。例如,由于Proxy 对象的内部方法 [[Get]] 没有使用 ECMA 规范的 10.1.8节给出的定义实现,所以 Proxy 是一个异质对象。

现在我们对 JavaScript 中的对象有了更加深入的理解。接下来,我们就具体看看 Proxy 对象。既然 Proxy 也是对象,那么它本身也部署了上述必要的内部方法,当我们通过代理对象访问属性值时:

01 const p = new Proxy(obj, {/* ... */})
02 p.foo

实际上,引擎会调用部署在对象 p 上的内部方法 [[Get]]。到这一步,其实代理对象和普通对象没有太大区别。它们的区别在于对于内部方法 [[Get]] 的实现,这里就体现了内部方法的多态性,即不同的对象部署相同的内部方法,但它们的行为可能不同。具体的不同体现在,如果在创建代理对象时没有指定对应的拦截函数,例如没有指定 get() 拦截函数,那么当我们通过代理对象访问属性值时,代理对象的内部方法 [[Get]] 会调用原始对象的内部方法 [[Get]] 来获取属性值,这其实就是代理透明性质。

现在相信你已经明白了,创建代理对象时指定的拦截函数,实际上是用来自定义代理对象本身的内部方法和行为的,而不是用来指定被代理对象的内部方法和行为的。下表列出了Proxy 对象部署的所有内部方法以及用来自定义内部方法和行为的拦截函数名字。
非原始值的响应式方案_第3张图片
当然,其中 [[Call]] 和 [[Construct]] 这两个内部方法只有当被代理的对象是函数和构造函数时才会部署。

由下表可知,当我们要拦截删除属性操作时,可以使用deleteProperty 拦截函数实现:

01 const obj = { foo: 1 }
02 const p = new Proxy(obj, {
03   deleteProperty(target, key) {
04     return Reflect.deleteProperty(target, key)
05   }
06 })
07
08 console.log(p.foo) // 1
09 delete p.foo
10 console.log(p.foo) // 未定义

这里需要强调的是,deleteProperty 实现的是代理对象 p 的内部方法和行为,所以为了删除被代理对象上的属性值,我们需要使用 Reflect.deleteProperty(target, key) 来完成。

3、如何代理 Object

从本节开始,我们将着手实现响应式数据。前面我们使用 get 拦截函数去拦截对属性的读取操作。但在响应系统中,“读取”是一个很宽泛的概念,例如使用 in 操作符检查对象上是否具有给定的 key 也属于“读取”操作,如下面的代码所示:

01 effect(() => {
02   'foo' in obj
03 })

这本质上也是在进行“读取”操作。响应系统应该拦截一切读取操作,以便当数据变化时能够正确地触发响应。下面列出了对一个普通对象的所有可能的读取操作。

  • 访问属性:obj.foo。
  • 判断对象或原型上是否存在给定的 key:key in obj。
  • 使用 for…in 循环遍历对象:for (const key in obj){}。

接下来,我们逐步讨论如何拦截这些读取操作。首先是对于属性的读取,例如 obj.foo,我们知道这可以通过 get 拦截函数实现:

01 const obj = { foo: 1 }
02
03 const p = new Proxy(obj, {
04   get(target, key, receiver) {
05     // 建立联系
06     track(target, key)
07     // 返回属性值
08     return Reflect.get(target, key, receiver)
09   },
10 })

对于 in 操作符,应该如何拦截呢?在 ECMA-262 规范的 13.10.1 节中,明确定义了 in 操作符的运行时逻辑:
非原始值的响应式方案_第4张图片
描述的内容如下:

  • 让 lref 的值为 RelationalExpression 的执行结果。
  • 让 lval 的值为 ? GetValue(lref)。
  • 让 rref 的值为 ShiftExpression 的执行结果。
  • 让 rval 的值为 ? GetValue(rref)。
  • 如果 Type(rval) 不是对象,则抛出 TypeError 异常。
  • 返回 ? HasProperty(rval, ? ToPropertyKey(lval))。

关键点在第 6 步,可以发现,in 操作符的运算结果是通过调用一个叫作 HasProperty 的抽象方法得到的。关于 HasProperty 抽象方法,可以在 ECMA-262 规范的 7.3.11 节中找到,它的操作如图 :
非原始值的响应式方案_第5张图片
描述的内容如下:

  • 断言:Type(O) 是 Object。
  • 断言:IsPropertyKey§ 是 true。
  • 返回 ? O.[HasProperty]。

在第 3 步中,可以看到 HasProperty 抽象方法的返回值是通过调用对象的内部方法 [[HasProperty]] 得到的。而[[HasProperty]] 内部方法可以在找到,它对应的拦截函数名叫 has,因此我们可以通过 has 拦截函数实现对 in 操作符的代理:

01 const obj = { foo: 1 }
02 const p = new Proxy(obj, {
03   has(target, key) {
04     track(target, key)
05     return Reflect.has(target, key)
06   }
07 })

这样,当我们在副作用函数中通过 in 操作符操作响应式数据时,就能够建立依赖关系:

01 effect(() => {
02   'foo' in p // 将会建立依赖关系
03 })

任何操作其实都是由这些基本语义方法及其组合实现的,for…in 循环也不例外。为了搞清楚 for…in 循环依赖哪些基本语义方法,还需要看规范。

由于这部分规范内容较多,因此这里只截取关键部分。在规范的 14.7.5.6 节中定义了 for…in 头部的执行规则:
非原始值的响应式方案_第6张图片
第 6 步描述的内容如下:
. 如果 iterationKind 是枚举(enumerate),则:

  • a. 如果 exprValue 是 undefined 或 null,那么  i. 返回 Completion { [[Type]]: break, [[Value]]: empty,[[Target]]: empty }。
  • b. 让 obj 的值为 ! ToObject(exprValue)。
  • c. 让 iterator 的值为 ? EnumerateObjectProperties(obj)。
  • d. 让 nextMethod 的值为 ! GetV(iterator, “next”)。
  • e. 返回 Record{ [[Iterator]]: iterator, [[NextMethod]]:nextMethod, [[Done]]: false }。

仔细观察第 6 步的第 c 子步骤:让 iterator 的值为 ? EnumerateObjectProperties(obj)。
其中的关键点在于 EnumerateObjectProperties(obj)。这里的EnumerateObjectProperties 是一个抽象方法,该方法返回一个迭代器对象,规范的 14.7.5.9 节给出了满足该抽象方法的示例实现,如下面的代码所示:

01 function* EnumerateObjectProperties(obj) {
02   const visited = new Set();
03   for (const key of Reflect.ownKeys(obj)) {
04     if (typeof key === "symbol") continue;
05     const desc = Reflect.getOwnPropertyDescriptor(obj, key);
06     if (desc) {
07       visited.add(key);
08       if (desc.enumerable) yield key;
09     }
10   }
11   const proto = Reflect.getPrototypeOf(obj);
12   if (proto === null) return;
13   for (const protoKey of EnumerateObjectProperties(proto)) {
14     if (!visited.has(protoKey)) yield protoKey;
15   }
16 }

可以看到,该方法是一个 generator 函数,接收一个参数obj。实际上,obj 就是被 for…in 循环遍历的对象,其关键点在于使用 Reflect.ownKeys(obj) 来获取只属于对象自身拥有的键。有了这个线索,如何拦截 for…in 循环的答案已经很明显了,我们可以使用 ownKeys 拦截函数来拦截 Reflect.ownKeys 操作:

01 const obj = { foo: 1 }
02 const ITERATE_KEY = Symbol()
03
04 const p = new Proxy(obj, {
05   ownKeys(target) {
06     // 将副作用函数与 ITERATE_KEY 关联
07     track(target, ITERATE_KEY)
08     return Reflect.ownKeys(target)
09   }
10 })

如上面的代码所示,拦截 ownKeys 操作即可间接拦截 for…in 循环。但相信大家已经注意到了,我们在使用 track 函数进行追踪的时候,将 ITERATE_KEY 作为追踪的 key,为什么这么做呢?这是因为 ownKeys 拦截函数与 get/set 拦截函数不同,在set/get 中,我们可以得到具体操作的 key,但是在 ownKeys 中,我们只能拿到目标对象 target。这也很符合直觉,因为在读写属性值时,总是能够明确地知道当前正在操作哪一个属性,所以只需要在该属性与副作用函数之间建立联系即可。而ownKeys 用来获取一个对象的所有属于自己的键值,这个操作明显不与任何具体的键进行绑定,因此我们只能够构造唯一的key 作为标识,即 ITERATE_KEY。

既然追踪的是 ITERATE_KEY,那么相应地,在触发响应的时候也应该触发它才行:

01 trigger(target, ITERATE_KEY)

但是在什么情况下,对数据的操作需要触发与 ITERATE_KEY 相关联的副作用函数重新执行呢?为了搞清楚这个问题,我们用一段代码来说明。假设副作用函数内有一段 for…in 循环:

01 const obj = { foo: 1 }
02 const p = new Proxy(obj, {/* ... */})
03
04 effect(() => {
05   // for...in 循环
06   for (const key in p) {
07     console.log(key) // foo
08   }
09 })

副作用函数执行后,会与 ITERATE_KEY 之间建立响应联系,接下来我们尝试为对象 p 添加新的属性 bar:

01 p.bar = 2

由于对象 p 原本只有 foo 属性,因此 for…in 循环只会执行一次。现在为它添加了新的属性 bar,所以 for…in 循环就会由执行一次变成执行两次。也就是说,当为对象添加新属性时,会对 for…in 循环产生影响,所以需要触发与 ITERATE_KEY 相关联的副作用函数重新执行。但目前的实现还做不到这一点。当我们为对象 p 添加新的属性 bar 时,并没有触发副作用函数重新执行,这是为什么呢?我们来看一下现在的 set 拦截函数的实现:

01 const p = new Proxy(obj, {
02   // 拦截设置操作
03   set(target, key, newVal, receiver) {
04     // 设置属性值
05     const res = Reflect.set(target, key, newVal, receiver)
06     // 把副作用函数从桶里取出并执行
07     trigger(target, key)
08
09     return res
10   },
11   // 省略其他拦截函数
12 })

当为对象 p 添加新的 bar 属性时,会触发 set 拦截函数执行。此时 set 拦截函数接收到的 key 就是字符串 ‘bar’,因此最终调用 trigger 函数时也只是触发了与 ‘bar’ 相关联的副作用函数重新执行。但根据前文的介绍,我们知道 for…in 循环是在副作用函数与 ITERATE_KEY 之间建立联系,这和 ‘bar’ 一点儿关系都没有,因此当我们尝试执行 p.bar = 2 操作时,并不能正确地触发响应。

弄清楚了问题在哪里,解决方案也就随之而来了。当添加属性时,我们将那些与 ITERATE_KEY 相关联的副作用函数也取出来执行就可以了:

01 function trigger(target, key) {
02   const depsMap = bucket.get(target)
03   if (!depsMap) return
04   // 取得与 key 相关联的副作用函数
05   const effects = depsMap.get(key)
06   // 取得与 ITERATE_KEY 相关联的副作用函数
07   const iterateEffects = depsMap.get(ITERATE_KEY)
08
09   const effectsToRun = new Set()
10   // 将与 key 相关联的副作用函数添加到 effectsToRun
11   effects && effects.forEach(effectFn => {
12     if (effectFn !== activeEffect) {
13       effectsToRun.add(effectFn)
14     }
15   })
16   // 将与 ITERATE_KEY 相关联的副作用函数也添加到 effectsToRun
17   iterateEffects && iterateEffects.forEach(effectFn => {
18     if (effectFn !== activeEffect) {
19       effectsToRun.add(effectFn)
20     }
21   })
22
23   effectsToRun.forEach(effectFn => {
24     if (effectFn.options.scheduler) {
25       effectFn.options.scheduler(effectFn)
26     } else {
27       effectFn()
28     }
29   })
30 }

如以上代码所示,当 trigger 函数执行时,除了把那些直接与具体操作的 key 相关联的副作用函数取出来执行外,还要把那些与 ITERATE_KEY 相关联的副作用函数取出来执行。

但相信细心的你已经发现了,对于添加新的属性来说,这么做没有什么问题,但如果仅仅修改已有属性的值,而不是添加新属性,那么问题就来了。看如下代码:

01 const obj = { foo: 1 }
02 const p = new Proxy(obj, {/* ... */})
03
04 effect(() => {
05   // for...in 循环
06   for (const key in p) {
07     console.log(key) // foo
08   }
09 })

当我们修改 p.foo 的值时:

01 p.foo = 2

与添加新属性不同,修改属性不会对 for…in 循环产生影响。因为无论怎么修改一个属性的值,对于 for…in 循环来说都只会循环一次。所以在这种情况下,我们不需要触发副作用函数重新执行,否则会造成不必要的性能开销。然而无论是添加新属性,还是修改已有的属性值,其基本语义都是 [[Set]],我们都是通过 set 拦截函数来实现拦截的,如以下代码所示:

01 const p = new Proxy(obj, {
02   // 拦截设置操作
03   set(target, key, newVal, receiver) {
04     // 设置属性值
05     const res = Reflect.set(target, key, newVal, receiver)
06     // 把副作用函数从桶里取出并执行
07     trigger(target, key)
08
09     return res
10   },
11   // 省略其他拦截函数
12 })

所以要想解决上述问题,当设置属性操作发生时,就需要我们在 set 拦截函数内能够区分操作的类型,到底是添加新属性还是设置已有属性:

01 const p = new Proxy(obj, {
02   // 拦截设置操作
03   set(target, key, newVal, receiver) {
04     // 如果属性不存在,则说明是在添加新属性,否则是设置已有属性
05     const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
06
07     // 设置属性值
08     const res = Reflect.set(target, key, newVal, receiver)
09
10     // 将 type 作为第三个参数传递给 trigger 函数
11     trigger(target, key, type)
12
13     return res
14   },
15   // 省略其他拦截函数
16 })

如以上代码所示,我们优先使用Object.prototype.hasOwnProperty 检查当前操作的属性是否已经存在于目标对象上,如果存在,则说明当前操作类型为’SET’,即修改属性值;否则认为当前操作类型为 ‘ADD’,即添加新属性。最后,我们把类型结果 type 作为第三个参数传递给trigger 函数。

在 trigger 函数内就可以通过类型 type 来区分当前的操作类型,并且只有当操作类型 type 为 ‘ADD’ 时,才会触发与ITERATE_KEY 相关联的副作用函数重新执行,这样就避免了不必要的性能损耗:

01 function trigger(target, key, type) {
02   const depsMap = bucket.get(target)
03   if (!depsMap) return
04   const effects = depsMap.get(key)
05
06   const effectsToRun = new Set()
07   effects && effects.forEach(effectFn => {
08     if (effectFn !== activeEffect) {
09       effectsToRun.add(effectFn)
10     }
11   })
12
13   console.log(type, key)
14   // 只有当操作类型为 'ADD' 时,才触发与 ITERATE_KEY 相关联的副作用函数重新执行
15   if (type === 'ADD') {
16     const iterateEffects = depsMap.get(ITERATE_KEY)
17     iterateEffects && iterateEffects.forEach(effectFn => {
18       if (effectFn !== activeEffect) {
19         effectsToRun.add(effectFn)
20       }
21     })
22   }
23
24   effectsToRun.forEach(effectFn => {
25     if (effectFn.options.scheduler) {
26       effectFn.options.scheduler(effectFn)
27     } else {
28       effectFn()
29     }
30   })
31 }

通常我们会将操作类型封装为一个枚举值,例如:

01 const TriggerType = {
02   SET: 'SET',
03   ADD: 'ADD'
04 }

这样无论是对后期代码的维护,还是对代码的清晰度,都是非常有帮助的。但这里我们就不讨论这些细枝末节了。

关于对象的代理,还剩下最后一项工作需要做,即删除属性操作的代理:

01 delete p.foo

如何代理 delete 操作符呢?还是看规范,规范的 13.5.1.2 节中明确定义了 delete 操作符的行为,如下图所示:
非原始值的响应式方案_第7张图片
第 5 步描述的内容如下:
如果 IsPropertyReference(ref) 是 true,那么

  • a. 断言:! IsPrivateReference(ref) 是 false。
  • b. 如果 IsSuperReference(ref) 也是 true,则抛出ReferenceError 异常。
  • c. 让 baseObj 的值为 ! ToObject(ref,[[Base]])。
  • d. 让 deleteStatus 的值为 ? baseObj.[Delete]。
  • e. 如果 deleteStatus 的值为 false 并且 ref.[[Strict]] 的值是true,则抛出 TypeError 异常。
  • f. 返回 deleteStatus。

由第 5 步中的 d 子步骤可知,delete 操作符的行为依赖[[Delete]] 内部方法。接着查看表 5可知,该内部方法可以使用 deleteProperty 拦截:

01 const p = new Proxy(obj, {
02   deleteProperty(target, key) {
03     // 检查被操作的属性是否是对象自己的属性
04     const hadKey = Object.prototype.hasOwnProperty.call(target, key)
05     // 使用 Reflect.deleteProperty 完成属性的删除
06     const res = Reflect.deleteProperty(target, key)
07
08     if (res && hadKey) {
09       // 只有当被删除的属性是对象自己的属性并且成功删除时,才触发更新
10       trigger(target, key, 'DELETE')
11     }
12
13     return res
14   }
15 })

如以上代码所示,首先检查被删除的属性是否属于对象自身,然后调用 Reflect.deleteProperty 函数完成属性的删除工作,只有当这两步的结果都满足条件时,才调用 trigger 函数触发副作用函数重新执行。需要注意的是,在调用 trigger 函数时,我们传递了新的操作类型 ‘DELETE’。由于删除操作会使得对象的键变少,它会影响 for…in 循环的次数,因此当操作类型为’DELETE’ 时,我们也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行:

01 function trigger(target, key, type) {
02   const depsMap = bucket.get(target)
03   if (!depsMap) return
04   const effects = depsMap.get(key)
05
06   const effectsToRun = new Set()
07   effects && effects.forEach(effectFn => {
08     if (effectFn !== activeEffect) {
09       effectsToRun.add(effectFn)
10     }
11   })
12
13   // 当操作类型为 ADD 或 DELETE 时,需要触发与 ITERATE_KEY 相关联的副作用函数重新执行
14   if (type === 'ADD' || type === 'DELETE') {
15     const iterateEffects = depsMap.get(ITERATE_KEY)
16     iterateEffects && iterateEffects.forEach(effectFn => {
17       if (effectFn !== activeEffect) {
18         effectsToRun.add(effectFn)
19       }
20     })
21   }
22
23   effectsToRun.forEach(effectFn => {
24     if (effectFn.options.scheduler) {
25       effectFn.options.scheduler(effectFn)
26     } else {
27       effectFn()
28     }
29   })
30 }

在这段代码中,我们添加了 type === ‘DELETE’ 判断,使得删除属性操作能够触发与 ITERATE_KEY 相关联的副作用函数重新执行。

4、合理地触发响应

上一节中,我们从规范的角度详细介绍了如何代理对象,在这个过程中,处理了很多边界条件。例如,我们需要明确知道操作的类型是 ‘ADD’ 还是 ‘SET’,抑或是其他操作类型,从而正确地触发响应。但想要合理地触发响应,还有许多工作要做。

首先,我们来看要面临的第一个问题,即当值没有发生变化时,应该不需要触发响应才对:

01 const obj = { foo: 1 }
02 const p = new Proxy(obj, { /* ... */ })
03
04 effect(() => {
05   console.log(p.foo)
06 })
07
08 // 设置 p.foo 的值,但值没有变化
09 p.foo = 1

如上面的代码所示,p.foo 的初始值为 1,当为p.foo 设置新的值时,如果值没有发生变化,则不需要触发响应。为了满足需求,我们需要修改 set 拦截函数的代码,在调用 trigger 函数触发响应之前,需要检查值是否真的发生了变化:

01 const p = new Proxy(obj, {
02   set(target, key, newVal, receiver) {
03     // 先获取旧值
04     const oldVal = target[key]
05
06     const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
07     const res = Reflect.set(target, key, newVal, receiver)
08     // 比较新值与旧值,只要当不全等的时候才触发响应
09     if (oldVal !== newVal) {
10       trigger(target, key, type)
11     }
12
13     return res
14   },
15 })

如上面的代码所示,我们在 set 拦截函数内首先获取旧值 oldVal,接着比较新值与旧值,只有当它们不全等的时候才触发响应。现在,如果我们再次测试本节开头的例子,会发现重新设置相同的值已经不会触发响应了。

然而,仅仅进行全等比较是有缺陷的,这体现在对NaN 的处理上。我们知道 NaN 与 NaN 进行全等比较总会得到 false:

01 NaN === NaN // false
02 NaN !== NaN // true

换句话说,如果 p.foo 的初始值是 NaN,并且后续又为其设置了 NaN 作为新值,那么仅仅进行全等比较的缺陷就暴露了:

01 const obj = { foo: NaN }
02 const p = new Proxy(obj, { /* ... */ })
03
04 effect(() => {
05   console.log(p.foo)
06 })
07
08 // 仍然会触发响应,因为 NaN !== NaN 为 true
09 p.foo = NaN

这仍然会触发响应,并导致不必要的更新。为了解决这个问题,我们需要再加一个条件,即在新值和旧值不全等的情况下,要保证它们都不是 NaN:

01 const p = new Proxy(obj, {
02   set(target, key, newVal, receiver) {
03     // 先获取旧值
04     const oldVal = target[key]
05
06     const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
07     const res = Reflect.set(target, key, newVal, receiver)
08     // 比较新值与旧值,只有当它们不全等,并且不都是 NaN 的时候才触发响应
09     if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
10       trigger(target, key, type)
11     }
12
13     return res
14   },
15 })

这样我们就解决了 NaN 的问题。

但想要合理地触发响应,仅仅处理关于 NaN 的问题还不够。接下来,我们讨论一种从原型上继承属性的情况。为了后续讲解方便,我们需要封装一个reactive 函数,该函数接收一个对象作为参数,并返回为其创建的响应式数据:

01 function reactive(obj) {
02   return new Proxy(obj, {
03     // 省略前文讲解的拦截函数
04   })
05 }

可以看到,reactive 函数只是对 Proxy 进行了一层封装。接下来,我们基于 reactive 创建一个例子:

01 const obj = {}
02 const proto = { bar: 1 }
03 const child = reactive(obj)
04 const parent = reactive(proto)
05 // 使用 parent 作为 child 的原型
06 Object.setPrototypeOf(child, parent)
07
08 effect(() => {
09   console.log(child.bar) // 1
10 })
11 // 修改 child.bar 的值
12 child.bar = 2 // 会导致副作用函数重新执行两次

观察如上代码,我们定义了空对象 obj 和对象proto,分别为二者创建了对应的响应式数据child 和 parent,并且使用Object.setPrototypeOf 方法将 parent 设置为child 的原型。接着,在副作用函数内访问child.bar 的值。从代码中可以看出,child 本身并没有 bar 属性,因此当访问 child.bar 时,值是从原型上继承而来的。但无论如何,既然 child 是响应式数据,那么它与副作用函数之间就会建立联系,因此当我们执行 child.bar = 2 时,期望副作用函数会重新执行。但如果你尝试运行上面的代码,会发现副作用函数不仅执行了,还执行了两次,这会造成不必要的更新。

为了搞清楚问题的原因,我们需要逐步分析整个过程。当在副作用函数中读取 child.bar 的值时,会触发 child 代理对象的 get 拦截函数。我们知道,在拦截函数内是使用 Reflect.get(target, key,receiver) 来得到最终结果的,对应到上例,这句话相当于:

01 Reflect.get(obj, 'bar', receiver)

这其实是实现了通过 obj.bar 来访问属性值的默认行为。也就是说,引擎内部是通过调用 obj 对象所部署的 [[Get]] 内部方法来得到最终结果的,因此我们有必要查看规范 10.1.8.1 节来了解 [[Get]]内部方法的执行流程:
非原始值的响应式方案_第8张图片
第 3 步所描述的内容如下:
如果 desc 是 undefined,那么:

  • a. 让 parent 的值为 ? O.[GetPrototypeOf]。
  • b. 如果 parent 是 null,则返回 undefined。
  • c. 返回 ? parent.[[Get]](P, Receiver)。

在第 3 步中,我们能够了解到非常关键的信息,即如果对象自身不存在该属性,那么会获取对象的原型,并调用原型的 [[Get]] 方法得到最终结果。对应到上例中,当读取 child.bar 属性值时,由于child 代理的对象 obj 自身没有 bar 属性,因此会获取对象 obj 的原型,也就是 parent 对象,所以最终得到的实际上是 parent.bar 的值。但是大家不要忘了,parent 本身也是响应式数据,因此在副作用函数中访问 parent.bar 的值时,会导致副作用函数被收集,从而也建立响应联系。所以我们能够得出一个结论,即 child.bar 和 parent.bar 都与副作用函数建立了响应联系。

但这仍然解释不了为什么当设置 child.bar 的值时,会连续触发两次副作用函数执行,所以接下来我们需要看看当设置操作发生时的具体执行流程。我们知道,当执行 child.bar = 2 时,会调用child 代理对象的 set 拦截函数。同样,在 set 拦截函数内,我们使用 Reflect.set(target, key,newVal, receiver) 来完成默认的设置行为,即引擎会调用 obj 对象部署的 [[Set]] 内部方法,根据规范的 10.1.9.2 节可知 [[Set]] 内部方法的执行流程,如图:
非原始值的响应式方案_第9张图片
第 2 步所描述的内容如下:
如果 ownDesc 是 undefined,那么:

  • a. 让 parent 的值为 O.[GetPrototypeOf]。
  • b. 如果 parent 不是 null,则 I. 返回 ? parent.[[Set]](P, V, Receiver);
  • c. 否则 I. 将 ownDesc 设置为 { [[Value]]:undefined, [[Writable]]: true, [[Enumerable]]:true, [[Configurable]]: true }。

由第 2 步可知,如果设置的属性不存在于对象上,那么会取得其原型,并调用原型的 [[Set]] 方法,也就是 parent 的 [[Set]] 内部方法。由于parent 是代理对象,所以这就相当于执行了它的set 拦截函数。换句话说,虽然我们操作的是child.bar,但这也会导致 parent 代理对象的 set 拦截函数被执行。前面我们分析过,当读取child.bar 的值时,副作用函数不仅会被 child.bar 收集,也会被 parent.bar 收集。所以当 parent 代理对象的 set 拦截函数执行时,就会触发副作用函数重新执行,这就是为什么修改 child.bar 的值会导致副作用函数重新执行两次。

接下来,我们需要思考解决方案。思路很简单,既然执行两次,那么只要屏蔽其中一次不就可以了吗?我们可以把由 parent.bar 触发的那次副作用函数的重新执行屏蔽。怎么屏蔽呢?我们知道,两次更新是由于 set 拦截函数被触发了两次导致的,所以只要我们能够在 set 拦截函数内区分这两次更新就可以了。当我们设置 child.bar 的值时,会执行 child 代理对象的 set 拦截函数:

01 // child 的 set 拦截函数
02 set(target, key, value, receiver) {
03   // target 是原始对象 obj
04   // receiver 是代理对象 child
05 }

此时的 target 是原始对象 obj,receiver 是代理对象 child,我们发现 receiver 其实就是 target 的代理对象。

但由于 obj 上不存在 bar 属性,所以会取得 obj 的原型 parent,并执行 parent 代理对象的 set 拦截函数:

01 // parent 的 set 拦截函数
02 set(target, key, value, receiver) {
03   // target 是原始对象 proto
04   // receiver 仍然是代理对象 child
05 }

我们发现,当 parent 代理对象的 set 拦截函数执行时,此时 target 是原始对象 proto,而receiver 仍然是代理对象 child,而不再是 target 的代理对象。通过这个特点,我们可以看到target 和 receiver 的区别。由于我们最初设置的是 child.bar 的值,所以无论在什么情况下,receiver 都是 child,而 target 则是变化的。根据这个区别,我们很容易想到解决办法,只需要判断receiver 是否是 target 的代理对象即可。只有当receiver 是 target 的代理对象时才触发更新,这样就能够屏蔽由原型引起的更新了。

所以接下来的问题变成了如何确定 receiver 是不是 target 的代理对象,这需要我们为 get 拦截函数添加一个能力,如以下代码所示:

01 function reactive(obj) {
02   return new Proxy(obj {
03     get(target, key, receiver) {
04       // 代理对象可以通过 raw 属性访问原始数据
05       if (key === 'raw') {
06         return target
07       }
08
09       track(target, key)
10       return Reflect.get(target, key, receiver)
11     }
12     // 省略其他拦截函数
13   })
14 }

我们增加了一段代码,它实现的功能是,代理对象可以通过 raw 属性读取原始数据,例如:

01 child.raw === obj // true
02 parent.raw === proto // true

有了它,我们就能够在 set 拦截函数中判断receiver 是不是 target 的代理对象了:

01 function reactive(obj) {
02   return new Proxy(obj {
03     set(target, key, newVal, receiver) {
04       const oldVal = target[key]
05       const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
06       const res = Reflect.set(target, key, newVal, receiver)
07
08       // target === receiver.raw 说明 receiver 就是 target 的代理对象
09       if (target === receiver.raw) {
10         if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
11           trigger(target, key, type)
12         }
13       }
14
15       return res
16     }
17     // 省略其他拦截函数
18   })
19 }

如以上代码所示,我们新增了一个判断条件,只有当 receiver 是 target 的代理对象时才触发更新,这样就能屏蔽由原型引起的更新,从而避免不必要的更新操作。

5、浅响应与深响应

介绍 reactive 与 shallowReactive 的区别,即深响应和浅响应的区别。实际上,我们目前所实现的 reactive 是浅响应的。拿如下代码来说:

01 const obj = reactive({ foo: { bar: 1 } })
02
03 effect(() => {
04   console.log(obj.foo.bar)
05 })
06 // 修改 obj.foo.bar 的值,并不能触发响应
07 obj.foo.bar = 2

首先,创建 obj 代理对象,该对象的 foo 属性值也是一个对象,即 { bar: 1 }。接着,在副作用函数内访问 obj.foo.bar 的值。但是我们发现,后续对 obj.foo.bar 的修改不能触发副作用函数重新执行,这是为什么呢?来看一下现在的实现:

01 function reactive(obj) {
02   return new Proxy(obj {
03     get(target, key, receiver) {
04       if (key === 'raw') {
05         return target
06       }
07
08       track(target, key)
09       // 当读取属性值时,直接返回结果
10       return Reflect.get(target, key, receiver)
11     }
12     // 省略其他拦截函数
13   })
14 }

由上面这段代码可知,当我们读取 obj.foo.bar 时,首先要读取 obj.foo 的值。这里我们直接使用Reflect.get 函数返回 obj.foo 的结果。由于通过Reflect.get 得到 obj.foo 的结果是一个普通对象,即 { bar: 1 },它并不是一个响应式对象,所以在副作用函数中访问 obj.foo.bar 时,是不能建立响应联系的。要解决这个问题,我们需要对Reflect.get 返回的结果做一层包装:

01 function reactive(obj) {
02   return new Proxy(obj {
03     get(target, key, receiver) {
04       if (key === 'raw') {
05         return target
06       }
07
08       track(target, key)
09       // 得到原始值结果
10       const res = Reflect.get(target, key, receiver)
11       if (typeof res === 'object' && res !== null) {
12         // 调用 reactive 将结果包装成响应式数据并返回
13         return reactive(res)
14       }
15       // 返回 res
16       return res
17     }
18     // 省略其他拦截函数
19   })
20 }

如上面的代码所示,当读取属性值时,我们首先检测该值是否是对象,如果是对象,则递归地调用reactive 函数将其包装成响应式数据并返回。这样当使用 obj.foo 读取 foo 属性值时,得到的就会是一个响应式数据,因此再通过 obj.foo.bar 读取bar 属性值时,自然就会建立响应联系。这样,当修改 obj.foo.bar 的值时,就能够触发副作用函数重新执行了。

然而,并非所有情况下我们都希望深响应,这就催生了 shallowReactive,即浅响应。所谓浅响应,指的是只有对象的第一层属性是响应的,例如:

01 const obj = shallowReactive({ foo: { bar: 1 } })
02
03 effect(() => {
04   console.log(obj.foo.bar)
05 })
06 // obj.foo 是响应的,可以触发副作用函数重新执行
07 obj.foo = { bar: 2 }
08 // obj.foo.bar 不是响应的,不能触发副作用函数重新执行
09 obj.foo.bar = 3

在这个例子中,我们使用 shallowReactive 函数创建了一个浅响应的代理对象 obj。可以发现,只有对象的第一层属性是响应的,第二层及更深层次的属性则不是响应的。实现此功能并不难,如下面的代码所示:

01 // 封装 createReactive 函数,接收一个参数 isShallow,代表是否为浅响应,默认为 false,即非浅响应
02 function createReactive(obj, isShallow = false) {
03   return new Proxy(obj, {
04     // 拦截读取操作
05     get(target, key, receiver) {
06       if (key === 'raw') {
07         return target
08       }
09
10       const res = Reflect.get(target, key, receiver)
11
12       track(target, key)
13
14       // 如果是浅响应,则直接返回原始值
15       if (isShallow) {
16         return res
17       }
18
19       if (typeof res === 'object' && res !== null) {
20         return reactive(res)
21       }
22
23       return res
24     }
25     // 省略其他拦截函数
26   })
27 }

在上面这段代码中,我们把对象创建的工作封装到一个新的函数 createReactive 中。该函数除了接收原始对象 obj 之外,还接收参数 isShallow,它是一个布尔值,代表是否创建浅响应对象。默认情况下,isShallow 的值为 false,代表创建深响应对象。这里需要注意的是,当读取属性操作发生时,在 get 拦截函数内如果发现是浅响应的,那么直接返回原始数据即可。有了 createReactive 函数后,我们就可以使用它轻松地实现 reactive 以及 shallowReactive 函数了:

01 function reactive(obj) {
02   return createReactive(obj)
03 }
04 function shallowReactive(obj) {
05   return createReactive(obj, true)
06 }

6、只读和浅只读

我们希望一些数据是只读的,当用户尝试修改只读数据时,会收到一条警告信息。这样就实现了对数据的保护,例如组件接收到的 props 对象应该是一个只读数据。这时就要用到接下来要讨论的readonly 函数,它能够将一个数据变成只读的:

01 const obj = readonly({ foo: 1 })
02 // 尝试修改数据,会得到警告
03 obj.foo = 2

只读本质上也是对数据对象的代理,我们同样可以使用 createReactive 函数来实现。如下面的代码所示,我们为 createReactive 函数增加第三个参数 isReadonly:

01 // 增加第三个参数 isReadonly,代表是否只读,默认为 false,即非只读
02 function createReactive(obj, isShallow = false, isReadonly = false) {
03   return new Proxy(obj, {
04     // 拦截设置操作
05     set(target, key, newVal, receiver) {
06       // 如果是只读的,则打印警告信息并返回
07       if (isReadonly) {
08         console.warn(`属性 ${key} 是只读的`)
09         return true
10       }
11       const oldVal = target[key]
12       const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
13       const res = Reflect.set(target, key, newVal, receiver)
14       if (target === receiver.raw) {
15         if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
16           trigger(target, key, type)
17         }
18       }
19
20       return res
21     },
22     deleteProperty(target, key) {
23       // 如果是只读的,则打印警告信息并返回
24       if (isReadonly) {
25         console.warn(`属性 ${key} 是只读的`)
26         return true
27       }
28       const hadKey = Object.prototype.hasOwnProperty.call(target, key)
29       const res = Reflect.deleteProperty(target, key)
30
31       if (res && hadKey) {
32         trigger(target, key, 'DELETE')
33       }
34
35       return res
36     }
37     // 省略其他拦截函数
38   })
39 }

在这段代码中,当使用 createReactive 创建代理对象时,可以通过第三个参数指定是否创建一个只读的代理对象。同时,我们还修改了 set 拦截函数和 deleteProperty 拦截函数的实现,因为对于一个对象来说,只读意味着既不可以设置对象的属性值,也不可以删除对象的属性。在这两个拦截函数中,我们分别添加了是否是只读的判断,一旦数据是只读的,则当这些操作发生时,会打印警告信息,提示用户这是一个非法操作。

当然,如果一个数据是只读的,那就意味着任何方式都无法修改它。因此,没有必要为只读数据建立响应联系。出于这个原因,当在副作用函数中读取一个只读属性的值时,不需要调用 track 函数追踪响应:

01 const obj = readonly({ foo: 1 })
02 effect(() => {
03   obj.foo // 可以读取值,但是不需要在副作用函数与数据之间建立响应联系
04 })

为了实现该功能,我们需要修改 get 拦截函数的实现:

01 function createReactive(obj, isShallow = false, isReadonly = false) {
02   return new Proxy(obj, {
03     // 拦截读取操作
04     get(target, key, receiver) {
05       if (key === 'raw') {
06         return target
07       }
08       // 非只读的时候才需要建立响应联系
09       if (!isReadonly) {
10         track(target, key)
11       }
12
13       const res = Reflect.get(target, key, receiver)
14
15       if (isShallow) {
16         return res
17       }
18
19       if (typeof res === 'object' && res !== null) {
20         return reactive(res)
21       }
22
23       return res
24     }
25     // 省略其他拦截函数
26   })
27 }

如上面的代码所示,在 get 拦截函数内检测isReadonly 变量的值,判断是否是只读的,只有在非只读的情况下才会调用 track 函数建立响应联系。基于此,我们就可以实现 readonly 函数了:

01 function readonly(obj) {
02   return createReactive(obj, false, true /* 只读 */)
03 }

然而,上面实现的 readonly 函数更应该叫作shallowReadonly,因为它没有做到深只读:

01 const obj = readonly({ foo: { bar: 1 } })
02 obj.foo.bar = 2 // 仍然可以修改

所以为了实现深只读,我们还应该在 get 拦截函数内递归地调用 readonly 将数据包装成只读的代理对象,并将其作为返回值返回:

01 function createReactive(obj, isShallow = false, isReadonly = false) {
02   return new Proxy(obj, {
03     // 拦截读取操作
04     get(target, key, receiver) {
05       if (key === 'raw') {
06         return target
07       }
08       if (!isReadonly) {
09         track(target, key)
10       }
11
12       const res = Reflect.get(target, key, receiver)
13
14       if (isShallow) {
15         return res
16       }
17
18       if (typeof res === 'object' && res !== null) {
19         // 如果数据为只读,则调用 readonly 对值进行包装
20         return isReadonly ? readonly(res) : reactive(res)
21       }
22
23       return res
24     }
25     // 省略其他拦截函数
26   })
27 }

如上面的代码所示,我们在返回属性值之前,判断它是否是只读的,如果是只读的,则调用readonly 函数对值进行包装,并把包装后的只读对象返回。

对于 shallowReadonly,实际上我们只需要修改createReactive 的第二个参数即可:

01 function readonly(obj) {
02   return createReactive(obj, false, true)
03 }
04
05 function shallowReadonly(obj) {
06   return createReactive(obj, true /* shallow */, true)
07 }

如上面的代码所示,在 shallowReadonly 函数内调用 createReactive 函数创建代理对象时,将第二个参数 isShallow 设置为 true,这样就可以创建一个浅只读的代理对象了。

7、代理数组

实际上,在JavaScript 中,数组只是一个特殊的对象而已,因此想要更好地实现对数组的代理,就有必要了解相比普通对象,数组到底有何特殊之处。

在 JavaScript 中有两种对象:常规对象和异质对象。数组就是一个异质对象,这是因为数组对象的 [[DefineOwnProperty]] 内部方法与常规对象不同。换句话说,数组对象除了[[DefineOwnProperty]] 这个内部方法之外,其他内部方法的逻辑都与常规对象相同。因此,当实现对数组的代理时,用于代理普通对象的大部分代码可以继续使用,如下所示:

01 const arr = reactive(['foo'])
02
03 effect(() => {
04   console.log(arr[0]) // 'foo'
05 })
06
07 arr[0] = 'bar' // 能够触发响应

上面这段代码能够按预期工作。实际上,当我们通过索引读取或设置数组元素的值时,代理对象的get/set 拦截函数也会执行,因此我们不需要做任何额外的工作,就能够让数组索引的读取和设置操作是响应式的了:

  • 通过索引访问数组元素值:arr[0]。
  • 访问数组的长度:arr.length。
  • 把数组作为对象,使用 for…in 循环遍历。
  • 使用 for…of 迭代遍历数组。
  • 数组的原型方法,如concat/join/every/some/find/findIndex/includes 等,以及其他所有不改变原数组的原型方法。

可以看到,对数组的读取操作要比普通对象丰富得多。我们再来看看对数组元素或属性的设置操作有哪些:

  • 通过索引修改数组元素值:arr[1] = 3。
  • 修改数组长度:arr.length = 0。
  • 数组的栈方法:push/pop/shift/unshift。
  • 修改原数组的原型方法:splice/fill/sort 等。

除了通过数组索引修改数组元素值这种基本操作之外,数组本身还有很多会修改原数组的原型方法。调用这些方法也属于对数组的操作,有些方法的操作语义是“读取”,而有些方法的操作语义是“设置”。因此,当这些操作发生时,也应该正确地建立响应联系或触发响应。

从上面列出的这些对数组的操作来看,似乎代理数组的难度要比代理普通对象的难度大很多。但事实并非如此,这是因为数组本身也是对象,只不过它是异质对象罢了,它与常规对象的差异并不大。因此,大部分用来代理常规对象的代码对于数组也是生效的。接下来,我们就从通过索引读取或设置数组的元素值说起。

7.1、数组的索引与 length

当通过数组的索引访问元素的值时,已经能够建立响应联系了:

01 const arr = reactive(['foo'])
02
03 effect(() => {
04   console.log(arr[0]) // 'foo'
05 })
06
07 arr[0] = 'bar' // 能够触发响应

但通过索引设置数组的元素值与设置对象的属性值仍然存在根本上的不同,这是因为数组对象部署的内部方法 [[DefineOwnProperty]] 不同于常规对象。实际上,当我们通过索引设置数组元素的值时,会执行数组对象所部署的内部方法 [[Set]],这一步与设置常规对象的属性值一样。根据规范可知,内部方法 [[Set]] 其实依赖于[[DefineOwnProperty]],到了这里就体现出了差异。数组对象所部署的内部方法[[DefineOwnProperty]] 的逻辑定义在规范:
非原始值的响应式方案_第10张图片
可以看到,规范中明确说明,如果设置的索引值大于数组当前的长度,那么要更新数组的 length 属性。所以当通过索引设置元素值时,可能会隐式地修改 length 的属性值。因此在触发响应时,也应该触发与 length 属性相关联的副作用函数重新执行,如下面的代码所示:

01 const arr = reactive(['foo']) // 数组的原长度为 1
02
03 effect(() => {
04   console.log(arr.length) // 1
05 })
06 // 设置索引 1 的值,会导致数组的长度变为 2
07 arr[1] = 'bar'

在这段代码中,数组的原长度为 1,并且在副作用函数中访问了 length 属性。然后设置数组索引为1 的元素值,这会导致数组的长度变为 2,因此应该触发副作用函数重新执行。但目前的实现还做不到这一点,为了实现目标,我们需要修改 set 拦截函数,如下面的代码所示:

01 function createReactive(obj, isShallow = false, isReadonly = false) {
02   return new Proxy(obj, {
03     // 拦截设置操作
04     set(target, key, newVal, receiver) {
05       if (isReadonly) {
06         console.warn(`属性 ${key} 是只读的`)
07         return true
08       }
09       const oldVal = target[key]
10       // 如果属性不存在,则说明是在添加新的属性,否则是设置已有属性
11       const type = Array.isArray(target)
12         // 如果代理目标是数组,则检测被设置的索引值是否小于数组长度,
13         // 如果是,则视作 SET 操作,否则是 ADD 操作
14         ? Number(key) < target.length ? 'SET' : 'ADD'
15         : Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
16
17       const res = Reflect.set(target, key, newVal, receiver)
18       if (target === receiver.raw) {
19         if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
20           trigger(target, key, type)
21         }
22       }
23
24       return res
25     }
26     // 省略其他拦截函数
27 }

我们在判断操作类型时,新增了对数组类型的判断。如果代理的目标对象是数组,那么对于操作类型的判断会有所区别。即被设置的索引值如果小于数组长度,就视作 SET 操作,因为它不会改变数组长度;如果设置的索引值大于数组的当前长度,则视作 ADD 操作,因为这会隐式地改变数组的length 属性值。有了这些信息,我们就可以在trigger 函数中正确地触发与数组对象的 length 属性相关联的副作用函数重新执行了:

01 function trigger(target, key, type) {
02   const depsMap = bucket.get(target)
03   if (!depsMap) return
04   // 省略部分内容
05
06   // 当操作类型为 ADD 并且目标对象是数组时,应该取出并执行那些与 length 属性相关联的副作用函数
07   if (type === 'ADD' && Array.isArray(target)) {
08     // 取出与 length 相关联的副作用函数
09     const lengthEffects = depsMap.get('length')
10     // 将这些副作用函数添加到 effectsToRun 中,待执行
11     lengthEffects && lengthEffects.forEach(effectFn => {
12       if (effectFn !== activeEffect) {
13         effectsToRun.add(effectFn)
14       }
15     })
16   }
17
18   effectsToRun.forEach(effectFn => {
19     if (effectFn.options.scheduler) {
20       effectFn.options.scheduler(effectFn)
21     } else {
22       effectFn()
23     }
24   })
25 }

但是反过来思考,其实修改数组的 length 属性也会隐式地影响数组元素,例如:

01 const arr = reactive(['foo'])
02
03 effect(() => {
04   // 访问数组的第 0 个元素
05   console.log(arr[0]) // foo
06 })
07 // 将数组的长度修改为 0,导致第 0 个元素被删除,因此应该触发响应
08 arr.length = 0

如上面的代码所示,在副作用函数内访问了数组的第 0 个元素,接着将数组的 length 属性修改为0。我们知道这会隐式地影响数组元素,即所有元素都被删除,所以应该触发副作用函数重新执行。然而并非所有对 length 属性的修改都会影响数组中的已有元素,拿上例来说,如果我们将 length 属性设置为 100,这并不会影响第 0 个元素,所以也就不需要触发副作用函数重新执行。这让我们意识到,当修改 length 属性值时,只有那些索引值大于或等于新的 length 属性值的元素才需要触发响应。但无论如何,目前的实现还做不到这一点,为了实现目标,我们需要修改 set 拦截函数。在调用 trigger 函数触发响应时,应该把新的属性值传递过去:

01 function createReactive(obj, isShallow = false, isReadonly = false) {
02   return new Proxy(obj, {
03     // 拦截设置操作
04     set(target, key, newVal, receiver) {
05       if (isReadonly) {
06         console.warn(`属性 ${key} 是只读的`)
07         return true
08       }
09       const oldVal = target[key]
10
11       const type = Array.isArray(target)
12         ? Number(key) < target.length ? 'SET' : 'ADD'
13         : Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
14
15       const res = Reflect.set(target, key, newVal, receiver)
16       if (target === receiver.raw) {
17         if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
18           // 增加第四个参数,即触发响应的新值
19           trigger(target, key, type, newVal)
20         }
21       }
22
23       return res
24     },
25   })
26 }

接着,我们还需要修改 trigger 函数:

01 // 为 trigger 函数增加第四个参数,newVal,即新值
02 function trigger(target, key, type, newVal) {
03   const depsMap = bucket.get(target)
04   if (!depsMap) return
05   // 省略其他代码
06
07   // 如果操作目标是数组,并且修改了数组的 length 属性
08   if (Array.isArray(target) && key === 'length') {
09     // 对于索引大于或等于新的 length 值的元素,
10     // 需要把所有相关联的副作用函数取出并添加到 effectsToRun 中待执行
11     depsMap.forEach((effects, key) => {
12       if (key >= newVal) {
13         effects.forEach(effectFn => {
14           if (effectFn !== activeEffect) {
15             effectsToRun.add(effectFn)
16           }
17         })
18       }
19     })
20   }
21
22   effectsToRun.forEach(effectFn => {
23     if (effectFn.options.scheduler) {
24       effectFn.options.scheduler(effectFn)
25     } else {
26       effectFn()
27     }
28   })
29 }

如上面的代码所示,为 trigger 函数增加了第四个参数,即触发响应时的新值。在本例中,新值指的是新的 length 属性值,它代表新的数组长度。接着,我们判断操作的目标是否是数组,如果是,则需要找到所有索引值大于或等于新的 length 值的元素,然后把与它们相关联的副作用函数取出并执行。

7.2、遍历数组

既然数组也是对象,就意味着同样可以使用for…in 循环遍历:

01 const arr = reactive(['foo'])
02
03 effect(() => {
04   for (const key in arr) {
05     console.log(key) // 0
06   }
07 })

这里有必要指出一点,我们应该尽量避免使用for…in 循环遍历数组。但既然在语法上是可行的,那么当然也需要考虑。前面我们提到,数组对象和常规对象的不同仅体现在[[DefineOwnProperty]] 这个内部方法上,也就是说,使用 for…in 循环遍历数组与遍历常规对象并无差异,因此同样可以使用 ownKeys 拦截函数进行拦截。下面是我们之前实现的 ownKeys 拦截函数:

01 function createReactive(obj, isShallow = false, isReadonly = false) {
02   return new Proxy(obj, {
03     // 省略其他拦截函数
04     ownKeys(target) {
05       track(target, ITERATE_KEY)
06       return Reflect.ownKeys(target)
07     }
08   })
09 }

这段代码取自前文,当初我们为了追踪对普通对象的 for…in 操作,人为创造了 ITERATE_KEY 作为追踪的 key。但这是为了代理普通对象而考虑的,对于一个普通对象来说,只有当添加或删除属性值时才会影响 for…in 循环的结果。所以当添加或删除属性操作发生时,我们需要取出与ITERATE_KEY 相关联的副作用函数重新执行。不过,对于数组来说情况有所不同,我们看看哪些操作会影响 for…in 循环对数组的遍历:

  • 添加新元素:arr[100] = ‘bar’。
  • 修改数组长度:arr.length = 0。

其实,无论是为数组添加新元素,还是直接修改数组的长度,本质上都是因为修改了数组的 length 属性。一旦数组的 length 属性被修改,那么for…in 循环对数组的遍历结果就会改变,所以在这种情况下我们应该触发响应。很自然的,我们可以在 ownKeys 拦截函数内,判断当前操作目标target 是否是数组,如果是,则使用 length 作为key 去建立响应联系:

01 function createReactive(obj, isShallow = false, isReadonly = false) {
02   return new Proxy(obj, {
03     // 省略其他拦截函数
04     ownKeys(target) {
05       // 如果操作目标 target 是数组,则使用 length 属性作为 key 并建立响应联系
06       track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
07       return Reflect.ownKeys(target)
08     }
09   })
10 }

这样无论是为数组添加新元素,还是直接修改length 属性,都能够正确地触发响应了:

01 const arr = reactive(['foo'])
02
03 effect(() => {
04   for (const key in arr) {
05     console.log(key)
06   }
07 })
08
09 arr[1] = 'bar' // 能够触发副作用函数重新执行
10 arr.length = 0 // 能够触发副作用函数重新执行

讲解了使用 for…in 遍历数组,接下来我们再看看使用 for…of 遍历数组的情况。与 for…in 不同,for…of 是用来遍历可迭代对象(iterable object)的,因此我们需要先搞清楚什么是可迭代对象。ES2015 为 JavaScript 定义了迭代协议(iteration protocol),它不是新的语法,而是一种协议。具体来说,一个对象能否被迭代,取决于该对象或者该对象的原型是否实现了@@iterator 方法。这里的 @@[name] 标志在ECMAScript 规范里用来代指 JavaScript 内建的symbols 值,例如 @@iterator 指的就是Symbol.iterator 这个值。如果一个对象实现了Symbol.iterator 方法,那么这个对象就是可以迭代的,例如:

01 const obj = {
02   val: 0,
03   [Symbol.iterator]() {
04     return {
05       next() {
06         return {
07           value: obj.val++,
08           done: obj.val > 10 ? true : false
09         }
10       }
11     }
12   }
13 }

该对象实现了 Symbol.iterator 方法,因此可以使用 for…of 循环遍历它:

01 for (const value of obj) {
02   console.log(value)  // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
03 }

数组内建了 Symbol.iterator 方法的实现,我们可以做一个实验:

01 const arr = [1, 2, 3, 4, 5]
02 // 获取并调用数组内建的迭代器方法
03 const itr = arr[Symbol.iterator]()
04
05 console.log(itr.next())  // {value: 1, done: false}
06 console.log(itr.next())  // {value: 2, done: false}
07 console.log(itr.next())  // {value: 3, done: false}
08 console.log(itr.next())  // {value: 4, done: false}
09 console.log(itr.next())  // {value: 5, done: false}
10 console.log(itr.next())  // {value: undefined, done: true}

可以看到,我们能够通过将 Symbol.iterator 作为键,获取数组内建的迭代器方法。然后手动执行迭代器的 next 函数,这样也可以得到期望的结果。这也是默认情况下数组可以使用 for…of 遍历的原因:

01 const arr = [1, 2, 3, 4, 5]
02
03 for (const val of arr) {
04   console.log(val)  // 1, 2, 3, 4, 5
05 }

实际上,想要实现对数组进行 for…of 遍历操作的拦截,关键点在于找到 for…of 操作依赖的基本语义。在规范的 23.1.5.1 节中定义了数组迭代器的执行流程:
非原始值的响应式方案_第11张图片
可以看到,数组迭代器的执行会读取数组的length 属性。如果迭代的是数组元素值,还会读取数组的索引。其实我们可以给出一个数组迭代器的模拟实现:

01 const arr = [1, 2, 3, 4, 5]
02
03 arr[Symbol.iterator] = function() {
04   const target = this
05   const len = target.length
06   let index = 0
07
08   return {
09     next() {
10       return {
11         value: index < len ? target[index] : undefined,
12         done: index++ >= len
13       }
14     }
15   }
16 }

如上面的代码所示,我们用自定义的实现覆盖了数组内建的迭代器方法,但它仍然能够正常工作。

这个例子表明,迭代数组时,只需要在副作用函数与数组的长度和索引之间建立响应联系,就能够实现响应式的 for…of 迭代:

01 const arr = reactive([1, 2, 3, 4, 5])
02
03 effect(() => {
04   for (const val of arr) {
05     console.log(val)
06   }
07 })
08
09 arr[1] = 'bar'  // 能够触发响应
10 arr.length = 0  // 能够触发响应

可以看到,不需要增加任何代码就能够使其正确地工作。这是因为只要数组的长度和元素值发生改变,副作用函数自然会重新执行。

这里不得不提的一点是,数组的 values 方法的返回值实际上就是数组内建的迭代器,我们可以验证这一点:

01 console.log(Array.prototype.values === Array.prototype[Symbol.iterator]) // true

换句话说,在不增加任何代码的情况下,我们也能够让数组的迭代器方法正确地工作:

01 const arr = reactive([1, 2, 3, 4, 5])
02
03 effect(() => {
04   for (const val of arr.values()) {
05     console.log(val)
06   }
07 })
08
09 arr[1] = 'bar'  // 能够触发响应
10 arr.length = 0  // 能够触发响应

最后需要指出的是,无论是使用 for…of 循环,还是调用 values 等方法,它们都会读取数组的Symbol.iterator 属性。该属性是一个 symbol 值,为了避免发生意外的错误,以及性能上的考虑,我们不应该在副作用函数与 Symbol.iterator 这类 symbol 值之间建立响应联系,因此需要修改get 拦截函数,如以下代码所示:

01 function createReactive(obj, isShallow = false, isReadonly = false) {
02   return new Proxy(obj, {
03     // 拦截读取操作
04     get(target, key, receiver) {
05       console.log('get: ', key)
06       if (key === 'raw') {
07         return target
08       }
09
10       // 添加判断,如果 key 的类型是 symbol,则不进行追踪
11       if (!isReadonly && typeof key !== 'symbol') {
12         track(target, key)
13       }
14
15       const res = Reflect.get(target, key, receiver)
16
17       if (isShallow) {
18         return res
19       }
20
21       if (typeof res === 'object' && res !== null) {
22         return isReadonly ? readonly(res) : reactive(res)
23       }
24
25       return res
26     },
27   })
28 }

在调用 track 函数进行追踪之前,需要添加一个判断条件,即只有当 key 的类型不是 symbol 时才进行追踪,这样就避免了上述问题。

7.3、数组的查找方法

数组的方法内部其实都依赖了对象的基本语义。所以大多数情况下,我们不需要做特殊处理即可让这些方法按预期工作,例如:

01 const arr = reactive([1, 2])
02
03 effect(() => {
04   console.log(arr.includes(1)) // 初始打印 true
05 })
06
07 arr[0] = 3 // 副作用函数重新执行,并打印 false

这是因为 includes 方法为了找到给定的值,它内部会访问数组的 length 属性以及数组的索引,因此当我们修改某个索引指向的元素值后能够触发响应。

然而 includes 方法并不总是按照预期工作,举个例子:

01 const obj = {}
02 const arr = reactive([obj])
03
04 console.log(arr.includes(arr[0]))  // false

如上面的代码所示。我们首先定义一个对象 obj,并将其作为数组的第一个元素,然后调用 reactive 函数为其创建一个响应式对象,接着尝试调用includes 方法在数组中进行查找,看看其中是否包含第一个元素。很显然,这个操作应该返回true,但如果你尝试运行这段代码,会发现它返回了 false。

为什么会这样呢?这需要我们去查阅语言规范,看看 includes 方法的执行流程是怎样的:
非原始值的响应式方案_第12张图片
我们知道,通过代理对象来访问元素值时,如果值仍然是可以被代理的,那么得到的值就是新的代理对象而非原始对象。下面这段 get 拦截函数内的代码可以证明这一点:

01 if (typeof res === 'object' && res !== null) {
02   // 如果值可以被代理,则返回代理对象
03   return isReadonly ? readonly(res) : reactive(res)
04 }

知道这些后,我们再回头看这句代码:arr.includes(arr[0])。其中,arr[0] 得到的是一个代理对象,而在 includes 方法内部也会通过 arr 访问数组元素,从而也得到一个代理对象,问题是这两个代理对象是不同的。这是因为每次调用reactive 函数时都会创建一个新的代理对象:

01 function reactive(obj) {
02   // 每次调用 reactive 时,都会创建新的代理对象
03   return createReactive(obj)
04 }

即使参数 obj 是相同的,每次调用 reactive 函数时,也都会创建新的代理对象。这个问题的解决方案如下所示:

01 // 定义一个 Map 实例,存储原始对象到代理对象的映射
02 const reactiveMap = new Map()
03
04 function reactive(obj) {
05   // 优先通过原始对象 obj 寻找之前创建的代理对象,如果找到了,直接返回已有的代理对象
06   const existionProxy = reactiveMap.get(obj)
07   if (existionProxy) return existionProxy
08
09   // 否则,创建新的代理对象
10   const proxy = createReactive(obj)
11   // 存储到 Map 中,从而避免重复创建
12   reactiveMap.set(obj, proxy)
13
14   return proxy
15 }

在上面这段代码中,我们定义了 reactiveMap,用来存储原始对象到代理对象的映射。每次调用reactive 函数创建代理对象之前,优先检查是否已经存在相应的代理对象,如果存在,则直接返回已有的代理对象,这样就避免了为同一个原始对象多次创建代理对象的问题。接下来,我们再次运行本节开头的例子:

01 const obj = {}
02 const arr = reactive([obj])
03
04 console.log(arr.includes(arr[0]))  // true

可以发现,此时的行为已经符合预期了。然而,还不能高兴得太早,再来看下面的代码:

01 const obj = {}
02 const arr = reactive([obj])
03
04 console.log(arr.includes(obj))  // false

在上面这段代码中,我们直接把原始对象作为参数传递给 includes 方法,这是很符合直觉的行为。而从用户的角度来看,自己明明把 obj 作为数组的第一个元素了,为什么在数组中却仍然找不到obj 对象呢?其实原因很简单,因为 includes 内部的 this 指向的是代理对象 arr,并且在获取数组元素时得到的值也是代理对象,所以拿原始对象obj 去查找肯定找不到,因此返回 false。为此,我们需要重写数组的 includes 方法并实现自定义的行为,才能解决这个问题。首先,我们来看如何重写 includes 方法,如下面的代码所示:

01 const arrayInstrumentations = {
02   includes: function() {/* ... */}
03 }
04
05 function createReactive(obj, isShallow = false, isReadonly = false) {
06   return new Proxy(obj, {
07     // 拦截读取操作
08     get(target, key, receiver) {
09       console.log('get: ', key)
10       if (key === 'raw') {
11         return target
12       }
13       // 如果操作的目标对象是数组,并且 key 存在于 arrayInstrumentations 上,
14       // 那么返回定义在 arrayInstrumentations 上的值
15       if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
16         return Reflect.get(arrayInstrumentations, key, receiver)
17       }
18
19       if (!isReadonly && typeof key !== 'symbol') {
20         track(target, key)
21       }
22
23       const res = Reflect.get(target, key, receiver)
24
25       if (isShallow) {
26         return res
27       }
28
29       if (typeof res === 'object' && res !== null) {
30         return isReadonly ? readonly(res) : reactive(res)
31       }
32
33       return res
34     },
35   })
36 }

在上面这段代码中,我们修改了 get 拦截函数,目的是重写数组的 includes 方法。具体怎么做呢?我们知道,arr.includes 可以理解为读取代理对象 arr 的 includes 属性,这就会触发 get 拦截函数,在该函数内检查 target 是否是数组,如果是数组并且读取的键值存在于arrayInstrumentations 上,则返回定义在arrayInstrumentations 对象上相应的值。也就是说,当执行 arr.includes 时,实际执行的是定义在arrayInstrumentations 上的 includes 函数,这样就实现了重写。

接下来,我们就可以自定义 includes 函数了:

01 const originMethod = Array.prototype.includes
02 const arrayInstrumentations = {
03   includes: function(...args) {
04     // this 是代理对象,先在代理对象中查找,将结果存储到 res 中
05     let res = originMethod.apply(this, args)
06
07     if (res === false) {
08       // res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找并更新 res 值
09       res = originMethod.apply(this.raw, args)
10     }
11     // 返回最终结果
12     return res
13   }
14 }

如上面这段代码所示,其中 includes 方法内的this 指向的是代理对象,我们先在代理对象中进行查找,这其实是实现了 arr.include(obj) 的默认行为。如果找不到,通过 this.raw 拿到原始数组,再去其中查找,最后返回结果,这样就解决了上述问题。运行如下测试代码:

01 const obj = {}
02 const arr = reactive([obj])
03
04 console.log(arr.includes(obj))  // true

可以发现,现在代码的行为已经符合预期了。

除了 includes 方法之外,还需要做类似处理的数组方法有 indexOf 和 lastIndexOf,因为它们都属于根据给定的值返回查找结果的方法。完整的代码如下:

01 const arrayInstrumentations = {}
02
03 ;['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
04   const originMethod = Array.prototype[method]
05   arrayInstrumentations[method] = function(...args) {
06     // this 是代理对象,先在代理对象中查找,将结果存储到 res 中
07     let res = originMethod.apply(this, args)
08
09     if (res === false || res === -1) {
10       // res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找,并更新 res 值
11       res = originMethod.apply(this.raw, args)
12     }
13     // 返回最终结果
14     return res
15   }
16 })

7.4、隐式修改数组长度的原型方法

如何处理那些会隐式修改数组长度的方法,主要指的是数组的栈方法,例如push/pop/shift/unshift。除此之外,splice 方法也会隐式地修改数组长度,我们可以查阅规范来证实这一点。以 push 方法为例,规范的 23.1.3.20节定义了 push 方法的执行流程:
非原始值的响应式方案_第13张图片
当调用 push 方法并传递 0 个或多个参数时,会执行以下步骤:

  • 让 O 的值为 ? ToObject(this value)。
  • 让 len 的值为 ? LengthOfArrayLike(O)。
  • 让 argCount 的值为 items 的元素数量。
  • 如果 len + argCount > 2[53] - 1,则抛出TypeError 异常。
  • 对于 items 中的每一个元素 E:执行 ? Set(O, ! ToString(插图), E, true);将 len 设置为 len + 1。
  • 执行 ? Set(O, ‘‘length’’, (len), true)。
  • 返回 插图。

由第 2 步和第 6 步可知,当调用数组的 push 方法向数组中添加元素时,既会读取数组的 length 属性值,也会设置数组的 length 属性值。这会导致两个独立的副作用函数互相影响。以下面的代码为例:

01 const arr = reactive([])
02 // 第一个副作用函数
03 effect(() => {
04   arr.push(1)
05 })
06
07 // 第二个副作用函数
08 effect(() => {
09   arr.push(1)
10 })

如果你尝试在浏览器中运行上面这段代码,会得到栈溢出的错误(Maximum call stack size exceeded)。

为什么会这样呢?我们来详细分析上面这段代码的执行过程:

  • 第一个副作用函数执行。在该函数内,调用arr.push 方法向数组中添加了一个元素。我们知道,调用数组的 push 方法会间接读取数组的length 属性。所以,当第一个副作用函数执行完毕后,会与 length 属性建立响应联系。
  • 接着,第二个副作用函数执行。同样,它也会与length 属性建立响应联系。但不要忘记,调用arr.push 方法不仅会间接读取数组的 length 属性,还会间接设置 length 属性的值。
  • 第二个函数内的 arr.push 方法的调用设置了数组的 length 属性值。于是,响应系统尝试把与length 属性相关联的副作用函数全部取出并执行,其中就包括第一个副作用函数。问题就出在这里,可以发现,第二个副作用函数还未执行完毕,就要再次执行第一个副作用函数了。
  • 第一个副作用函数再次执行。同样,这会间接设置数组的 length 属性。于是,响应系统又要尝试把所有与 length 属性相关联的副作用函数取出并执行,其中就包含第二个副作用函数。
  • 如此循环往复,最终导致调用栈溢出。

问题的原因是 push 方法的调用会间接读取length 属性。所以,只要我们“屏蔽”对 length 属性的读取,从而避免在它与副作用函数之间建立响应联系,问题就迎刃而解了。这个思路是正确的,因为数组的 push 方法在语义上是修改操作,而非读取操作,所以避免建立响应联系并不会产生其他副作用。有了解决思路后,我们尝试实现它,这需要重写数组的 push 方法,如下面的代码所示:

01 // 一个标记变量,代表是否进行追踪。默认值为 true,即允许追踪
02 let shouldTrack = true
03 // 重写数组的 push 方法
04 ;['push'].forEach(method => {
05   // 取得原始 push 方法
06   const originMethod = Array.prototype[method]
07   // 重写
08   arrayInstrumentations[method] = function(...args) {
09     // 在调用原始方法之前,禁止追踪
10     shouldTrack = false
11     // push 方法的默认行为
12     let res = originMethod.apply(this, args)
13     // 在调用原始方法之后,恢复原来的行为,即允许追踪
14     shouldTrack = true
15     return res
16   }
17 })

在这段代码中,我们定义了一个标记变量shouldTrack,它是一个布尔值,代表是否允许追踪。接着,我们重写了数组的 push 方法,利用了前文介绍的 arrayInstrumentations 对象。重写后的 push 方法保留了默认行为,只不过在执行默认行为之前,先将标记变量 shouldTrack 的值设置为 false,即禁止追踪。当 push 方法的默认行为执行完毕后,再将标记变量 shouldTrack 的值还原为 true,代表允许追踪。最后,我们还需要修改 track 函数,如下面的代码所示:

01 function track(target, key) {
02   // 当禁止追踪时,直接返回
03   if (!activeEffect || !shouldTrack) return
04   // 省略部分代码
05 }

可以看到,当标记变量 shouldTrack 的值为 false 时,即禁止追踪时,track 函数会直接返回。这样,当 push 方法间接读取 length 属性值时,由于此时是禁止追踪的状态,所以 length 属性与副作用函数之间不会建立响应联系。这样就实现了前文给出的方案。我们再次尝试运行下面这段测试代码:

01 const arr = reactive([])
02 // 第一个副作用函数
03 effect(() => {
04   arr.push(1)
05 })
06
07 // 第二个副作用函数
08 effect(() => {
09   arr.push(1)
10 })

会发现它能够正确地工作,并且不会导致调用栈溢出。

除了 push 方法之外,pop、shift、unshift 以及splice 等方法都需要做类似的处理。完整的代码如下:

01 let shouldTrack = true
02 // 重写数组的 push、pop、shift、unshift 以及 splice 方法
03 ;['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
04   const originMethod = Array.prototype[method]
05   arrayInstrumentations[method] = function(...args) {
06     shouldTrack = false
07     let res = originMethod.apply(this, args)
08     shouldTrack = true
09     return res
10   }
11 })

8、代理 Set 和 Map

介绍集合类型数据的响应式方案。集合类型包括 Map/Set 以及WeakMap/WeakSet。使用 Proxy 代理集合类型的数据不同于代理普通对象,因为集合类型数据的操作与普通对象存在很大的不同。下面总结了 Set 和 Map 这两个数据类型的原型属性和方法。

Set 类型的原型属性和方法如下:

  • size:返回集合中元素的数量。
  • add(value):向集合中添加给定的值。
  • clear():清空集合。
  • delete(value):从集合中删除给定的值。
  • has(value):判断集合中是否存在给定的值。
  • keys():返回一个迭代器对象。可用于 for…of 循环,迭代器对象产生的值为集合中的元素值。
  • values():对于 Set 集合类型来说,keys() 与values() 等价。
  • entries():返回一个迭代器对象。迭代过程中为集合中的每一个元素产生一个数组值 [value,value]。
  • forEach(callback[, thisArg]):forEach 函数会遍历集合中的所有元素,并对每一个元素调用callback 函数。forEach 函数接收可选的第二个参数 thisArg,用于指定 callback 函数执行时的this 值。

Map 类型的原型属性和方法如下:

  • size:返回 Map 数据中的键值对数量。
  • clear():清空 Map。
  • delete(key):删除指定 key 的键值对。
  • has(key):判断 Map 中是否存在指定 key 的键值对。
  • get(key):读取指定 key 对应的值。
  • set(key, value):为 Map 设置新的键值对。
  • keys():返回一个迭代器对象。迭代过程中会产生键值对的 key 值。
  • values():返回一个迭代器对象。迭代过程中会产生键值对的 value 值。
  • entries():返回一个迭代器对象。迭代过程中会产生由 [key, value] 组成的数组值。
  • forEach(callback[, thisArg]):forEach 函数会遍历 Map 数据的所有键值对,并对每一个键值对调用 callback 函数。forEach 函数接收可选的第二个参数 thisArg,用于指定 callback 函数执行时的 this 值。

观察上述列表可以发现,Map 和 Set 这两个数据类型的操作方法相似。它们之间最大的不同体现在,Set 类型使用 add(value) 方法添加元素,而Map 类型使用 set(key, value) 方法设置键值对,并且 Map 类型可以使用 get(key) 方法读取相应的值。既然两者如此相似,那么是不是意味着我们可以用相同的处理办法来实现对它们的代理呢?没错,接下来,我们就深入探讨如何实现对 Set 和Map 类型数据的代理。

8.1、如何代理 Set 和 Map

Set 和 Map 类型的数据有特定的属性和方法用来操作自身。这一点与普通对象不同,如下面的代码所示:

01 // 普通对象的读取和设置操作
02 const obj = { foo: 1 }
03 obj.foo // 读取属性
04 obj.foo = 2 // 设置属性
05
06 // 用 get/set 方法操作 Map 数据
07 const map = new Map()
08 map.set('key', 1) // 设置数据
09 map.get('key') // 读取数据

正是因为这些差异的存在,我们不能像代理普通对象那样代理 Set 和 Map 类型的数据。但整体思路不变,即当读取操作发生时,应该调用 track 函数建立响应联系;当设置操作发生时,应该调用trigger 函数触发响应,例如:

01 const proxy = reactive(new Map([['key', 1]]))
02
03 effect(() => {
04   console.log(proxy.get('key')) // 读取键为 key 的值
05 })
06
07 proxy.set('key', 2) // 修改键为 key 的值,应该触发响应

当然,这段代码展示的效果是我们最终要实现的目标。但在动手实现之前,我们有必要先了解关于使用 Proxy 代理 Set 或 Map 类型数据的注意事项。

先来看一段代码,如下:

01 const s = new Set([1, 2, 3])
02 const p = new Proxy(s, {})
03
04 console.log(p.size) // 报错 TypeError: Method get Set.prototype.size called on incompatible receiver

在这段代码中,我们首先定义了一个 Set 类型的数据 s,接着为它创建一个代理对象 p。由于代理的目标对象是 Set 类型,因此我们可以通过读取它的 p.size 属性获取元素的数量。但不幸的是,我们得到了一个错误。错误信息的大意是“在不兼容的 receiver 上调用了 get Set.prototype.size 方法”。由此我们大概能猜到,size 属性应该是一个访问器属性,所以它作为方法被调用了。通过查阅规范可以证实这一点:
非原始值的响应式方案_第14张图片
Set.prototype.size 是一个访问器属性,它的 set 访问器函数是 undefined,它的 get 访问器函数会执行以下步骤:

  • 让 S 的值为 this。
  • 执行 ? RequireInternalSlot(S, [[SetData]])。
  • 让 entries 的值为 List,即 S.[[SetData]]。
  • 让 count 的 值为 0。
  • 对于 entries 中 的每个元素 e,执行:a. 如果 e 不是空的,则将 count 设置为 count +1。
  • 返回 插图。

由此可知,Set.prototype.size 是一个访问器属性。这里的关键点在第 1 步和第 2 步。根据第 1步的描述:让 S 的值为 this。这里的 this 是谁呢?由于我们是通过代理对象 p 来访问 size 属性的,所以 this 就是代理对象 p。接着在第 2 步中,调用抽象方法 RequireInternalSlot(S,[[SetData]]) 来检查 S 是否存在内部槽[[SetData]]。很显然,代理对象 S 不存在[[SetData]] 这个内部槽,于是会抛出一个错误,也就是前面例子中得到的错误。

为了修复这个问题,我们需要修正访问器属性的getter 函数执行时的 this 指向,如下面的代码所示:

01 const s = new Set([1, 2, 3])
02 const p = new Proxy(s, {
03     get(target, key, receiver) {
04       if (key === 'size') {
05         // 如果读取的是 size 属性
06         // 通过指定第三个参数 receiver 为原始对象 target 从而修复问题
07         return Reflect.get(target, key, target)
08       }
09       // 读取其他属性的默认行为
10       return Reflect.get(target, key, receiver)
11     }
12 })
13
14 console.log(s.size) // 3

在上面这段代码中,我们在创建代理对象时增加了get 拦截函数。然后检查读取的属性名称是不是size,如果是,则在调用 Reflect.get 函数时指定第三个参数为原始 Set 对象,这样访问器属性size 的 getter 函数在执行时,其 this 指向的就是原始 Set 对象而非代理对象了。由于原始 Set 对象上存在 [[SetData]] 内部槽,因此程序得以正确运行。

接着,我们再来尝试从 Set 中删除数据,如下面的代码所示:

01 const s = new Set([1, 2, 3])
02 const p = new Proxy(s, {
03     get(target, key, receiver) {
04       if (key === 'size') {
05         return Reflect.get(target, key, target)
06       }
07       // 读取其他属性的默认行为
08       return Reflect.get(target, key, receiver)
09     }
10   }
11 )
12
13 // 调用 delete 方法删除值为 1 的元素
14 // 会得到错误 TypeError: Method Set.prototype.delete called on incompatible receiver [object Object]
15 p.delete(1)

可以看到,调用 p.delete 方法时会得到一个错误,这个错误与前文讲解的访问 p.size 属性时发生的错误非常相似。为了搞清楚问题的原因,我们需要详细分析当调用 p.delete(1) 方法时都发生了什么。

实际上,访问 p.size 与访问 p.delete 是不同的。这是因为 size 是属性,是一个访问器属性,而delete 是一个方法。当访问 p.size 时,访问器属性的 getter 函数会立即执行,此时我们可以通过修改 receiver 来改变 getter 函数的 this 的指向。而当访问 p.delete 时,delete 方法并没有执行,真正使其执行的语句是 p.delete(1) 这句函数调用。因此,无论怎么修改 receiver,delete 方法执行时的 this 都会指向代理对象 p,而不会指向原始 Set 对象。想要修复这个问题也不难,只需要把 delete 方法与原始数据对象绑定即可,如以下代码所示:

01 const s = new Set([1, 2, 3])
02 const p = new Proxy(s, {
03     get(target, key, receiver) {
04       if (key === 'size') {
05         return Reflect.get(target, key, target)
06       }
07       // 将方法与原始数据对象 target 绑定后返回
08       return target[key].bind(target)
09     }
10   }
11 )
12
13 // 调用 delete 方法删除值为 1 的元素,正确执行
14 p.delete(1)

在上面这段代码中,我们使用target[key].bind(target) 代替了Reflect.get(target, key, receiver)。可以看到,我们使用 bind 函数将用于操作数据的方法与原始数据对象 target 做了绑定。这样当 p.delete(1) 语句执行时,delete 函数的 this 总是指向原始数据对象而非代理对象,于是代码能够正确执行。

最后,为了后续讲解方便以及代码的可扩展性,我们将 new Proxy 也封装到前文介绍的createReactive 函数中:

01 const reactiveMap = new Map()
02 // reactive 函数与之前相比没有变化
03 function reactive(obj) {
04
05   const existionProxy = reactiveMap.get(obj)
06   if (existionProxy) return existionProxy
07   const proxy = createReactive(obj)
08
09   reactiveMap.set(obj, proxy)
10
11   return proxy
12 }
13 // 在 createReactive 里封装用于代理 Set/Map 类型数据的逻辑
14 function createReactive(obj, isShallow = false, isReadonly = false) {
15   return new Proxy(obj, {
16     get(target, key, receiver) {
17       if (key === 'size') {
18         return Reflect.get(target, key, target)
19       }
20
21       return target[key].bind(target)
22     }
23   })
24 }

这样,我们就可以很简单地创建代理数据了:

01 const p = reactive(new Set([1, 2, 3]))
02 console.log(p.size) // 3

8.2、建立响应联系

了解了为 Set 和 Map 类型数据创建代理时的注意事项之后,我们就可以着手实现 Set 类型数据的响应式方案了。其实思路并不复杂,以下面的代码为例:

01 const p = reactive(new Set([1, 2, 3]))
02
03 effect(() => {
04   // 在副作用函数内访问 size 属性
05   console.log(p.size)
06 })
07 // 添加值为 1 的元素,应该触发响应
08 p.add(1)

这段代码展示了响应式 Set 类型数据的工作方式。首先,在副作用函数内访问了 p.size 属性;接着,调用 p.add 函数向集合中添加数据。由于这个行为会间接改变集合的 size 属性值,所以我们期望副作用函数会重新执行。为了实现这个目标,我们需要在访问 size 属性时调用 track 函数进行依赖追踪,然后在 add 方法执行时调用trigger 函数触发响应。下面的代码展示了如何进行依赖追踪:

01 function createReactive(obj, isShallow = false, isReadonly = false) {
02   return new Proxy(obj, {
03     get(target, key, receiver) {
04       if (key === 'size') {
05         // 调用 track 函数建立响应联系
06         track(target, ITERATE_KEY)
07         return Reflect.get(target, key, target)
08       }
09
10       return target[key].bind(target)
11     }
12   })
13 }

可以看到,当读取 size 属性时,只需要调用 track 函数建立响应联系即可。这里需要注意的是,响应联系需要建立在 ITERATE_KEY 与副作用函数之间,这是因为任何新增、删除操作都会影响 size 属性。接着,我们来看如何触发响应。当调用add 方法向集合中添加新元素时,应该怎么触发响应呢?很显然,这需要我们实现一个自定义的add 方法才行,如以下代码所示:

01 // 定义一个对象,将自定义的 add 方法定义到该对象下
02 const mutableInstrumentations = {
03   add(key) {/* ... */}
04 }
05
06 function createReactive(obj, isShallow = false, isReadonly = false) {
07   return new Proxy(obj, {
08     get(target, key, receiver) {
09       // 如果读取的是 raw 属性,则返回原始数据对象 target
10       if (key === 'raw') return target
11       if (key === 'size') {
12         track(target, ITERATE_KEY)
13         return Reflect.get(target, key, target)
14       }
15       // 返回定义在 mutableInstrumentations 对象下的方法
16       return mutableInstrumentations[key]
17     }
18   })
19 }

首先,定义一个对象mutableInstrumentations,我们会将所有自定义实现的方法都定义到该对象下,例如mutableInstrumentations.add 方法。然后,在get 拦截函数内返回定义在mutableInstrumentations 对象中的方法。这样,当通过 p.add 获取方法时,得到的就是我们自定义的 mutableInstrumentations.add 方法了。有了自定义实现的方法后,就可以在其中调用trigger 函数触发响应了:

01 // 定义一个对象,将自定义的 add 方法定义到该对象下
02 const mutableInstrumentations = {
03   add(key) {
04     // this 仍然指向的是代理对象,通过 raw 属性获取原始数据对象
05     const target = this.raw
06     // 通过原始数据对象执行 add 方法添加具体的值,
07     // 注意,这里不再需要 .bind 了,因为是直接通过 target 调用并执行的
08     const res = target.add(key)
09     // 调用 trigger 函数触发响应,并指定操作类型为 ADD
10     trigger(target, key, 'ADD')
11     // 返回操作结果
12     return res
13   }
14 }

如上面的代码所示,自定义的 add 函数内的 this 仍然指向代理对象,所以需要通过 this.raw 获取原始数据对象。有了原始数据对象后,就可以通过它调用 target.add 方法,这样就不再需要 .bind 绑定了。待添加操作完成后,调用 trigger 函数触发响应。需要注意的是,我们指定了操作类型为ADD,这一点很重要。还记得 trigger 函数的实现吗?我们来回顾一下,如下面的代码片段所示:

01 function trigger(target, key, type, newVal) {
02   const depsMap = bucket.get(target)
03   if (!depsMap) return
04   const effects = depsMap.get(key)
05
06   // 省略无关内容
07
08   // 当操作类型 type 为 ADD 时,会取出与 ITERATE_KEY 相关联的副作用函数并执行
09   if (type === 'ADD' || type === 'DELETE') {
10     const iterateEffects = depsMap.get(ITERATE_KEY)
11     iterateEffects && iterateEffects.forEach(effectFn => {
12       if (effectFn !== activeEffect) {
13         effectsToRun.add(effectFn)
14       }
15     })
16   }
17
18   effectsToRun.forEach(effectFn => {
19     if (effectFn.options.scheduler) {
20       effectFn.options.scheduler(effectFn)
21     } else {
22       effectFn()
23     }
24   })
25 }

当操作类型是 ADD 或 DELETE 时,会取出与ITERATE_KEY 相关联的副作用函数并执行,这样就可以触发通过访问 size 属性所收集的副作用函数来执行了。

当然,如果调用 add 方法添加的元素已经存在于Set 集合中了,就不再需要触发响应了,这样做对性能更加友好,因此,我们可以对代码做如下优化:

01 const mutableInstrumentations = {
02   add(key) {
03     const target = this.raw
04     // 先判断值是否已经存在
05     const hadKey = target.has(key)
06     // 只有在值不存在的情况下,才需要触发响应
07     const res = target.add(key)
08     if (!hadKey) {
09       trigger(target, key, 'ADD')
10     }
11     return res
12   }
13 }

在上面这段代码中,我们先调用 target.has 方法判断值是否已经存在,只有在值不存在的情况下才需要触发响应。

在此基础上,我们可以按照类似的思路轻松地实现delete 方法:

01 const mutableInstrumentations = {
02   delete(key) {
03     const target = this.raw
04     const hadKey = target.has(key)
05     const res = target.delete(key)
06     // 当要删除的元素确实存在时,才触发响应
07     if (hadKey) {
08       trigger(target, key, 'DELETE')
09     }
10     return res
11   }
12 }

如上面的代码所示,与 add 方法的区别在于,delete 方法只有在要删除的元素确实在集合中存在时,才需要触发响应,这一点恰好与 add 方法相反。

8.3、避免污染原始数据

借助 Map 类型数据的 set 和 get 这两个方法来讲解什么是“避免污染原始数据”及其原因。

Map 数据类型拥有 get 和 set 这两个方法,当调用 get 方法读取数据时,需要调用 track 函数追踪依赖建立响应联系;当调用 set 方法设置数据时,需要调用 trigger 方法触发响应。如下面的代码所示:

01 const p = reactive(new Map([['key', 1]]))
02
03 effect(() => {
04   console.log(p.get('key'))
05 })
06
07 p.set('key', 2) // 触发响应

其实想要实现上面这段代码所展示的功能并不难,因为我们已经有了实现 add、delete 等方法的经验。下面是 get 方法的具体实现:

01 const mutableInstrumentations = {
02   get(key) {
03     // 获取原始对象
04     const target = this.raw
05     // 判断读取的 key 是否存在
06     const had = target.has(key)
07     // 追踪依赖,建立响应联系
08     track(target, key)
09    // 如果存在,则返回结果。这里要注意的是,如果得到的结果 res 仍然是可代理的数据,
10    // 则要返回使用 reactive 包装后的响应式数据
11     if (had) {
12       const res = target.get(key)
13       return typeof res === 'object' ? reactive(res) : res
14     }
15   }
16 }

如上面的代码及注释所示,整体思路非常清晰。这里有一点需要注意,在非浅响应的情况下,如果得到的数据仍然可以被代理,那么要调用reactive(res) 将数据转换成响应式数据后返回。在浅响应模式下,就不需要这一步了。由于前文讲解过如何实现浅响应,因此这里不再详细讨论。

接着,我们来讨论 set 方法的实现。简单来说,当set 方法被调用时,需要调用 trigger 方法触发响应。只不过在触发响应的时候,需要区分操作的类型是 SET 还是 ADD,如下面的代码所示:

01 const mutableInstrumentations = {
02   set(key, value) {
03     const target = this.raw
04     const had = target.has(key)
05     // 获取旧值
06     const oldValue = target.get(key)
07     // 设置新值
08     target.set(key, value)
09     // 如果不存在,则说明是 ADD 类型的操作,意味着新增
10     if (!had) {
11       trigger(target, key, 'ADD')
12     } else if (oldValue !== value || (oldValue === oldValue && value === value)) {
13       // 如果不存在,并且值变了,则是 SET 类型的操作,意味着修改
14       trigger(target, key, 'SET')
15     }
16   }
17 }

这段代码的关键点在于,我们需要判断设置的 key 是否存在,以便区分不同的操作类型。我们知道,对于 SET 类型和 ADD 类型的操作来说,它们最终触发的副作用函数是不同的。因为 ADD 类型的操作会对数据的 size 属性产生影响,所以任何依赖 size 属性的副作用函数都需要在 ADD 类型的操作发生时重新执行。

上面给出的 set 函数的实现能够正常工作,但它仍然存在问题,即 set 方法会污染原始数据。这是什么意思呢?来看下面的代码:

01 // 原始 Map 对象 m
02 const m = new Map()
03 // p1 是 m 的代理对象
04 const p1 = reactive(m)
05 // p2 是另外一个代理对象
06 const p2 = reactive(new Map())
07 // 为 p1 设置一个键值对,值是代理对象 p2
08 p1.set('p2', p2)
09
10 effect(() => {
11   // 注意,这里我们通过原始数据 m 访问 p2
12   console.log(m.get('p2').size)
13 })
14 // 注意,这里我们通过原始数据 m 为 p2 设置一个键值对 foo --> 1
15 m.get('p2').set('foo', 1)

在这段代码中,我们首先创建了一个原始 Map 对象 m,p1 是对象 m 的代理对象,接着创建另外一个代理对象 p2,并将其作为值设置给 p1,即p1.set(‘p2’, p2)。接下来问题出现了,在副作用函数中,我们通过原始数据 m 来读取数据值,然后又通过原始数据 m 设置数据值,此时发现副作用函数重新执行了。这其实不是我们所期望的行为,因为原始数据不应该具有响应式数据的能力,否则就意味着用户既可以操作原始数据,又能够操作响应式数据,这样一来代码就乱套了。

那么,导致问题的原因是什么呢?其实很简单,观察我们前面实现的 set 方法:

01 const mutableInstrumentations = {
02   set(key, value) {
03     const target = this.raw
04     const had = target.has(key)
05     const oldValue = target.get(key)
06     // 我们把 value 原封不动地设置到原始数据上
07     target.set(key, value)
08     if (!had) {
09       trigger(target, key, 'ADD')
10     } else if (oldValue !== value || (oldValue === oldValue && value === value)) {
11       trigger(target, key, 'SET')
12     }
13   }
14 }

在 set 方法内,我们把 value 原样设置到了原始数据 target 上。如果 value 是响应式数据,就意味着设置到原始对象上的也是响应式数据,我们把响应式数据设置到原始数据上的行为称为数据污染

要解决数据污染也不难,只需要在调用 target.set 函数设置值之前对值进行检查即可:只要发现即将要设置的值是响应式数据,那么就通过 raw 属性获取原始数据,再把原始数据设置到 target 上,如下面的代码所示:

01 const mutableInstrumentations = {
02   set(key, value) {
03     const target = this.raw
04     const had = target.has(key)
05
06     const oldValue = target.get(key)
07     // 获取原始数据,由于 value 本身可能已经是原始数据,所以此时 value.raw 不存在,则直接使用 value
08     const rawValue = value.raw || value
09     target.set(key, rawValue)
10
11     if (!had) {
12       trigger(target, key, 'ADD')
13     } else if (oldValue !== value || (oldValue === oldValue && value === value)) {
14       trigger(target, key, 'SET')
15     }
16   }
17 }

现在的实现已经不会造成数据污染了。不过,细心观察上面的代码,会发现新的问题。我们一直使用raw 属性来访问原始数据是有缺陷的,因为它可能与用户自定义的 raw 属性冲突,所以在一个严谨的实现中,我们需要使用唯一的标识来作为访问原始数据的键,例如使用 Symbol 类型来代替。

本节中,我们通过 Map 类型数据的 set 方法讲解了关于避免污染原始数据的问题。其实除了 set 方法需要避免污染原始数据之外,Set 类型的 add 方法、普通对象的写值操作,还有为数组添加元素的方法等,都需要做类似的处理。

8.4、处理 forEach

集合类型的 forEach 方法类似于数组的 forEach 方法,我们先来看看它是如何工作的:

01 const m = new Map([
02   [{ key: 1 }, { value: 1 }]
03 ])
04
05 effect(() => {
06   m.forEach(function (value, key, m) {
07     console.log(value) // { value: 1 }
08     console.log(key) // { key: 1 }
09   })
10 })

以 Map 为例,forEach 方法接收一个回调函数作为参数,该回调函数会在 Map 的每个键值对上被调用。回调函数接收三个参数,分别是值、键以及原始 Map 对象。如上面的代码所示,我们可以使用 forEach 方法遍历 Map 数据的每一组键值对。

遍历操作只与键值对的数量有关,因此任何会修改Map 对象键值对数量的操作都应该触发副作用函数重新执行,例如 delete 和 add 方法等。所以当forEach 函数被调用时,我们应该让副作用函数与ITERATE_KEY 建立响应联系,如下面的代码所示:

01 const mutableInstrumentations = {
02   forEach(callback) {
03     // 取得原始数据对象
04     const target = this.raw
05     // 与 ITERATE_KEY 建立响应联系
06     track(target, ITERATE_KEY)
07     // 通过原始数据对象调用 forEach 方法,并把 callback 传递过去
08     target.forEach(callback)
09   }
10 }

这样我们就实现了对 forEach 操作的追踪,可以使用下面的代码进行测试:

01 const p = reactive(new Map([
02   [{ key: 1 }, { value: 1 }]
03 ]))
04
05 effect(() => {
06   p.forEach(function (value, key) {
07     console.log(value) // { value: 1 }
08     console.log(key) // { key: 1 }
09   })
10 })
11
12 // 能够触发响应
13 p.set({ key: 2 }, { value: 2 })

可以发现,这段代码能够按照预期工作。然而,上面给出的 forEach 函数仍然存在缺陷,我们在自定义实现的 forEach 方法内,通过原始数据对象调用了原生的 forEach 方法,即:

01 // 通过原始数据对象调用 forEach 方法,并把 callback 传递过去
02 target.forEach(callback)

这意味着,传递给 callback 回调函数的参数将是非响应式数据。这导致下面的代码不能按预期工作:

01 const key = { key: 1 }
02 const value = new Set([1, 2, 3])
03 const p = reactive(new Map([
04   [key, value]
05 ]))
06
07 effect(() => {
08   p.forEach(function (value, key) {
09     console.log(value.size) // 3
10   })
11 })
12
13 p.get(key).delete(1)

在上面这段代码中,响应式数据 p 有一个键值对,其中键是普通对象 { key: 1 },值是 Set 类型的原始数据 new Set([1, 2, 3])。接着,我们在副作用函数中使用 forEach 方法遍历 p,并在回调函数中访问 value.size。最后,我们尝试删除 Set 类型数据中值为 1 的元素,却发现没能触发副作用函数重新执行。导致问题的原因就是上面曾提到的,当通过 value.size 访问 size 属性时,这里的value 是原始数据对象,即 new Set([1, 2, 3]),而非响应式数据对象,因此无法建立响应联系。但这其实不符合直觉,因为 reactive 本身是深响应,forEach 方法的回调函数所接收到的参数也应该是响应式数据才对。为了解决这个问题,我们需要对现有实现做一些修改,如下面的代码所示:

01 const mutableInstrumentations = {
02   forEach(callback) {
03     // wrap 函数用来把可代理的值转换为响应式数据
04     const wrap = (val) => typeof val === 'object' ? reactive(val) : val
05     const target = this.raw
06     track(target, ITERATE_KEY)
07     // 通过 target 调用原始 forEach 方法进行遍历
08     target.forEach((v, k) => {
09       // 手动调用 callback,用 wrap 函数包裹 value 和 key 后再传给 callback,这样就实现了深响应
10       callback(wrap(v), wrap(k), this)
11     })
12   }
13 }

其实思路很简单,既然 callback 函数的参数不是响应式的,那就将它转换成响应式的。所以在上面的代码中,我们又对 callback 函数的参数做了一层包装,即把传递给 callback 函数的参数包装成响应式的。此时,如果再次尝试运行前文给出的例子,会发现它能够按预期工作了。

最后,出于严谨性,我们还需要做一些补充。因为forEach 函数除了接收 callback 作为参数之外,它还接收第二个参数,该参数可以用来指定callback 函数执行时的 this 值。更加完善的实现如下所示:

01 const mutableInstrumentations = {
02   // 接收第二个参数
03   forEach(callback, thisArg) {
04     const wrap = (val) => typeof val === 'object' ? reactive(val) : val
05     const target = this.raw
06     track(target, ITERATE_KEY)
07
08     target.forEach((v, k) => {
09       // 通过 .call 调用 callback,并传递 thisArg
10       callback.call(thisArg, wrap(v), wrap(k), this)
11     })
12   }
13 }

至此,我们的工作仍然没有完成。现在我们知道,无论是使用for…in 循环遍历一个对象,还是使用 forEach 循环遍历一个集合,它们的响应联系都是建立在 ITERATE_KEY 与副作用函数之间的。然而,使用 for…in 来遍历对象与使用 forEach 遍历集合之间存在本质的不同。具体体现在,当使用 for…in 循环遍历对象时,它只关心对象的键,而不关心对象的值,如以下代码所示:

01 effect(() => {
02   for (const key in obj) {
03     console.log(key)
04   }
05 })

只有当新增、删除对象的 key 时,才需要重新执行副作用函数。所以我们在 trigger 函数内判断操作类型是否是 ADD 或DELETE,进而知道是否需要触发那些与 ITERATE_KEY 相关联的副作用函数重新执行。对于 SET 类型的操作来说,因为它不会改变一个对象的键的数量,所以当 SET 类型的操作发生时,不需要触发副作用函数重新执行。

但这个规则不适用于 Map 类型的 forEach 遍历,如以下代码所示:

01 const p = reactive(new Map([
02   ['key', 1]
03 ]))
04
05 effect(() => {
06   p.forEach(function (value, key) {
07     // forEach 循环不仅关心集合的键,还关心集合的值
08     console.log(value) // 1
09   })
10 })
11
12 p.set('key', 2) // 即使操作类型是 SET,也应该触发响应

当使用 forEach 遍历 Map 类型的数据时,它既关心键,又关心值。这意味着,当调用 p.set(‘key’, 2) 修改值的时候,也应该触发副作用函数重新执行,即使它的操作类型是 SET。因此,我们应该修改 trigger 函数的代码来弥补这个缺陷:

01 function trigger(target, key, type, newVal) {
02   console.log('trigger', key)
03   const depsMap = bucket.get(target)
04   if (!depsMap) return
05   const effects = depsMap.get(key)
06
07   const effectsToRun = new Set()
08   effects && effects.forEach(effectFn => {
09     if (effectFn !== activeEffect) {
10       effectsToRun.add(effectFn)
11     }
12   })
13
14   if (
15     type === 'ADD' ||
16     type === 'DELETE' ||
17     // 如果操作类型是 SET,并且目标对象是 Map 类型的数据,
18     // 也应该触发那些与 ITERATE_KEY 相关联的副作用函数重新执行
19     (
20       type === 'SET' &&
21       Object.prototype.toString.call(target) === '[object Map]'
22     )
23   ) {
24     const iterateEffects = depsMap.get(ITERATE_KEY)
25     iterateEffects && iterateEffects.forEach(effectFn => {
26       if (effectFn !== activeEffect) {
27         effectsToRun.add(effectFn)
28       }
29     })
30   }
31
32   // 省略部分内容
33
34   effectsToRun.forEach(effectFn => {
35     if (effectFn.options.scheduler) {
36       effectFn.options.scheduler(effectFn)
37     } else {
38       effectFn()
39     }
40   })
41 }

如上面的代码所示,我们增加了一个判断条件:如果操作的目标对象是 Map 类型的,则 SET 类型的操作也应该触发那些与ITERATE_KEY 相关联的副作用函数重新执行。

8.5、迭代器方法

接下来,我们讨论关于集合类型的迭代器方法,实际上前面讲解如何拦截 for…of 循环遍历数组的时候介绍过迭代器的相关知识。集合类型有三个迭代器方法:

  • entries
  • keys
  • values

调用这些方法会得到相应的迭代器,并且可以使用 for…of 进行循环迭代,例如:

01 const m = new Map([
02   ['key1', 'value1'],
03   ['key2', 'value2']
04 ])
05
06 for (const [key, value] of m.entries()) {
07   console.log(key, value)
08 }
09 // 输出:
10 // key1 value1
11 // key2 value2

另外,由于 Map 或 Set 类型本身部署了 Symbol.iterator 方法,因此它们可以使用 for…of 进行迭代:

01 for (const [key, value] of m {
02   console.log(key, value)
03 }
04 // 输出:
05 // key1 value1
06 // key2 value2

当然,我们也可以调用迭代器函数取得迭代器对象后,手动调用迭代器对象的 next 方法获取对应的值:

01 const itr = m[Symbol.iterator]()
02 console.log(itr.next())  // { value: ['key1', 'value1'], done: false }
03 console.log(itr.next())  // { value: ['key2', 'value2'], done: false }
04 console.log(itr.next())  // { value: undefined, done: true }

实际上,m[Symbol.iterator] 与 m.entries 是等价的:

01 console.log(m[Symbol.iterator] === m.entries) // true

这就是为什么上例中使用 for…of 循环迭代 m.entries 和 m 会得到同样的结果。

理解了这些内容后,我们就可以尝试实现对迭代器方法的代理了。不过在这之前,不妨做一些尝试,看看会发生什么,如以下代码所示:

01 const p = reactive(new Map([
02   ['key1', 'value1'],
03   ['key2', 'value2']
04 ]))
05
06 effect(() => {
07   // TypeError: p is not iterable
08   for (const [key, value] of p) {
09     console.log(key, value)
10   }
11 })
12
13 p.set('key3', 'value3')

在这段代码中,我们首先创建一个代理对象 p,接着尝试使用for…of 循环遍历它,却得到了一个错误:“p 是不可迭代的”。我们知道一个对象能否迭代,取决于该对象是否实现了迭代协议,如果一个对象正确地实现了 Symbol.iterator 方法,那么它就是可迭代的。很显然,代理对象 p 没有实现Symbol.iterator 方法,因此我们得到了上面的错误。

但实际上,当我们使用 for…of 循环迭代一个代理对象时,内部会试图从代理对象 p 上读取 p[Symbol.iterator] 属性,这个操作会触发 get 拦截函数,所以我们仍然可以把 Symbol.iterator 方法的实现放到 mutableInstrumentations 中,如以下代码所示:

01 const mutableInstrumentations = {
02   [Symbol.iterator]() {
03     // 获取原始数据对象 target
04     const target = this.raw
05     // 获取原始迭代器方法
06     const itr = target[Symbol.iterator]()
07     // 将其返回
08     return itr
09   }
10 }

实现很简单,不过是把原始的迭代器对象返回而已,这样就能够使用 for…of 循环迭代代理对象 p 了,然而事情不可能这么简单。forEach 方法时我们提到过,传递给callback 的参数是包装后的响应式数据,如:

01 p.forEach((value, key) => {
02   // value 和 key 如果可以被代理,那么它们就是代理对象,即响应式数据
03 })

同理,使用 for…of 循环迭代集合时,如果迭代产生的值也是可以被代理的,那么也应该将其包装成响应式数据,例如:

01 for (const [key, value] of p) {
02   // 期望 key 和 value 是响应式数据
03 }

因此,我们需要修改代码:

01 const mutableInstrumentations = {
02   [Symbol.iterator]() {
03     // 获取原始数据对象 target
04     const target = this.raw
05     // 获取原始迭代器方法
06     const itr = target[Symbol.iterator]()
07
08     const wrap = (val) => typeof val === 'object' && val !== null ? reactive(val) : val
09
10     // 返回自定义的迭代器
11     return {
12       next() {
13         // 调用原始迭代器的 next 方法获取 value 和 done
14         const { value, done } = itr.next()
15         return {
16           // 如果 value 不是 undefined,则对其进行包裹
17           value: value ? [wrap(value[0]), wrap(value[1])] : value,
18           done
19         }
20       }
21     }
22   }
23 }

如以上代码所示,为了实现对 key 和 value 的包装,我们需要自定义实现的迭代器,在其中调用原始迭代器获取值 value 以及代表是否结束的 done。如果值 value 不为 undefined,则对其进行包装,最后返回包装后的代理对象,这样当使用for…of 循环进行迭代时,得到的值就会是响应式数据了。

最后,为了追踪 for…of 对数据的迭代操作,我们还需要调用track 函数,让副作用函数与 ITERATE_KEY 建立联系:

01 const mutableInstrumentations = {
02   [Symbol.iterator]() {
03     const target = this.raw
04     const itr = target[Symbol.iterator]()
05
06     const wrap = (val) => typeof val === 'object' && val !== null ? reactive(val) : val
07
08     // 调用 track 函数建立响应联系
09     track(target, ITERATE_KEY)
10
11     return {
12       next() {
13         const { value, done } = itr.next()
14         return {
15           value: value ? [wrap(value[0]), wrap(value[1])] : value,
16           done
17         }
18       }
19     }
20   }
21 }

由于迭代操作与集合中元素的数量有关,所以只要集合的 size 发生变化,就应该触发迭代操作重新执行。因此,我们在调用track 函数时让 ITERATE_KEY 与副作用函数建立联系。完成这一步后,集合的响应式数据功能就相对完整了,我们可以通过如下代码测试一下:

01 const p = reactive(new Map([
02   ['key1', 'value1'],
03   ['key2', 'value2']
04 ]))
05
06 effect(() => {
07   for (const [key, value] of p) {
08     console.log(key, value)
09   }
10 })
11
12 p.set('key3', 'value3') // 能够触发响应

前面我们说过,由于 p.entries 与 p[Symbol.iterator] 等价,所以我们可以使用同样的代码来实现对 p.entries 函数的拦截,如以下代码所示:

01 const mutableInstrumentations = {
02   // 共用 iterationMethod 方法
03   [Symbol.iterator]: iterationMethod,
04   entries: iterationMethod
05 }
06 // 抽离为独立的函数,便于复用
07 function iterationMethod() {
08   const target = this.raw
09   const itr = target[Symbol.iterator]()
10
11   const wrap = (val) => typeof val === 'object' ? reactive(val) : val
12
13   track(target, ITERATE_KEY)
14
15   return {
16     next() {
17       const { value, done } = itr.next()
18       return {
19         value: value ? [wrap(value[0]), wrap(value[1])] : value,
20         done
21       }
22     }
23   }
24 }

但当你尝试运行代码使用 for…of 进行迭代时,会得到一个错误:

01 // TypeError: p.entries is not a function or its return value is not iterable
02 for (const [key, value] of p.entries()) {
03   console.log(key, value)
04 }

错误的大意是 p.entries 的返回值不是一个可迭代对象。很显然,p.entries 函数的返回值是一个对象,该对象带有 next 方法,但不具有 Symbol.iterator 方法,因此它确实不是一个可迭代对象。这里是经常出错的地方,大家切勿把可迭代协议与迭代器协议搞混。可迭代协议指的是一个对象实现了Symbol.iterator 方法,而迭代器协议指的是一个对象实现了next 方法。但一个对象可以同时实现可迭代协议和迭代器协议,例如:

01 const obj = {
02   // 迭代器协议
03   next() {
04     // ...
05   }
06   // 可迭代协议
07   [Symbol.iterator]() {
08     return this
09   }
10 }

所以解决问题的方法也自然而然地出现了:

01 // 抽离为独立的函数,便于复用
02 function iterationMethod() {
03   const target = this.raw
04   const itr = target[Symbol.iterator]()
05
06   const wrap = (val) => typeof val === 'object' ? reactive(val) : val
07
08   track(target, ITERATE_KEY)
09
10   return {
11     next() {
12       const { value, done } = itr.next()
13       return {
14         value: value ? [wrap(value[0]), wrap(value[1])] : value,
15         done
16       }
17     }
18     // 实现可迭代协议
19     [Symbol.iterator]() {
20       return this
21     }
22   }
23 }

现在一切都能正常工作了。

8.6、values 与 keys 方法

values 方法的实现与 entries 方法类似,不同的是,当使用for…of 迭代 values 时,得到的仅仅是 Map 数据的值,而非键值对:

01 for (const value of p.values()) {
02   console.log(value)
03 }

values 方法的实现如下:

01 const mutableInstrumentations = {
02   // 共用 iterationMethod 方法
03   [Symbol.iterator]: iterationMethod,
04   entries: iterationMethod,
05   values: valuesIterationMethod
06 }
07
08 function valuesIterationMethod() {
09   // 获取原始数据对象 target
10   const target = this.raw
11   // 通过 target.values 获取原始迭代器方法
12   const itr = target.values()
13
14   const wrap = (val) => typeof val === 'object' ? reactive(val) : val
15
16   track(target, ITERATE_KEY)
17
18   // 将其返回
19   return {
20     next() {
21       const { value, done } = itr.next()
22       return {
23         // value 是值,而非键值对,所以只需要包裹 value 即可
24         value: wrap(value),
25         done
26       }
27     },
28     [Symbol.iterator]() {
29       return this
30     }
31   }
32 }

其中,valuesIterationMethod 与 iterationMethod 这两个方法有两点区别:

  • iterationMethod 通过 target[Symbol.iterator] 获取迭代器对象,而 valuesIterationMethod 通过 target.values 获取迭代器对象;
  • iterationMethod 处理的是键值对,即 [wrap(value[0]),wrap(value[1])],而 valuesIterationMethod 只处理值,即wrap(value)。

由于它们的大部分逻辑相同,所以我们可以将它们封装到一个可复用的函数中。但为了便于理解,这里仍然将它们设计为两个独立的函数来实现。

keys 方法与 values 方法非常类似,不同点在于,前者处理的是键而非值。因此,我们只需要修改 valuesIterationMethod 方法中的一行代码,即可实现对 keys 方法的代理。把下面这句代码:

01 const itr = target.values()

替换成:

01 const itr = target.keys()

这么做的确能够达到目的,但如果我们尝试运行如下测试用例,就会发现存在缺陷:

01 const p = reactive(new Map([
02   ['key1', 'value1'],
03   ['key2', 'value2']
04 ]))
05
06 effect(() => {
07   for (const value of p.keys()) {
08     console.log(value) // key1 key2
09   }
10 })
11
12 p.set('key2', 'value3') // 这是一个 SET 类型的操作,它修改了 key2 的值

在上面这段代码中,我们使用 for…of 循环来遍历 p.keys,然后调用 p.set(‘key2’, ‘value3’) 修改键为 key2 的值。在这个过程中,Map 类型数据的所有键都没有发生变化,仍然是 key1 和key2,所以在理想情况下,副作用函数不应该执行。但如果你尝试运行上例,会发现副作用函数仍然重新执行了。

这是因为,我们对 Map 类型的数据进行了特殊处理。前文提到,即使操作类型为 SET,也会触发那些与 ITERATE_KEY 相关联的副作用函数重新执行,trigger 函数的代码可以证明这一点:

01 function trigger(target, key, type, newVal) {
02   // 省略其他代码
03
04   if (
05     type === 'ADD' ||
06     type === 'DELETE' ||
07     // 即使是 SET 类型的操作,也会触发那些与 ITERATE_KEY 相关联的副作用函数重新执行
08     (
09       type === 'SET' &&
10       Object.prototype.toString.call(target) === '[object Map]'
11     )
12   ) {
13     const iterateEffects = depsMap.get(ITERATE_KEY)
14     iterateEffects && iterateEffects.forEach(effectFn => {
15       if (effectFn !== activeEffect) {
16         effectsToRun.add(effectFn)
17       }
18     })
19   }
20
21   // 省略其他代码
22 }

这对于 values 或 entries 等方法来说是必需的,但对于 keys 方法来说则没有必要,因为 keys 方法只关心 Map 类型数据的键的变化,而不关心值的变化。

解决办法很简单,如以下代码所示:

01 const MAP_KEY_ITERATE_KEY = Symbol()
02
03 function keysIterationMethod() {
04   // 获取原始数据对象 target
05   const target = this.raw
06   // 获取原始迭代器方法
07   const itr = target.keys()
08
09   const wrap = (val) => typeof val === 'object' ? reactive(val) : val
10
11   // 调用 track 函数追踪依赖,在副作用函数与 MAP_KEY_ITERATE_KEY 之间建立响应联系
12   track(target, MAP_KEY_ITERATE_KEY)
13
14   // 将其返回
15   return {
16     next() {
17       const { value, done } = itr.next()
18       return {
19         value: wrap(value),
20         done
21       }
22     },
23     [Symbol.iterator]() {
24       return this
25     }
26   }
27 }

在上面这段代码中,当调用 track 函数追踪依赖时,我们使用MAP_KEY_ITERATE_KEY 代替了 ITERATE_KEY。其中MAP_KEY_ITERATE_KEY 与 ITERATE_KEY 类似,是一个新的Symbol 类型,用来作为抽象的键。这样就实现了依赖收集的分离,即 values 和 entries 等方法仍然依赖 ITERATE_KEY,而keys 方法则依赖 MAP_KEY_ITERATE_KEY。当 SET 类型的操作只会触发与 ITERATE_KEY 相关联的副作用函数重新执行时,自然就会忽略那些与 MAP_KEY_ITERATE_KEY 相关联的副作用函数。但当 ADD 和 DELETE 类型的操作发生时,除了触发与ITERATE_KEY 相关联的副作用函数重新执行之外,还需要触发与 MAP_KEY_ITERATE_KEY 相关联的副作用函数重新执行,因此我们需要修改 trigger 函数的代码,如下所示:

01 function trigger(target, key, type, newVal) {
02   // 省略其他代码
03
04   if (
05     // 操作类型为 ADD 或 DELETE
06     (type === 'ADD' || type === 'DELETE') &&
07     // 并且是 Map 类型的数据
08     Object.prototype.toString.call(target) === '[object Map]'
09   ) {
10     // 则取出那些与 MAP_KEY_ITERATE_KEY 相关联的副作用函数并执行
11     const iterateEffects = depsMap.get(MAP_KEY_ITERATE_KEY)
12     iterateEffects && iterateEffects.forEach(effectFn => {
13       if (effectFn !== activeEffect) {
14         effectsToRun.add(effectFn)
15       }
16     })
17   }
18
19   // 省略其他代码
20 }

这样,就能够避免不必要的更新了:

01 const p = reactive(new Map([
02   ['key1', 'value1'],
03   ['key2', 'value2']
04 ]))
05
06 effect(() => {
07   for (const value of p.keys()) {
08     console.log(value)
09   }
10 })
11
12 p.set('key2', 'value3') // 不会触发响应
13 p.set('key3', 'value3') // 能够触发响应

你可能感兴趣的:(Web,#,Vue+TypeScript,vue.js)