
在我们构建响应式系统的过程中,虽然对于原生 JavaScript 对象的处理已经相当完善,但数组 (Array) 与普通对象的属性不同,数组的 length
属性与其数值索引之间有紧密的联动关系。
手动变更数组长度
最直接改变数组长度的方式就是手动赋值。虽然这在日常开发中不被鼓励,但一个健壮的响应式系统必须能正确处理这种情况。
HTML
<body>
<div id="app"></div>
<script type="module">
// import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { reactive, effect } from '../dist/reactivity.esm.js'
const state = reactive(['a', 'b', 'c','d'])
effect(() => {
console.log(state.length)
})
setTimeout(() => {
state.length = 2
}, 1000)
</script>
</body>
在上述示例中,我们直接修改了数组长度,它会触发更新(通常我们会避免直接更改数组长度的做法)。
像这样直接缩短数组长度,超出新长度的元素会被删除,如下图:

HTML
<body>
<div id="app"></div>
<script type="module">
// import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { reactive, effect } from '../dist/reactivity.esm.js'
const state = reactive(['a', 'b', 'c','d'])
effect(() => {
console.log(state[3])
})
setTimeout(() => {
state.length = 2
}, 1000)
</script>
</body>
执行这段代码,控制台会先输出 d
,这符合预期。然而,一秒后当 state.length
被修改为 2 时,console.log
并没有再次执行。
然而,问题出在哪里?
我们的 effect
依赖的是 state[3]
。当 state.length
被修改为 2 时,索引为 3 的元素实际上已经被删除了。
因此,这个 effect
所依赖的键 ('3'
) 后续不会再发生任何 set
行为,导致它再也没有机会被重新触发。依赖关系因此丢失。
这个"删除"操作,仅触发了 state
对象 length
属性的 set
,并未触发 索引 '3'
的 set
。
所以我们需要做的是,当 length
被缩短时,我们要找出所有依赖"被删除索引"的 effect
,并通知它们重新执行:
TypeScript
// dep.ts
export function trigger(target, key) {
const depsMap = targetMap.get(target)
// 如果 depsMap 不存在,表示没有收集过依赖,直接返回
if (!depsMap) return
const targetIsArray = Array.isArray(target)
// 新增:如果目标是数组且修改的是 length 属性
if (targetIsArray && key === 'length') {
// 遍历该数组的所有依赖
depsMap.forEach((dep, depKey) => {
// 如果依赖的键是 'length' 或大于等于新 length 值的索引
// newValue 在 set 处理器中还拿不到,这里假设能拿到
// 实际上应该在 set 中拿到 newValue 再传给 trigger
if (depKey >= newValue || depKey === 'length') {
// 通知访问了这些索引的 effect 以及访问了 length 的 effect 重新执行
propagate(dep.subs)
}
})
} else {
// 如果不是数组,或者更新的不是 length,则直接获取依赖
const dep = depsMap.get(key)
// 如果依赖不存在,表示这个 key 没有在 effect 中被使用过,直接返回
if (!dep) return
// 找到依赖,触发更新
propagate(dep.subs)
}
}
(注:上面的代码片段为示意,newValue
需要从 set
处理器传入 trigger
)
当 state.length = 2
时,effect
会被重新触发。现在看我们的示例代码,会发现触发更新后,输出结果是 undefined
,因为 state[3]
已不存在。

数组方法导致长度变更
除了直接赋值外,一些我们常用的数组方法,如 pop
、push
、shift
等,也会隐式地影响到数组长度:
HTML
<body>
<div id="app"></div>
<script type="module">
// import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { reactive, effect } from '../dist/reactivity.esm.js'
const state = reactive(['a', 'b', 'c','d'])
effect(() => {
console.log(state.length)
})
setTimeout(() => {
state.push('e')
}, 1000)
</script>
</body>
这里可以看到,我们通过 push
增加了一个新元素,但是依赖 length
的 effect
并没有被触发更新:
那么,当
push
这类方法被调用时,我们如何侦测到 length
的隐式变更呢?
关键在于拦截 set
操作。push('e')
的底层操作,除了在索引 4
上设置新值,也会修改 length
属性。
我们可以在 set
代理中,比较操作前后的数组长度,如果不一致,就主动触发 length
属性的依赖更新:
TypeScript
javascript
// baseHandlers.ts
export const mutableHandlers = {
// ...
set(target, key, newValue, receiver) {
const oldValue = target[key]
const targetIsArray = Array.isArray(target)
// 如果 target 是数组,记录其旧长度
const oldLength = targetIsArray ? target.length : 0
const res = Reflect.set(target, key, newValue, receiver)
// ... (ref 相关的处理)
if (hasChanged(newValue, oldValue)) {
// 触发当前 key 的更新
trigger(target, key, newValue) // 把新值传进去
}
// 如果目标是数组,并且长度发生了变化
if (targetIsArray && target.length !== oldLength) {
// 并且被修改的 key 本身不是 'length' (避免重复触发)
if (key !== 'length') {
trigger(target, 'length', target.length) // 主动为 'length' 触发一次更新
}
}
return res
}
}
这段代码的逻辑是: 在 set
操作之后,我们检查目标是否为数组,并比较其新旧长度。
- 如果长度发生了变化
- 并且当前修改的
key
不是length
本身(为了避免在state.length = 2
这种情况下重复触发) - 我们就为
length
属性也主动触发一次更新。
今天我们聚焦于数组 length
属性的特殊性。通过分别在 trigger
函数和 set
代理中增加特殊的处理逻辑:
- 在
trigger
中 :处理了手动缩短length
时,对已删除索引的依赖触发。 - 在
set
中 :处理了数组方法隐式改变length
时,对length
属性的依赖触发。
想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。