翻 Vue 源码看 computed:dirty 标记怎么在副作用嵌套时保持缓存不崩
Vue 的 computed 在副作用嵌套时不会提前求值,缓存也不崩,靠的不是什么运行时黑魔法,而是一个简单的 dirty 标记配合两层 effect 嵌套实现的依赖精确追踪。这个机制在 Vue 3.4.0 的响应式系统中表现得尤其清晰——我直接翻的当前最新源码,下面把链路拆开讲。
先说一个反直觉的点:computed 的惰性求值根本不是靠“延迟执行”实现的,而是靠“不主动执行”。你定义一个 computed,只要没人读它的 .value,它内部的 getter 函数连一次都不会跑。这个行为在 packages/reactivity/src/computed.ts 的 ComputedRefImpl 构造函数里就能看到:
// computed.ts,约第 45 行
constructor(getter) {
this.effect = new ReactiveEffect(getter, () => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this)
}
})
}
ReactiveEffect 的第二个参数是 scheduler。注意这里只是创建了 effect 对象,没有调用 effect.run()。真正的首次求值发生在 get value() 被访问时:
get value() {
const self = toRaw(this)
if (self._dirty) {
self._dirty = false
self._value = self.effect.run()
}
return self._value
}
_dirty 初始值是 true,所以第一次读 .value 会触发 effect.run() 执行 getter,然后把 _dirty 置为 false。之后只要依赖不变,再读 .value 就直接返回缓存的 _value,getter 不会重新执行。这就是惰性求值的全部机制——说白了就是“不读不算,读一次算一次,没变化就复用”。
真正有意思的是依赖变化时 _dirty 怎么被重新置为 true。这个逻辑藏在 scheduler 里。当 computed 内部依赖的某个响应式数据发生变化时,ReactiveEffect 的 notify 流程会被触发,但不会直接执行 getter,而是调用我们传入的 scheduler:
() => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this)
}
}
scheduler 只做两件事:把 _dirty 重新标记为 true,然后触发依赖于这个 computed 的外部 effect。注意这里 _dirty 变成 true 时 getter 还没执行——只是标记“缓存脏了”。下一次谁读 .value,谁就会触发重新计算。这就是缓存失效的全部机制。
到这里都还比较直观。但问题来了:如果一个 computed 在另一个 computed 的 getter 里被读取,或者在 watchEffect 里被读取,依赖链会变成多层嵌套。这时候依赖追踪不能乱,_dirty 的状态也不能错。我们看一个具体场景:
const a = ref(1)
const b = computed(() => a.value * 2)
const c = computed(() => b.value + 1)
watchEffect(() => {
console.log(c.value)
})
依赖链路是:a → b → c → watchEffect。当 a.value 变成 2 时,发生了什么?
第一步,a 的依赖集合里有 b 的内部 effect。a 变化时,遍历 dep 通知 b.effect,但 b.effect 有 scheduler,所以不执行 getter,而是调用 scheduler:把 b._dirty 置为 true,然后 triggerRefValue(b)。
第二步,triggerRefValue(b) 会通知所有依赖 b 的 effect。在这个例子里,c 的内部 effect 依赖 b,所以 c.effect 被通知。c.effect 也有 scheduler,同样不执行 getter,而是把 c._dirty 置为 true,然后 triggerRefValue(c)。
第三步,triggerRefValue(c) 通知依赖 c 的 watchEffect。watchEffect 没有 scheduler(它是普通 effect),所以直接调度执行其回调函数。
第四步,watchEffect 回调执行,读取 c.value。此时 c._dirty 是 true,所以 c 的 getter 重新执行。在 c 的 getter 里读取 b.value,此时 b._dirty 也是 true,所以 b 的 getter 也重新执行。b 的 getter 读取 a.value,拿到新值 2,计算得到 4,b._dirty 置为 false。c 的 getter 拿到 4,计算得到 5,c._dirty 置为 false。最终 watchEffect 打印 5。
关键点在于:整个链路中,b 和 c 的 getter 都是在 watchEffect 回调里被“拉取”执行的,而不是在 a 变化时被“推送”执行的。这就是 _dirty 标记配合 scheduler 的核心价值——依赖变化只传递“脏”信号,不触发计算;只有最终的数据消费者才会反向拉动整条链路重新求值。
再看一个更微妙的场景:多个 computed 共享同一个依赖,并且在嵌套的 watchEffect 中被读取:
const x = ref(0)
const y = computed(() => x.value + 10)
const z = computed(() => x.value + 20)
watchEffect(() => {
// 第一轮:x = 0,y = 10,z = 20
if (x.value < 5) {
console.log(y.value) // 只读 y
} else {
console.log(z.value) // 只读 z
}
})
初始时 x.value = 0,watchEffect 执行,进入 if 分支,读取 y.value。此时 y 的 getter 执行,内部读取 x.value,所以 watchEffect 的依赖集合是 [y, x]。注意这里 z 根本没有被读取,所以 z 的 getter 一次都没跑过,z._dirty 仍然是初始的 true。
当我们执行 x.value = 3 时,x 变化通知 watchEffect 重新执行。此时 x.value 还是 3,小于 5,所以仍然进入 if 分支,读取 y.value。y._dirty 在 x 变化时已经被 scheduler 置为 true,所以 y 重新计算,得到 13。z 依然没有被读取,它的 _dirty 依然是 true,但没人关心,因为没人在读它。
当我们执行 x.value = 6 时,x 变化通知 watchEffect 重新执行。这次 x.value = 6,进入 else 分支,读取 z.value。z._dirty 是 true,所以 z 的 getter 首次执行(或者说是这一轮的首次执行),读取 x.value 得到 6,计算得到 26。同时,y 在这一轮没有被读取,它的 _dirty 被之前的 scheduler 置为 true 后一直保持 true,但同样没人读它。
这里暴露了一个容易忽略的细节:_dirty 是“全局”的脏标记,而不是针对某一次读取的。只要依赖变化过一次,_dirty 就一直是 true,直到下一次实际读取 .value 时才被置为 false。这意味着如果 y 连续多次没有被读取,它的 _dirty 会一直保持 true,但这不造成任何问题——无非是下次读到它时多执行一次 getter 而已。
真正会导致缓存“崩”的情况是:某个 computed 的依赖变化了,但 _dirty 没有被正确置为 true。这在 Vue 3.4 之前的一个边缘情况里确实出现过,跟 effect 嵌套时的依赖收集顺序有关。Vue 3.4 对响应式系统做了一次重构(具体在 PR #5916 和后续几个 PR 中完成),核心改动之一就是把依赖追踪的粒度从“effect 级别”细化到了“依赖键级别”,同时确保嵌套 effect 在恢复时能正确重新收集依赖。这个改动直接影响了 computed 在嵌套场景下的正确性。
具体来说,3.4 之前的版本里,如果一个 computed 的 getter 内部有条件分支,而外部 watchEffect 在不同轮次读取了不同的 computed,可能会导致某个 computed 的依赖在不需要时被清理掉,但在需要时又没有重新收集回来。3.4 引入了 ReactiveEffect 上的 _dirtyLevel 和全局的 effectTrackDepth 来精确控制依赖收集的时机,确保每次 effect 重新执行前,依赖关系都是干净的。这部分逻辑在 packages/reactivity/src/effect.ts 的 run 方法里:
run() {
if (this._dirtyLevel === DirtyLevels.Dirty) {
// 清理依赖,准备重新收集
cleanupEffect(this)
}
// ... 执行 fn,重新收集依赖
}
结合 computed 的 scheduler,当 _dirty 被置为 true 时,对应的 _dirtyLevel 也会被标记为 Dirty,这样在下次 run 时就会触发依赖清理和重新收集。这套机制保证了即使嵌套层级很深、条件分支很复杂,依赖链也不会断。
总结一下核心链路:computed 的惰性求值靠的是构造函数里不主动 run,缓存失效靠的是 scheduler 里置 _dirty = true 然后通知外部,嵌套不崩靠的是 scheduler 只传信号不执行、让消费者反向拉取整条链路的计算。3.4 的依赖追踪重构则是在底层保证了这套机制在复杂分支和嵌套下的正确性。整个设计干净利落,没有多余的步骤,每一步都是必须的。
常见问题
computed 的 getter 里有条件分支,会不会导致依赖丢失?
不会,只要你在某一轮读取了 .value。每次 _dirty 被置为 true 后的下一次 .value 读取都会触发 effect.run(),而 run() 内部会重新执行 getter 并重新收集依赖。哪怕这个 computed 前几轮没有被读取,只要当前轮被读取了,它的依赖就会被完整地重新收集一遍。Vue 3.4 的 _dirtyLevel 机制保证了每次重新执行前旧依赖会被清理干净。
为什么 computed 不能直接返回 ref 而要用 .value?
因为 computed 返回的是 ComputedRefImpl 实例,它本身就是一个 ref 包装器。.value 的 getter 里才封装了惰性求值和缓存逻辑。如果去掉 .value 直接暴露内部值,就没法拦截读取行为了,惰性求值无从谈起。这也是为什么在 <script setup> 里 Vue 能自动解包 ref,但在 JS/TS 里必须手动 .value——模板编译阶段会自动插入 .value,但 JS 代码里没有编译器帮你做这件事。
多个 computed 链式依赖时,中间某个 computed 的 _dirty 一直是 true 会不会有问题?
不会。_dirty = true 只意味着“下次读我时我会重新计算”,不消耗任何资源。它不会触发额外的 getter 执行,也不会导致内存泄漏。唯一的影响是下一次读取时多跑一次 getter,但如果这个 computed 长时间不被读取,那它的 getter 本来就不会跑,_dirty 是 true 还是 false 没区别。
watchEffect 里只读 computed 的 .value,为什么 computed 内部依赖变化时 watchEffect 会重新执行?
因为读取 .value 时,computed 的内部 effect 会作为活跃 effect 被收集到其依赖的 dep 中。具体来说,当 watchEffect 的回调执行时,watchEffect 自身的 effect 是当前活跃的 activeEffect。读取 computed.value 会触发 getter,getter 里调用 this.effect.run(),在 run() 执行期间,computed 的 getter 函数执行,读取内部依赖(比如某个 ref),此时 track 函数收集的依赖是 watchEffect 的 effect,而不是 computed 的内部 effect。所以最终 ref 的 dep 里直接存的是 watchEffect 的 effect,ref 变化时直接通知 watchEffect 重新执行,中间跳过了 computed 这一层。这是 Vue 3 响应式系统的一个精妙设计——依赖追踪是“穿透” computed 的。