在之前的文章中,我们的响应式对象都是普通的对象形式,这本文中将要介绍对数组的代理。在 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]
时,隐式地对 arr
的 length
属性进行了修改,所以我们期望与 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()
}
})
}