Vue响应式原理(10)-数组的索引和length

在之前的文章中,我们的响应式对象都是普通的对象形式,这本文中将要介绍对数组的代理。在 JavaScript 中数组其实就是一种特殊的对象,因此我们需要了解数组与普通对象到底存在什么区别。

其实主要区别就在于数组对象和普通对象的操作存在不同,对数组的读取 操作包括以下方式: 通过索引访问数组元素值:arr[0]。 访问数组的长度:arr.length。 把数组作为对象,使用 for...in 循环遍历。 使用 for...of 迭代遍历数组。 数组的原型方法,如 every/some/find/findIndex/includes 等,以及其他所有不改变原数组的原型方法。

对数组的设置操作包括以下方式: 通过索引修改数组元素值:arr[1] = 3。 修改数组长度:arr.length = 0。 数组的栈方法:push/pop/shift/unshift。 修改原数组的原型方法:splice/fill/sort 等。

除了通过数组索引修改数组元素值这种基本操作之外,数组本身还有很多会修改原数组的原型方法。调用这些方法也属于对数组的操作,有些方法的操作语义是"读取",而有些方法的操作语义是"设置"。因此,当这些操作发生时,也应该正确地建立响应联系或触发响应。

当我们对数组进行代理,并通过数组的索引访问元素值时,其实已经能够完成依赖关系的建立:

js 复制代码
const arr = new Proxy(['foo'], {
    get() { /*...*/},
    set() { /*...*/}
})

effect(() => {
    console.log(arr[0]) // 'foo'
})

arr[0] = 'bar' // 能够触发响应

1. 设置数组值,隐式修改数组长度

从数组的索引方式读取来看,依赖收集过程与普通对象并没有明显区别,但是在设置操作中则有一定的区别,如下代码:

js 复制代码
const arr = reactive(['foo']) // 数组的原长度为 1
effect(() => {
    console.log(arr.length) // 1
})
// 设置索引 1 的值,会导致数组的长度变为 2
arr[1] = 'bar'

在我们设置 arr[1] 值之前数组的长度为 1,而设置之后数组的长度变为 2。也就是说当我们设置 arr[1] 时,隐式地对 arrlength 属性进行了修改,所以我们期望与 length 属性建立依赖关系的副作用函数都能得到执行。

在上面这段代码中,我们在副作用函数中读取了数组的 length 属性,已经完成了依赖收集操作,因此根据预期,在我们设置数组值时,数组 length 属性被修改,应该触发该副作用函数重新执行。但是目前实现还做不到这一点。

因此,我们需要对 set 拦截函数进行改进:

js 复制代码
function createReactive(obj, isShallow = false, isReadonly = false) {
    return new Proxy(obj, {
        // 拦截设置操作
        set(target, key, newVal, receiver) {
            const oldVal = target[key]
            const type = Array.isArray(target)
                // 如果代理目标是数组,则检测被设置的索引值是否小于数组长度,
                // 如果是,则视作 SET 操作,否则是 ADD 操作
                ? Number(key) < target.length ? 'SET' : 'ADD'
                // 如果属性不存在,则说明是在添加新的属性,否则是设置已有属性
                : Object.prototype.hasOwnProperty.call(target, key) ?
                'SET' : 'ADD'

            const res = Reflect.set(target, key, newVal, receiver)
            if (target === receiver.raw) {
                if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) 
                {
                    trigger(target, key, type)
                }
            }
            return res
        }
        // 省略其他拦截函数
    })
}

我们在判断操作类型时,新增了对数组类型的判断。如果代理的目标对象是数组,那么对于操作类型的判断会有所区别。即被设置的索引值如果小于数组长度,就视作 SET 操作,因为它不会改变数组长度;如果设置的索引值大于数组的当前长度,则视作 ADD 操作,因为这会隐式地改变数组的 length 属性值。有了这些信息,我们就可以在 trigger 函数中正确地触发与数组对象的 length 属性相关联的副作用函数重新执行了:

js 复制代码
function trigger(target, key, type) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    // 省略部分内容
    // 当操作类型为 ADD 并且目标对象是数组时,应该取出并执行那些与 length属性相关联的副作用函数
    if (type === 'ADD' && Array.isArray(target)) {
        // 取出与 length 相关联的副作用函数
        const lengthEffects = depsMap.get('length')
        // 将这些副作用函数添加到 effectsToRun 中,待执行
        lengthEffects && lengthEffects.forEach(effectFn => {
            if (effectFn !== activeEffect) {
                effectsToRun.add(effectFn)
            }
        })
    }
    
    effectsToRun.forEach(effectFn => {
        if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    })
}

2. 设置数组长度,隐式修改数组值

修改数组的 length 属性也会隐式地影响数组元素,如下代码:

js 复制代码
const arr = reactive(['foo'])
effect(() => {
    console.log(arr[0]) // foo
})

// 将数组的长度修改为 0,导致第 0 个元素被删除,因此应该触发响应
arr.length = 0

在上面的代码中,我们在副作用函数内访问了数组下标为 0 的元素完成数组的依赖收集操作。接着将数组的 length 属性修改为 0,很显然这会隐式地影响数组元素,导致所有元素被删除。按照预期,此时副作用函数应该重新执行。但目前的代码实现还无法完成这一点。

那么我们是不是应该在所有对 length 操作的时候都同时触发数组属性修改对应的副作用函数呢?显然不是。如上例中如果我们将 length 属性设置为 100,这并不会影响第 0 个元素,所以也就不需要触发 索引 0 所对应的副作用函数重新执行。

所以我们可以知道,当修改 length 属性值时,只有当设置索引值大于或等于新的 length 属性值的元素才需要触发响应其对应的副作用函数。为了实现目标,我们需要修改 set 拦截函数。在调用 trigger 函数触发响应时,应该把新的属性值传递过去:

js 复制代码
function createReactive(obj, isShallow = false, isReadonly = false) {
    return new Proxy(obj, {
        // 拦截设置操作
        set(target, key, newVal, receiver) {
            const oldVal = target[key]
            const type = Array.isArray(target) 
                ? Number(key) < target.length ? 'SET' : 'ADD' 
                : Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'

            const res = Reflect.set(target, key, newVal, receiver)
            if (target === receiver.raw) {
                if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
                    // 增加第四个参数,即触发响应的新值
                    trigger(target, key, type, newVal)
                }
            }
            return res
        },
    })
}

相应的我们需要对 trigger 函数进行修改:

js 复制代码
function trigger(target, key, type, newVal) {
    const depsMap = bucket.get(target)
    if (!depsMap) return
    // 省略部分内容
    // 当操作类型为 ADD 并且目标对象是数组时,应该取出并执行那些与 length属性相关联的副作用函数
    if (type === 'ADD' && Array.isArray(target)) {
        // 取出与 length 相关联的副作用函数
        const lengthEffects = depsMap.get('length')
        // 将这些副作用函数添加到 effectsToRun 中,待执行
        lengthEffects && lengthEffects.forEach(effectFn => {
            if (effectFn !== activeEffect) {
                effectsToRun.add(effectFn)
            }
        })
    }
    
    // 如果操作目标是数组,并且修改了数组的 length 属性
    if (Array.isArray(target) && key === 'length') {
        // 对于索引大于或等于新的 length 值的元素,
        // 需要把所有相关联的副作用函数取出并添加到 effectsToRun 中待执行
        depsMap.forEach((effects, key) => {
            if (key >= newVal) {
                effects.forEach(effectFn => {
                    if (effectFn !== activeEffect) {
                        effectsToRun.add(effectFn)
                    }
                })
            }
        })
    }
    
    effectsToRun.forEach(effectFn => {
        if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    })
}
相关推荐
恋猫de小郭14 分钟前
Flutter 小技巧之:实现 iOS 26 的 “液态玻璃”
android·前端·flutter
糖墨夕18 分钟前
Trae还能将Figma 设计稿转化为前端代码
前端·trae
程序猿小D19 分钟前
第26节 Node.js 事件
服务器·前端·javascript·node.js·编辑器·ecmascript·vim
天天打码20 分钟前
Bootstrap Table开源的企业级数据表格集成
前端·开源·bootstrap
Allen Bright22 分钟前
【CSS-8】深入理解CSS选择器权重:掌握样式优先级的关键
前端·css
hnlucky25 分钟前
安装vue的教程——Windows Node.js Vue项目搭建
前端·javascript·vue.js·windows·node.js
余道各努力,千里自同风38 分钟前
CSS“多列布局”
前端·css·html
Keya44 分钟前
使用 tinypng 脚本打包为exe 进行压缩图片
前端·python·程序员
wordbaby1 小时前
React Router 的 handle 和 useMatches 的作用、场景和联系
前端·react.js
我的div丢了肿么办1 小时前
ResizeObserver和IntersectionObserver的详细讲解
前端·javascript·vue.js