非原始值的响应式方案(三、完全代理Array)
在上一节中,我们实现了手动完全代理 Object 类型,并且实现了深浅响应与深浅只读。接下来我们要一点点进入到 JS 的各个数据类型,所有的数据都得代理一遍,顺便了解一下各个数据类型的各个操作的底层到底是用的什么基本语义。那么开始开始!
在上一节中,我们实现了手动完全代理 Object 类型,并且实现了深浅响应与深浅只读。
接下来我们要一点点进入到 JS 的各个数据类型,所有的数据都得代理一遍,顺便了解一下各个数据类型的各个操作的底层到底是用的什么 基本语义。
那么开始开始!
Array是一个异质对象
我们知道,在 JavaScript 当中一切皆对象,所以 数组也只是一个特殊的对象而已,所以要实现对 Array 的代理的本质就是去了解 Array 和普通的对象相较而言有何特殊之处。
在之前的实现中,我们已经知道了在 JavaScript 当中存在两种类型的对象:常规对象与异质对象。而 Array 本身是一个异质对象,原因在于它其中的一个基本语义 [[DefineOwnProperty]]
的实现与常规对象不同。不过它的其他 10 个基本语义都是和常规对象相同的,这也就意味着实际上我们能够复用绝大部分的代码来对 Array 实现响应式代理。
我们可以试一下:
可以看到,能够正确的触发响应,这里使用的索引就相当于 arr
这个对象名为 0
的属性。不过既然是异质对象,它的一些操作操作肯定会与普通对象有所不同。
Array 的全部 读取 操作情况一般可以看成是这个对象内部属性的读取操作:
- 通过索引直接访问数组的元素值:
arr[0]
。 - 访问数组的长度:
arr.length
。 - 将数组作为对象,使用
for ... in
语法进行遍历。 - 使用
for ... of
迭代遍历数组。 - 数组的 原型方法,包括
concat | join | every | some | find | findIndex | includes
等,以及其他所有不改变原数组的原型方法。
可以看到,数组的读取操作是非常丰富的,并且很大程度上是多亏了数组原型上面丰富多样的原型方法。
Array 的全部 设置 操作:
- 通过索引来设置数组对应索引处的值:
arr[0] = hana
。 - 修改数组的长度:
arr.length = 0
。 - 数组的栈方法:
push | pop | shift | unshift
。 - 修改原数组的原型方法:
splice | fill | sort
等。
我们可以总结一下了,数组的读取 & 修改操作很大程度上是取决于其特殊属性以及其原型上的各种方法。其他的特殊对象,也都是差不多类似的情况。
Array的索引与length
在上面的例子中,我们通过索引来对数组元素进行 get 与 set,实际上是能够正常触发响应的。不过 Array 作为异质对象的唯一一点就是其基本语义 [[DefineOwnProperty]]
是不同于常规对象的实现的。当通过索引来设置数组元素的值时会执行基本语义 [[Set]]
,但是 [[Set]]
实际上是依赖于 [[DefineProperty]]
的,到了这里最终体现出了差异。
根据 ECMA 规范,实际上在根据索引设置值的时候,如果当前正在设置的索引值比 Array 的 length 值要大,会更新 length 属性到 index + 1 。所以在通过索引设置元素值的时候,可能会隐式地修改了 length 属性值。所以我们在触发响应的时候,需要触发与 length 属性相关联的副作用函数重新执行。
我们需要修改一下 createReactive
函数中的 set trap:
实际上只是在判断操作类型的时候新增了对数组类型的判断。如果设置的索引值小于数组的长度,那么直接视为 SET 操作;如果设置的索引值大于数组的长度,由于这也会间接的改变了 length 属性的值,所以要视为 ADD 操作。
基于以上的信息,我们可以改写一下 trigger 函数了:
可以看到,我们直接新增了一条规则,专门用于处理 TriggerType === 'ADD' && Array.isArray(target)
的情况,这样就能够精准定位到对数组的 set 操作是否间接的影响了 length 属性的变化。
不过反过来思考,修改数组的 length 属性实际上也会隐式地影响数组元素。比如,一个原本有 10 个元素的数组,将其 length 置为 5,会将其 5~9 的元素都被删除,这个时候也应该触发副作用函数重新执行。
所以实际上,修改 length 的值时只需要将索引值 ≥ length 的元素触发响应就可以。为了做到这一点,同样需要修改 set trap,在其触发 trigger 函数的时候需要携带第四个参数,能够把新设置的 length 的具体值带过去:
然后在 trigger 函数中把这个值利用起来:
如果很明确的知道了当前触发 trigger 的 target 是数组,并且触发的 key 是 length,那么就会自动取出所有索引 ≥ length 的 newValue
的元素,然后将其副作用函数都取出之后执行。
这样之后,我们对于 Array 中 length 与索引的处理基本上结束了。
Array遍历
for...in
说到 JS 中的数组,我们最常用的操作除了直接下标操作数组元素以外,最常用的肯定就是 遍历 了。而既然 Array 也是 JS 中的一种对象,那么它肯定也同样可以使用 for ... in
进行遍历。不过这种操作应该是要进行避免的,因为这个操作 本身其实是专门给遍历一般对象而设计的,Array 身为一个特质对象,其进行 for ... in
遍历后的结果其实会是 0 10 | 1 20
这样的 索引 + 值
的输出形式,和我们期待的 仅遍历数组元素 不符。并且这个遍历还会 在每次迭代时去查原型链上的所有可枚举属性,这会造成很大一部分的性能开销。
不过既然这种操作遍历数组是可行的,那么我们当然也得考虑一下啦。在之前对 Object 的代理当中,我们知道了 for ... in
遍历实际上是调用了对象的基本语义中的 ownKeys
,而这个基本语义在 Array 和一般对象上的实现都是一样的,所以实际上我们可以复用之前的那段逻辑:
当初为了追踪对于普通对象的 for ... in
操作,我们人为的使用了一个 Symbol
类型的 key ITERATE_KEY
作为追踪的 key。
对于普通对象来说,只有添加 & 删除属性值的时候才会影响 for ... in
的循环结果,不过对数组来说,有一些额外的情况,而这些额外的情况自然是出现在数组的索引操作上面:
- 添加新元素:
arr[100] = 'hana'
- 修改数组长度:
arr.length = 0
无论是添加新元素还是修改长度,本质上还是因为 修改了数组的 length 属性,而这个属性的修改就会导致数组的 for ... in
的循环结果被改变,而这种情况自然就得触发响应了。因为修改了数组的 length 属性的本质是修改了 数组的属性个数,for ... in
循环的次数可以说就是由 length 这个属性指定的。
所以为了处理这一特殊情况,我们需要在 ownKeys
trap 内部加上对于数组类型的特殊判断,如果是数组,那就用 length 作为 key 去追踪,如果不是,那就照老样子用 ITERATE_KEY
去追踪:
我们来写一段代码进行测试:
可以看到,正常的触发了响应:
for...of
说完了 for ... in
循环,接下来就是我们最常用、最标准也是最重量级的 for ... of
循环。
这个循环是一个标准的用来专门遍历 可迭代对象(iterable object) 的,在此之前我们还得先认识一下什么是可迭代对象。
可迭代对象实际上就是 JS 中实现了 迭代协议(iteration protocol) 的对象。迭代协议定义了一个对象是如何成为一个可迭代对象,从而能够使用 for ... of
语法进行遍历。迭代协议主要分为 可迭代协议 与 迭代器协议。
如果一个对象遵循了可迭代协议,那么它就可以被称为 可迭代对象。遵循这个协议的要求是:
- 对象需要具备
Symbol.iterator
属性。 Symbol.iterator
是一个无参方法,返回一个 迭代器对象,这个迭代器对象需要遵循迭代器协议。
迭代器协议则是为某些集合提供顺序访问的能力,它标志着 Symbol.iterator
需要返回什么样子的一个对象。这个对象被要求包含一个 next()
方法,这个方法返回包含当前迭代值 value
与一个用来标识是否迭代结束的布尔值 done
的对象,并且迭代结束后,done
为 true
,value
可以是 undefined
。
如果一个对象能够遵循以上的两个协议,那么它就是可以被迭代的。我们可以照着这个规则自己手搓一个可迭代的对象,并使用 for ... of
循环对其进行遍历:
可以看一下输出的结果:
而 Array 对象内部实际上内置了 Symbol.iterator
方法的实现。我们可以手动调用迭代器的 next()
方法来看一下具体的输出,为此我们需要先拿到迭代器本身,然后一遍一遍的去调用:
有了前置知识之后,我们需要了解一下 for ... of
这个循环实际上在底层调用的是 Array 的哪个基本语义。按照 ECMA 规范,实际上 数组迭代器的执行会读取数组的 length 属性,如果迭代的是数组元素值,还会读取数组的索引。基于此,我们可以给出一个数组迭代器的模拟实现,用自己写的方法覆盖原本的迭代器:
我们发现这样子实现后也是能够正常工作的。所以实际上,我们只需要实现副作用函数和索引之间建立响应式联系,就可以实现响应式的 for ... of
迭代。而在之前对于 Array 的处理当中,我们已经成功的建立起了数组索引与 length 属性的响应式联系,所以 我们实际上不用增加任何代码就可以实现对于 for ... of
的响应式代理。
当然,我们知道像 Array 这种内置的可迭代对象,实际上提供了一个专门返回其迭代器的方法:values()
,这点和 Map、Set 等结构是一样的。所以我们也不用增加任何代码,就可以对其 values()
返回的迭代器进行 for ... of
遍历时正确建立响应式。
基于以上的分析,实际上我们对于 for ... of
的处理基本上完成了。不过最后还有一点,那就是我们无论使用 for ... of
循环还是直接调用 values()
方法,实际上都会 读取 数组的 Symbol.iterator
属性,而这个属性是每个可迭代对象都具有的,所以是以 Symbol
的类型存在的。为了避免以外发生的错误,我们不应该让副作用函数与 Symbol.iterator
等 Symbol
类型的值之间建立响应联系。为了正确进行对这些特殊类型的 key 的过滤,我们也需要同步更改 get trap:
这下我们将 Array 的两种遍历方式都处理完成了!
Array查找
在前面的介绍中,我们已经大概对数组内部的方法都有概念了——数组的内部方法其实基本依赖的都是基本语义 ,唯一的特殊之处就是在于数组索引(属性个数)与数组长度(length)之间的关联。而把这个关联处理好,那么数组基本上就和一般对象一模一样了。
讲完了 Array 的遍历,我们也可以以类似的规律去分析一下 Array 的查找。
如果我们在数组里面存上基本数据类型,并且执行 includes()
方法对元素进行检查,我们会发现实际上不用更改代码就可以实现副作用函数收集,因为 includes()
方法内部依赖于数组的 length 属性以及数组的索引。
然而,如果数组里面存的不是基本类型而是一个对象,那么这段代码是无法按预期工作的:
这个时候,我们就需要手动去查阅 ECMA 的规范了,看 includes()
方法的底层执行依赖了哪些基本语义,以及它们的执行流程:
在这里我们可以着重看一下第一步:Let O be ? ToObject(this value). 。
如果有阅读过 ECMA 相关的标准,你会发现这实际上是一个非常常见的转换操作,用于确保当前上下文中的 this
值被安全地转换为一个对象类型。这里的 ToObject
相当于一个抽象操作,用来将传入的值(this value
)转换为一个对象。如果传入值已经是对象,就直接返回;如果不是对象(如原始值),则将它包装为对应的对象类型。它的主要作用是保证后续的操作可以以对象的形式进行。比如对于基本类型(string & number),在这一步会直接执行 Object(xxx)
的操作将其包装为一个对象,以供后续的处理流程。
重点在于,这个操作里面的 this 指向。在 arr.includes(arr[0])
语句中,arr
实际上是一个代理对象,所以在执行 includes()
的时候 this 指向的是 arr
。
接下来是第 10 步,这一步就是真正开始查找的流程了。在这一步,includes()
会通过索引读取元素的值,但是在这里实际上被操作的对象是代理对象 arr
。而通过代理对象来访问元素值的时候,如果这个元素值仍是可被代理的,那么得到的值会被递归的包装为响应式对象,而不是原始对象 。我们在之前刚刚写过递归处理深层响应式的逻辑:
所以实际上,arr.includes(arr[0])
这行代码,在 includes()
内部会通过索引,如果不传第二个参数则会从 0 开始,一个一个去 arr
上面找。而每次通过索引取到 arr
元素值的时候,根据我们之前编写的 get trap,实际就已经新建了一个响应式对象了。 而 includes()
传入的参数如果是一个表达式,比如这里的 arr[0]
,那么 JS 引擎会尝试 先计算表达式的值,再执行 includes
。所以,这里实际上,第一步先是读取了 arr[0]
的值 ,触发了 get trap,发现 arr[0]
居然是个对象,对象可以被代理,于是会进行深层代理,把 {}
也变为响应式,然后再执行 includes()
,根据索引一个个把其所有可以进行代理的元素全部变成响应式对象。
至此,我们就已经把完整的执行流程梳理完成了,我们可以简单进行总结:
- 在副作用函数注册函数
effect
中,执行console.log(arr.includes(arr[0]))
语句,arr
本身是响应式变量,触发了副作用收集机制。 - 首先,如果传入函数的参数不是一个具体的值,JS 引擎会计算传入函数内部的表达式。在这里,“计算” 的方式就是 读取
arr[0]
的值。但是,arr
本身是响应式变量,再加上我们之前写好的递归处理响应式逻辑,所以实际上在这里读出arr[0]
是一个对象,对象也可以被代理之后,也会把这个对象进行代理。最后的结果就是,传入includes()
内部的参数是一个包装好的响应式对象。 - 然后,执行
includes()
方法,这个方法在底层会通过索引从 0 开始读元素,直到读的元素等于传入的参数或者没找到。然而,用索引读元素也会创建响应式变量。 - 最后,一开始新建的响应式变量和
includes()
创建的响应式变量 不相等,所以返回 false。
至此,我们已经找到了罪魁祸首,接下来就是如何去解决了。即使参数 obj 是相同的,每次调用 reactive 函数时,也都会创建新的代理对象 。所以核心的解决方案,就是利用一个 Map 来存储原始对象和代理对象之间的映射关系。如果发现之前已经深层代理过这个对象了,那么直接从 Map 中取出并返回即可,以此确保相同的 obj 只对应一个代理对象。
这样子就能够解决我们一开始遇到的问题了。
不过,我们刚刚讲了一下,在 includes()
方法里面如果传入的是一个表达式,那么 JS 引擎会尝试运行它并拿到值,再放到 includes()
里面运行查找。传入 arr[0]
的时候,实际上是会对 arr[0]
的元素读取并进行代理的。
那么,我如果不传 arr[0]
而直接传 obj
呢?
结果输出当然是 false,因为你传个原始对象,我们 includes()
底层都直接把查的元素都变成代理对象了,代理对象和原始对象一比肯定不相等。
但是上面的写法又是非常非常自然的、符合一般直觉的写法,所以这个也是个 bug 我们得修。为了实现这一点,我们需要自己手动重写数组的 includes()
方法,以实现 自定义行为。所以实际上,Vue3 也是有重写数组方法的,并不是没有重写啊,面试的时候很多面试官甚至都不知道(笑
所以怎么去 重写 数组的方法呢?其实严格意义上来说并不是重写,而是我们手动利用 Proxy 代理的机制拦截对应数组方法对应的 key,然后直接手动去执行我们写在外面的对应的 key 对应的方法即可。 **对象中的方法,实际上也是一个键值对。键,就是方法名;值,就是函数体。**所以当然可以拦截,并执行我们想要的方法。
好,我们来重写吧:
可以看到,我们在真正进行依赖追踪之前,我们把对是否是数组以及是否正在调用数组的方法的情况进行了判断,并且使用 Reflect 手动调用了 arrayInstrumentations
对象内部定义的方法。
接下来,我们在 arrayInstrumentations
内部自定义方法体即可:
可以看到,实际上的自定义方法是两次调用原始方法的包装。这里的 this 可能会令人困惑,实际上这需要结合 Reflect.get(arrayInstrumentations, key, receiver)
这行代码来看。先看前两个参数,很简单,就是读取 arrayInstrumentations[key]
属性,如果是函数,则需要进行执行。而第三个参数,则是相当于为整个 get 操作指定了一个全局的上下文,也就意味着在调用 arrayInstrumentations[key]
方法的时候,方法内部的 this 的值就是所指定的 receiver,而这个 receiver 实际上就是这个包装好的代理对象。
基于以上的理解,我们可以观察整个重写的执行流程。首先,把数组原型上的 includes()
方法单独拿一份出来,使引用保持不变,用 apply 绑定 this 与参数。
第一次调用 originMethod
,此时的 this 是代理对象,会在代理对象中进行查找。而这个查找过程,之前也说过了,会按照索引一个个建立起响应联系,走的就是 arr.includes(arr[0])
的路,能够正确处理这个情况。
第二次调用 originMethod
,说明第一次直接在代理对象上找是找不到的,说明传进去的查找目标可能就是一个很单纯的对象或者是基本类型值。所以这个时候,我们直接用 this.raw
拿到原始的数组,在它上面再查一遍。
最后,将第一次、第二次查找的结果返回即可。
我们可以来写段 demo 来进行测试:
可以看到,结果已经是 true 了:
呼,讲了这么久,终于将 includes()
方法正确的进行代理了。而 Array 中的查找函数其实大差不差,需要进行类似处理的还有 indexOf
与 lastIndexOf
方法,它们在底层实际上也是通过类似的方式对数组元素进行查找的。解决一个问题,就能够以此类推平推许多问题!!
为了能够实现对 arrayInstrumentations
对象方法按照类型统一进行注册与管理,我们有必要直接根据具体方法名动态对属性进行注册。我们改写一下 arrayInstrumentations
的实现方式:
我们直接把要改写的数组方法用字符串数组的形式进行保存,然后直接 forEach
将每个方法都轮番注册一遍。这样子之后,如果有需要添加新的方法,我们可以直接往数组里面塞字符串就行了。
至此,对于 Array 查找部分的代理就告一段落,接下来需要对一些更加重量级也是更常用的方法进行正确代理:栈方法 。
Array栈方法
如果你是从头读到这里的,我相信你现在肯定对数组的异质的根本原因有了很清晰的了解,那就是 索引(index) 与 长度(length) 彼此间相互关联的问题。
而我们平常在对 Array 的使用中,对于其内容本身的增删改查也是 JS 数组所提供的强大特性之一。这些方法我们一般统称为 栈方法。具体的内容有:
- push()
- pop()
- shift()
- unshift()
不过除了栈方法以外,还有个特殊方法 splice()
也会修改数组长度,原理都是类似的。
push
我们先来讨论一下 push 方法吧,我们来看看这个方法的底层到底是怎么执行的,又依赖于哪些基本语义。
我们看一下第 2 步和第 6 步,第 2 步读取了 length 属性值,第 6 步又设置了 length 属性值为 length + 1。
???是不是跟我们一开始的 obj.count++
而造成栈溢出的情况一模一样?我们现在如果直接在副作用函数中用 push 方法其实确实会触发栈溢出。
不过解决方法也很明显,只要跟我们一开始解决自增问题一样的思路就行,在执行 push 方法时,屏蔽对 length 属性的读取。为了实现它,我们同样也需要重写数组的方法:
可以看到,我们新增了个 status:shouldTrack
,这个 status 用来控制当前是否能够进行依赖追踪。在重写的方法内部,也是老样子拿到原本的原型方法,之后在执行之前把 status 置为 false,表示现在不能够进行依赖追踪,等待执行完毕后,再将 status 置为 true。这样就巧妙地避开了对于 length 的依赖追踪,从而正确触发响应。
除了 push 方法以外,其他的几个类似的方法都要进行相同的处理。我们直接往数组里面加方法字符串就可以了:
至此,我们成功的代理了数组几乎全部的操作方面。