实际上,实现响应式数据要比想象中难很多,并不是单纯地拦截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]] 这个内部方法,下表列出了规范要求的所有必要的内部方法:
由上表可知,包括 [[Get]] 在内,一个对象必须部署 11 个必要的内部方法。除了上表所列的内部方法之外,还有两个额外的必要内部方法[插图]:[[Call]] 和 [[Construct]],如下表所示:
如果一个对象需要作为函数调用,那么这个对象就必须部署内部方法 [[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 对象部署的所有内部方法以及用来自定义内部方法和行为的拦截函数名字。
当然,其中 [[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 操作符的运行时逻辑:
描述的内容如下:
- 让 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 节中找到,它的操作如图 :
描述的内容如下:
- 断言: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 步描述的内容如下:
. 如果 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 操作符的行为,如下图所示:
第 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]]内部方法的执行流程:
第 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]] 内部方法的执行流程,如图:
第 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 的代理对象时才触发更新,这样就能屏蔽由原型引起的更新,从而避免不必要的更新操作。