1. 数组 includes 方法在响应式系统中存在的问题
通过前文# Vue响应式原理(10)-数组的索引和length的介绍我们意识到,数组的方法内部其实都依赖了对象的基本语义。所以大多数情况下,我们不需要做特殊处理即可让这些方法按预期工作,如下列代码:
js
const arr = reactive([1, 2])
effect(() => {
console.log(arr.includes(1)) // 初始打印 true
})
arr[0] = 3 // 副作用函数重新执行,并打印 false
这是因为 includes
方法在执行过程中,内部会访问数组的 length
属性以及数组的索引,因此隐式地完成了依赖收集过程,将副作用函数和数组索引建立了依赖联系。因此当我们修改某个索引指向的元素值后能够触发响应,重新执行副作用函数。
但是需要注意的是,includes
方法在部分场景下不会按照预期方式工作:
js
const obj = {}
const arr = reactive([obj])
console.log(arr.includes(arr[0])) // false
正常情况下,返回结果应该是 true
,但是这里却很诡异的打印了 false
,为什么会产生这样的现象?我们需要对 includes
方法的执行过程进行分析:
Version:0.9 StartHTML:0000000105 EndHTML:0000004127 StartFragment:0000000141 EndFragment:0000004087
上图展示了数组的 includes
方法的执行流程,我们重点关注第 1 步和第 10 步。其中,第 1 步就是对调用 includes
的对象使用 ?ToObject(this value)
,很显然这里的 this value
就是 arr
代理对象;第 10 步中,通过 while
循环对数组对象进行遍历,通过索引值获取数组中的对象,和 includes
方法传递的参数 searchElement
相对比,如果相同则返回 true
,否则继续遍历。
因此,我们在 includes
内部,会通过索引值逐个获取到 arr
对象的值,并且逐个和 arr[0]
进行比较看是否是同一对象。这就是出现问题的原因,因为 arr
是代理对象,在其 get
拦截函数中其实会有一步处理,就是如果当前 key
对应的值是对象形式,则会递归调用 reactive
函数对该值进行处理,返回代理后的对象,代码如下:
js
if (typeof res === 'object' && res !== null) {
// 如果值可以被代理,则返回代理对象
return reactive(res)
}
因此,当我们在 includes
函数内部通过索引获取 arr
对象时,取到的 arr[0]
是对象形式,因此会自动通过 reactive
函数进行处理,返回的是一个新的代理对象; 而我们在 includes
方法中传的参数 arr[0]
,同样会进入 get
拦截函数,通过 reactive
函数进行处理后返回另一个代理对象。换言之,我们每一次调用 reactive
函数都会返回一个新的代理对象。在 includes
方法内部,会对这两个不同的代理对象进行比较,显然他们不会相等,尽管他们所代理的都是 arr[0]
。因此,includes
方法遍历完整个数组也找不到和传入的参数对象相同的值,最终返回 false
。
在明确了问题产生的原因后,解决方案也容易想到,我们在每一次调用 reactive
创建代理对象的时候需要先判断当前传入的原始对象是不是已经进行过代理,如果是的话就直接返回代理对象,否则创建新的代理对象并通过 map
进行存储。代码如下:
js
// 定义一个 Map 实例,存储原始对象到代理对象的映射
const reactiveMap = new Map()
function reactive(obj) {
// 优先通过原始对象 obj 寻找之前创建的代理对象,如果找到了,直接返回已有的代理对象
const existionProxy = reactiveMap.get(obj)
if (existionProxy) return existionProxy
// 否则,创建新的代理对象
const proxy = createReactive(obj)
// 存储到 Map 中,从而避免重复创建
reactiveMap.set(obj, proxy)
return proxy
}
这样,当我们对同一对象多次调用 reactive
函数创建代理对象时,就会返回同一个代理对象,在 includes
方法内部进行比较,结果就会返回 true
。这样我们前文所描述的这个问题就得到了解决。
但是还不能高兴得太早,执行下列代码:
js
const obj = {}
const arr = reactive([obj])
console.log(arr.includes(obj)) // false
我们对上面的例子稍作了一些改动,在 includes
方法中传递的参数从 arr[0]
变成了 obj
,此时我们期望结果仍然返回 true
,但实际返回的却是 false
。其中的原因也不难猜测,因为我们传递的参数时原始对象 obj
,而在 includes
方法内部进行比较的对象是 obj
的代理对象,两个对象自然不可能相等。
2. 数组 includes 方法重写
此时,从其他方面我们无法入手对问题进行解决,我们需要对 includes
方法进行重写。首先我们定义一个数组重写方法对象 arrayInstrumentations
,用来存储所有我们进行重写的数组方法,这种形式方便我们后续对其他数组方法进行重写。
arr.includes
可以理解为读取代理对象 arr
的 includes
属性,在我们调用 includes
方法时会触发 get
拦截函数,因此我们可以在该函数内检查 target
是否是数组,如果是数组并且读取的键值存在于 arrayInstrumentations
上,则返回定义在arrayInstrumentations
对象上相应的值。也就是说,当执行 arr.includes
时,实际执行的是定义在 arrayInstrumentations
上的 includes
函数而非原有的 includes
函数,这样就实现了重写。代码结构大体如下:
js
const arrayInstrumentations = {
includes: function() {/* ... */}
}
function createReactive(obj) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
// 如果操作的目标对象是数组,并且 key 存在于arrayInstrumentations 上,
// 那么返回定义在 arrayInstrumentations 上的值
if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
/* ... */
},
})
}
接下来的重点就是我们该如何对 includes
函数进行改造,先前所存在的问题主要是当我们传递参数是原始对象时,方法内部会将代理对象和原始对象进行对比,原因是我们是通过代理后的数组调用 includes
方法,方法内部的 this
指向的是代理数组。而我们可以通过原始数组调用 includes
方法,必然能获取到正确的结果,那么问题就是我们该如何在代理数组的 includes
方法中获取到原生数组呢? 其实我们可以对 get
拦截函数进行一定修改,当我们读取的 key
值是 'raw'
时,就意味着我们想要获取到原始对象,因此我们在 get
拦截函数中新增一个判断,如果读取的属性是 'raw'
,就返回原始对象 target
,代码如下:
js
const arrayInstrumentations = {
includes: function() {/* ... */}
}
function createReactive(obj) {
return new Proxy(obj, {
// 拦截读取操作
get(target, key, receiver) {
if(key === 'raw') {
return target
}
// 如果操作的目标对象是数组,并且 key 存在于arrayInstrumentations 上,
// 那么返回定义在 arrayInstrumentations 上的值
if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
/* ... */
},
})
}
如此一来,当我们通过 arr.raw
获取到的就是原始的数组对象。
我们已经可以获取到原始数组对象,可以完成对 includes
方法的重写:
js
const originMethod = Array.prototype.includes
const arrayInstrumentations = {
includes: function(...args) {
// this 是代理对象,先在代理对象中查找,将结果存储到 res 中
let res = originMethod.apply(this, args)
if (res === false) {
// res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找并更新 res 值
res = originMethod.apply(this.raw, args)
}
// 返回最终结果
return res
}
}
我们先前的例子中,通过代理后的数组 arr
调用 includes
方法,因此 includes
方法内的 this
指向的是代理数组。重写的 includes
方法依然先在代理数组中进行查找,如果找不到再通过 this.raw
拿到原始数组进行查找,最后返回查找结果。此时再运行先前的例子,已经可以得到正确的结果。
3. 其它数组方法
其实上述针对数组 includes
方法所存在的问题和相应的优化手段,对于其他部分数组方法也同样适用,例如 indexOf
、lastIndexOf
,因为这三种数组方法内部实现的过程是大致相同的。因此,我们只需对原有的 arrayInstrumentations
继续进行扩展来适配这两种方法:
js
const arrayInstrumentations = {};
['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
const originMethod = Array.prototype[method]
arrayInstrumentations[method] = function(...args) {
// this 是代理对象,先在代理对象中查找,将结果存储到 res 中
let res = originMethod.apply(this, args)
if (res === false || res === -1) {
// res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找,并更新 res 值
res = originMethod.apply(this.raw, args)
}
// 返回最终结果
return res
}
})