非原始值响应式方案代理数组(一)

数组的索引与 length

拿下面这个例子来说,当通过数组索引访问元素的值时,已经能够建立响应联系了:

1
2
3
4
5
6
const arr = reactive(['foo'])

effect(() => {
console.log(arr[0]) // 'foo'
})
arr[0] = 'bar' // 能够触发响应

但是通过索引设置数组的元素值与设置对象的属性值仍然存在根本上的不同,这是因为数组对象部署的内部方法[[DefineOwnProperty]]不同于常规对象。实际上,当我们通过索引设置数组元素的值时,会执行数组对象所部署的内部方法[[Set]],这一步与设置常规对象的属性值一样。根据规范可知,内部方法[[Set]]其实依赖于[[DefineOwnProperty]],到了这里就体现出了差异。数组对象所部署的内部方法[[DefineOwnProperty]]的逻辑定义在规范的 10.4.2.1 节。

根据规范可知,如果设置的索引值大于数组当前的长度,那么要更新数组的length属性。所以当通过索引设置元素值时,可能会隐式地修改 length 的属性值。因此在触发响应时,也应该触发与 length 属性相关联的副作用函数重新执行,如下面代码所示:

1
2
3
4
5
6
const arr = reactive(['foo'])

effect(() => {
console.log(arr.length) // 'foo'
})
arr[1] = 'bar' // 设置索引1的值,会使得数组的长度变为2

在这段代码中,数组的原长度为 1,并且在副作用函数中访问了 length 属性。然后设置索引为 1 的元素值,这会导致数组的长度变为 2.因此应该触发副作用函数重新执行。但目前的实现还做不到这一点,为了实现目标,我们需要修改 set 拦截函数,如下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
set(target, key, newVal, receiver) {
if (isReadonly) {
console.warn(`属性 ${key}是只读的`)
return true
}
// 先获取旧值
const oldVal = target[key]
// type看看操作类型是添加新属性还是设置已有属性
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)
// 如果两者相等,说明receiver是target的代理对象
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
trigger(target, key, type)
}
}
return res
},
})
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function trigger(target, key, type) {
const depsMap = bucket.get(target)
if (!depsMap) return
if (type === 'ADD' && Array.isArray(target)) {
const lengthEffects = depsMap.get('length')
lengthEffects &&
lengthEffects.forEach((effectFn) => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}
effectsToRun.forEach((effectFn) => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}

但是反过来思考,其实修改数组的 length 属性也会隐式地影响数组元素,例如:

1
2
3
4
5
6
7
8
const arr = reactive(['foo'])

effect(() => {
// 访问数组的第0个元素
console.log(arr[0]) // foo
})
// 将数组的长度修改为0,导致第0个元素被删除,因此应该被触发响应
arr.length = 0

如上面代码所示,在副作用函数中访问了数组的第 0 个元素,接着将数组的 length 属性设置为 0.我们知道这会隐式地影响数组元素,即所有元素都会被删除,所以应该触发副作用函数重新执行。然而并非所有对 length 属性的修改都会影响数组中的已有元素,拿上面的例子来说,如果我们将 length 属性设置为 100,这并不会影响第 0 个元素,所以也就不需要触发副作用函数重新执行。这让我们意识到,当修改 length 属性值时,只有那些索引值大于或者等于新的 length 属性值的元素才需要触发响应。但无论如何,目前的实现还做不到这一点,为了实现目标,我们需要修改 set 拦截函数。在调用 trigger 函数触发响应时,应该把新的属性值传递过去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
set(target, key, newVal, receiver) {
if (isReadonly) {
console.warn(`属性 ${key}是只读的`)
return true
}
// 先获取旧值
const oldVal = target[key]
// type看看操作类型是添加新属性还是设置已有属性
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)
// 如果两者相等,说明receiver是target的代理对象
if (target === receiver.raw) {
if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
// 增加第四个参数,即触发响应的值
trigger(target, key, type, newVal)
}
}
return res
},
})
}

接着,我们还需要修改trigger函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
function trigger(target, key, type, newVal) {
const depsMap = bucket.get(target)
if (!depsMap) return
if (type === 'ADD' && Array.isArray(target)) {
const lengthEffects = depsMap.get('length')
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()
}
})
}

如上面的代码所示,为trigger函数增加了第四个参数,即触发响应时的新值。在本例中,新值指的是新的length属性值,它代表新的数组长度。接着,我们判断操作的目标是否是数组,如果是,则需要找到所有索引值大于或等于新的length值的元素,然后把它们相关联的副作用函数取出并执行。