有了上篇文章对于 JS 对象、Proxy 以及 Reflect 的了解,现在我们已经有了可以正式实现响应式数据的理论基础了。
在最开始的时候我们使用 Proxy 的 get 去拦截了 读取 这一操作,但是实际上 读取 这一操作在实际应用层面是一个很宽泛的概念。我们平时在代码中经常使用的一些语法很可能就是间接的触发了读取的操作。完备的响应式系统应该 100% 完全覆盖所有可能的基本语义触发情况。
对一个普通的 JS 对象所有可能的读取操作:
- 直接访问其属性:
obj.name
; - 判断对象 & 原型上面是否存在给定的
key
:key in obj
; - 使用
for ... in
来循环遍历对象:for (const key in obj) {}
。
接下来要做的就是如何正确的将这些情况全部拦截到了。我们看一下之前实现的 get 拦截方式:
我们现在已经拦截了第一点。我们在之前的文章中说过了一个对象具有其必不可少的基本语义,我们在代码中对其的所有操作在底层实际上都是进行了基本语义函数的调用。
所以,这些读取操作,从本质上都是一个个基本语义的调用。 我们要做的就是找到这些操作背后的基本语义是什么,然后对其进行直接拦截并代理,就可以了!
在这里我直接借用书上的结论:in 操作符的运算结果是调用一个叫做 HasProperty
的抽象方法。 而这个抽象方法也是调用 [[HasProperty]]
这个基本语义得到的。而这个基本语义对应的 Proxy trap 函数是 has
。
所以我们可以解决第二点了:
在这个的基础之上,我们可以进一步了解 for ... in
操作的基本语义。在 ECMA 规范中,这个操作是由 for (const key of Reflect.ownkeys(obj))
来实现的,所以其基本语义就是 [[ownKeys]]
。
接下来就解决一下第三点吧:
在这里,我们使用了一个外部的常量,ITERATE_KEY
,并且其为 Symbol
类型。核心原因是,我们在拦截 ownKeys
操作的时候,我们不知道正在拦截到的到底是哪个属性!
可以看到官方的 DTS 里面的回调参数也只有一个 target。因此,这个操作实际上 不与任何具体的键进行绑定,所以我们就用一个唯一的 key 对这种操作代为标识,也就是一个 Symbol
类型。
所以,我们在触发的时候也必须得用相同的 key 进行触发。问题来了,到底什么时候会触发 ITERATE_KEY
对应的副作用函数呢?
由于 for ... in
语法是对一个对象内部的所有属性键进行遍历,所以我们这么写副作用函数:
这里,我们一开始的 data 对象只有一个属性 name,后面我们给它加了一个新的 age 属性。加了之后,这个循环会从执行一次变为执行两次,这个时候就需要触发与 ITERATE_KEY
相关联的副作用函数重新执行。
为了做到这一点,我们要对 set 拦截函数进行改写。
按照目前的实现,给 obj
添加一个新的属性的时候,set 接收到的 key 仍然是 age
,而这和 ITERATE_KEY
一点关系都没有。所以这样子还不能正确的触发响应。
所以只要当添加属性时,我们手动把与 ITERATE_KEY
关联的副作用函数取出来执行就可以了。不过为了达成这一点,我们需要同时改 set 函数本身与对应的 trigger 函数。改动 set 函数主要为了 区分添加新属性还是单纯改变已有属性的值,改动 trigger 函数则是正确的触发 ITERATE_KEY
对应的副作用函数。
我们先来改 set 函数:
其实很简单,直接用 Object.hasOwnProperty
来判断一下 key 是否在 target 里面就可以了,用 call
来指定被实施的对象。然后用 type 来进行标识,最后把判断的结果作为 trigger 的第三个参数传给 trigger 就行,然后 trigger 内部再进行判断处理。
好,接下来就是改写一下 trigger:
其实也就是往 effectsToRun
里面把 ITERATE_KEY
对应的函数 Set 给推进去而已,如果是 ADD 操作就推进去,不是就不推进去。这里的枚举如果是用 TypeScript 写,直接用 enum
数据就可以。
在最后,还剩下最后一个操作就是 delete,删除对象的某个属性。当然,delete 肯定也是由基本语义实现的。查看 ECMA 规范:
可以看到,delete 操作依赖的就是单纯的 [[Delete]]
内部方法,我们可以使用 Proxy trap 中的 deleteProperty
方法进行拦截:
需要注意的是,我们当删除了一个对象的某个 key,这势必也会触发 for ... in
遍历的次数的变化,所以一般我们也需要直接触发 ITERATE_KEY
相关联的副作用函数重新执行。为此,我们直接改一下 iterateEffects
对应的获取条件:
这样就可以啦!我们来试试吧!
看一下输出!
结果很好!至此,对于一个标准 JS 对象的代理基本上就结束了。
虽然我们之前对于对象的代理做了非常多的工作,也实现了相对完善的代理方式,但是现在触发响应的方式还是相对不完善的。
在现在的实现中,我们只要对响应式变量的某个属性进行了 =
的操作,无论值是否发生变化都会触发 set,这听起来就很傻,因为 ECMA 规范并没有指定 设置 必须得设置与之前不同的值,而只要是设置操作,就触发的是 [[Set]]
这个基本语义。
所以我们来解决吧!
一个最直接的想法就是,在 set 这个 Proxy trap 函数里面,我们拿到 oldValue
之后与 newValue
进行比较,如果 oldValue !== newValue
,那么就直接 trigger。这确实是 99% 是对的,不过忘了一个东西:NaN
,这东西是 JS 唯一一个 非自反(irreflexive) 的值,也就是 NaN === NaN
不成立的值。不过问题不大,我们单独把它拿出来处理就行了。
改一下 set 部分的代码:
这样就解决了 NaN
的问题。不过,除了 NaN
以外,还有一些其他的设置场景,比如从原型上继承属性的情况。
我们来写一下这样的代码:
运行一下:
可以看到,结果输出了两次!
原因是什么呢?我们可以看到,首先,child 上面原本是空的,没有属性,所以在读取 child.age
属性的时候,实际上是从继承的原型 parent
上面进行读取的。
我们一点点来看这里的 get 与 set 的触发流程。首先在副作用依赖收集的部分,由于 child 本身是响应式数据,所以不可避免地会执行 Reflect(obj, 'age', receiver)
,执行 obj.age
的读取。而 obj
上并没有 age
属性,怎么办呢?在 ECMA 规范中指明了,如果当前对象没有对应的属性那么就会去其原型上面找,在这里就指的是 parent.age
,然后读它。但是 parent
本身也是响应式数据,相当于触发了它的基本语义 [[Get]]
然后触发了 track 追踪依赖,导致最后的结果是 child.age
和 parent.age
都被依赖收集了。
之后,当进行 child.age = 19
的时候,同样的道理,child
上面没有 age
属性,那么就会直接调用其原型上的 age
属性然后执行其基本语义 [[Set]]
,从而触发 trigger。而刚才我们刚说过 child.age
和 parent.age
都被依赖收集了,所以设置一次会导致触发两次,就是这个原因。
既然会触发两次,我们可以 屏蔽掉一次,留下一次。为了确保响应式系统的可预测性,我们选择屏蔽掉原型上的那一次基本语义,为了做到这一点,我们需要区分开来哪次是原型上的基本语义哪次是目标对象的基本语义。
当执行 child.age = 19
时,触发的 set trap 中的 target 是 obj,receiver 是 child。而 child 正是 obj 的代理对象,这其中是有一个明确的联系。
而当发现 obj 上面没有 age 时,会到其原型上去找。此时的 target 就变成了 proto,而 proto 和 child 没有联系 。我们可以凭这一点来判断。
为了利用起这个特点,我们需要给 get trap 加一个属性来让代理函数能够主动访问其原始对象:
之后我们在 set 函数里面用起来:
这样我们就可以判断是响应式数据的继承还是数据本身触发副作用函数了。运行一下代码:
成功!
我们或多或少都有使用过 Vue
中的 shallowReactive
与 reactive
。实际上,我们目前的实现还是一个 浅响应 的系统,也就是说我们无法去代理 多层嵌套 的数据。举个例子:
按照现在的实现,修改数据之后是无法触发这个副作用函数的。当读取 obj.people.name
时,首先第一个读取到的是 obj.people
,然后直接使用 Reflect.get
返回了结果,这个结果目前还是一个 普通对象 而非响应式数据,所以在访问 obj.people.name
的时候还是无法建立响应联系的。
为了能够将深层嵌套的属性也同样进行响应式注册,我们要把返回的逻辑改一下。在读取属性值的时候,先检测这个属性值是不是一个对象,如果是对象则 递归调用 reactive
函数将其包装成响应式数据并返回。
当然,有时候我们也 只想要浅响应,在目前的 Vue 当中是用 shallowReactive
实现的。我们之前写的那个 reactive
实际上就是浅响应。为了让这两个函数的实现部分统一而仅通过函数名区分,我们将创建响应式数据的函数进行封装:
这样子,我们就可以通过 reactive
和 shallowReactive
两个不同的函数分别实现深拷贝和浅拷贝了!
有时候我们会希望一些数据是只读的,不能够被修改。当其被修改时,会收到一条警告信息。基于上面的 createReactive
函数,我们可以简单的实现 readonly
函数。先改改 createReactive
使其接收第三个参数:
我们改了一下 set
和 deleteProperty
这两个 trap,使其能够根据传入的第三个参数来判断是否输出警告信息与不执行下面的代码。
既然是只读,那么肯定不允许修改,不修改就说明肯定不会重新触发副作用函数,那也就没必要建立响应联系了。所以也得改 get trap:
接下来就是二次封装 createReactive
函数了:
不过到目前为止,现在实现的应该也只能叫做浅只读,因为我们并没有递归的去调用 readonly
实现深层对象属性的代理。我们可以借助之前实现深响应与浅响应的逻辑,在递归上去做手脚:
当 isShallow
为 false 时,会走到 if (typeof res === 'object' && res !== null) {
的函数体内部。如果 isReadonly
为 true,则会递归调用 readonly
进行深层代理,反之调用 reactive
进行深层代理。
接下来就是对其进行二次封装了:
这样子之后,我们的深浅响应、深浅只读就都做好了。