Vue响应式原理(11)-数组includes方法重写

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 可以理解为读取代理对象 arrincludes 属性,在我们调用 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 方法所存在的问题和相应的优化手段,对于其他部分数组方法也同样适用,例如 indexOflastIndexOf,因为这三种数组方法内部实现的过程是大致相同的。因此,我们只需对原有的 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
    }
})
相关推荐
ekskef_sef36 分钟前
32岁前端干了8年,是继续做前端开发,还是转其它工作
前端
sunshine6411 小时前
【CSS】实现tag选中对钩样式
前端·css·css3
真滴book理喻1 小时前
Vue(四)
前端·javascript·vue.js
蜜獾云1 小时前
npm淘宝镜像
前端·npm·node.js
dz88i81 小时前
修改npm镜像源
前端·npm·node.js
Jiaberrr2 小时前
解锁 GitBook 的奥秘:从入门到精通之旅
前端·gitbook
顾平安3 小时前
Promise/A+ 规范 - 中文版本
前端
聚名网3 小时前
域名和服务器是什么?域名和服务器是什么关系?
服务器·前端
桃园码工3 小时前
4-Gin HTML 模板渲染 --[Gin 框架入门精讲与实战案例]
前端·html·gin·模板渲染
不是鱼3 小时前
构建React基础及理解与Vue的区别
前端·vue.js·react.js