L32 · 依赖追踪:effect / track / trigger
🎯 本节目标:深入 ReactiveEffect 类,理解 cleanup、嵌套 effect、调度器
📦 本节产出:升级版响应式系统 + 理解 Vue 3 真实依赖追踪 + computed/ref 原理
🔗 前置钩子:L31 的 mini reactive + effect
🔗 后续钩子:L33 将用响应式系统驱动 Virtual DOM 更新1. L31 遗留问题:条件分支
const state = reactive({ showName: true, name: 'Vue', count: 0 })
effect(() => {
if (state.showName) {
console.log('name:', state.name)
} else {
console.log('count:', state.count)
}
})第一次执行时 showName = true,收集了 showName 和 name 的依赖。
当 state.showName = false 后,name 的变化不应该再触发重新执行——但 L31 的 mini 版做不到,因为旧依赖没有被清理。
2. ReactiveEffect 类
继续使用 L31 的 labs/mini-reactivity。先用下面的完整内容替换 mini-reactivity.ts,再按第 5、6 节向末尾追加 computed 和 ref。它仍是普通对象教学模型;Vue 3.5.43 用 Dep / Subscriber / Link 和版本标记清理依赖,不是每轮删除整个 Set 的这个实现。
// labs/mini-reactivity/mini-reactivity.ts
export interface EffectRunner<T = unknown> { (): T; effect: ReactiveEffect<T> }
type Dep = Set<ReactiveEffect<unknown>>
const targetMap = new WeakMap<object, Map<PropertyKey, Dep>>()
let activeEffect: ReactiveEffect<unknown> | undefined
const reactiveMap = new WeakMap<object, object>()
const proxies = new WeakSet<object>()
function isPlainObject(value: unknown): value is Record<PropertyKey, unknown> {
if (value === null || typeof value !== 'object') return false
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}
export class ReactiveEffect<T = unknown> {
deps: Dep[] = []
active = true
running = false
computed = false // mini 用来优先传递 computed 的失效信号
constructor(private fn: () => T, public scheduler?: () => void) {}
run(): T {
if (!this.active) return this.fn() // stop 后仍允许手动执行,不重新订阅自己
if (this.running) throw new Error('不允许递归执行同一个 effect')
this.cleanup()
const previous = activeEffect
activeEffect = this
this.running = true
try { return this.fn() }
finally { activeEffect = previous; this.running = false }
}
cleanup() {
for (const dep of this.deps) dep.delete(this)
this.deps.length = 0
}
stop() {
if (!this.active) return
this.active = false
this.cleanup()
}
}
function track(target: object, key: PropertyKey) {
if (!activeEffect?.active) return
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, depsMap = new Map())
let dep = depsMap.get(key)
if (!dep) depsMap.set(key, dep = new Set())
if (!dep.has(activeEffect)) {
dep.add(activeEffect)
activeEffect.deps.push(dep) // 同一 dep 不重复登记
}
}
const pendingEffects = new Set<ReactiveEffect<unknown>>()
let triggerDepth = 0
let flushing = false
function trigger(target: object, key: PropertyKey) {
const dep = targetMap.get(target)?.get(key)
if (!dep) return
triggerDepth++
try {
for (const effect of new Set(dep)) {
if (!effect.active || effect.running) continue
if (effect.computed) effect.scheduler?.() // 先沿 computed 链标记失效
else pendingEffects.add(effect)
}
} finally {
triggerDepth--
if (triggerDepth === 0 && !flushing) {
flushing = true
try {
while (pendingEffects.size) {
const effect = pendingEffects.values().next().value!
pendingEffects.delete(effect)
if (!effect.active || effect.running) continue
if (effect.scheduler) effect.scheduler()
else effect.run()
}
} finally { pendingEffects.clear(); flushing = false }
}
}
}
export function reactive<T extends object>(target: T): T {
if (proxies.has(target)) return target
const existing = reactiveMap.get(target)
if (existing) return existing as T
if (!isPlainObject(target)) throw new TypeError('mini 版只接受普通对象')
const proxy = new Proxy(target, {
get(target, key, receiver) {
track(target, key)
const value = Reflect.get(target, key, receiver)
return isPlainObject(value) ? reactive(value) : value
},
set(target, key, value, receiver) {
const previous = Reflect.get(target, key, receiver)
const changed = Reflect.set(target, key, value, receiver)
if (changed && !Object.is(previous, value)) trigger(target, key)
return changed
},
deleteProperty(target, key) {
const existed = Object.prototype.hasOwnProperty.call(target, key)
const deleted = Reflect.deleteProperty(target, key)
if (deleted && existed) trigger(target, key)
return deleted
},
})
reactiveMap.set(target, proxy)
proxies.add(proxy)
return proxy
}
export function effect<T>(fn: () => T, options: {
lazy?: boolean; scheduler?: (runner: EffectRunner<T>) => void
} = {}): EffectRunner<T> {
const instance = new ReactiveEffect(fn)
const runner = instance.run.bind(instance) as EffectRunner<T>
runner.effect = instance
if (options.scheduler) instance.scheduler = () => options.scheduler!(runner)
if (!options.lazy) runner()
return runner
}cleanup 先解绑旧关系,本次读取再登记;track 只登记一次反向连接,避免同一属性读多次就堆积重复 dep。trigger 迭代快照,防止“边遍历 Set、边删除并重新加入自己”导致循环;computed 标记和待执行集合把同一次触发链内的失效传递放在消费者重跑之前。try/finally 确保异常也能恢复外层 effect;业务副作用之间互相写入形成的循环仍不属于这个模型的处理范围。
cleanup 流程图
3. 嵌套 effect
嵌套执行后必须恢复外层收集者。保存 previous 并在 finally 恢复,已经能表达调用栈,不必再额外维护一个全局数组。真实 Vue 3.5.43 也不能简单概括成旧版的 effectStack。
// 独立演示片段:使用本课 mini 导出的 reactive/effect
const state = reactive({ a: 1, b: 2, c: 3 })
const inner = effect(() => console.log('inner:', state.b), { lazy: true })
const outer = effect(() => {
console.log('outer before:', state.a)
inner()
console.log('outer after:', state.c) // 恢复 outer 后,c 归属于 outer
})
state.c++ // 会再次运行 outerinner 只创建一次,outer 重跑时复用它。如果每次运行 outer 都创建新的 inner,会积累独立订阅;保存/恢复 activeEffect 并不会自动销毁这些新 effect。组件运行时还要管理实例与作用域生命周期,不能把这个嵌套片段当作完整组件挂载逻辑。
4. 调度器(Scheduler)
trigger 可以立即执行,也可以把执行权交给 scheduler。本课提供稳定的 runner 给调度器;下面先用手动刷新观察这个区别:
// 独立演示片段
import { reactive, effect, type EffectRunner } from './mini-reactivity'
const state = reactive({ count: 0 })
let scheduled: EffectRunner<void> | undefined
const runner = effect(() => { console.log(state.count) }, {
scheduler: job => { scheduled = job },
})
state.count = 1
state.count = 2 // 这里只替换同一个待执行 runner,没有再次打印
scheduled?.() // 手动刷新,打印 2
runner.effect.stop()组件更新调度器会把任务排队并去重,L35 再实现微任务刷新。mini computed 会用 scheduler 标记缓存失效;watch 还需要封装新旧值、回调和清理等逻辑,不能仅用一个队列调用冒充完整 watch。真实 watch 默认是 pre,flush: 'post' 才选择所属组件更新后的时机。
5. computed 的实现原理
下面用带 scheduler 的惰性 effect 实现 computed 的缓存思路,追加到 mini-reactivity.ts。这不是 Vue 3.5.43 的类结构:该版本 ComputedRefImpl 自身是 Subscriber,使用 flags/globalVersion/dep 版本检查,不再创建独立 ReactiveEffect。
export function computed<T>(getter: () => T) {
let value: T
let dirty = true // 是否需要重新计算
const effect = new ReactiveEffect(getter, () => {
// scheduler:依赖变化时不立即执行 getter
// 而是标记为 dirty,等到下次读取时才重新计算
if (!dirty) {
dirty = true
trigger(obj, 'value') // 通知依赖了此 computed 的 effect
}
})
effect.computed = true
const obj = {
get value() {
track(obj, 'value') // 在进入内部 effect 前,登记外部消费者
if (dirty) {
value = effect.run() // 重新计算
dirty = false
}
return value
}
}
return obj
}computed 的两个关键特性:
- 惰性(lazy):依赖变化只标记 dirty,不立即重算。只有被读取时才计算。
- 缓存(cached):依赖未使缓存失效时,多次读取复用上一次结果。依赖变了但最终结果相同,mini 仍可能重新计算并通知消费者,不能等同于真实 Vue 的 computed 稳定性优化。
// 验证缓存
const count = ref(1)
const double = computed(() => {
console.log('computed 执行了!') // 只在 count 变化且被读取时输出
return count.value * 2
})
double.value // 输出 "computed 执行了!" → 2
double.value // 不输出(缓存命中)→ 2
count.value = 2
// 这个例子没有其他消费者,所以此时只标记 dirty;
// 若一个同步 effect 订阅了 double,它可能被通知并在内部立即读取、求值。
double.value // 输出 "computed 执行了!" → 4本模型先同步传递 computed 的失效信号,再执行去重后的普通 effect,避免同一次变更中读取尚未失效的缓存;这不是组件更新的微任务队列,也没有实现 Vue 的版本检查与完整错误隔离。getter 应保持无副作用。3.5.43 computed.ts、refreshComputed
6. ref 的实现原理
这里也复用 mini 的 track/trigger,避免引用未定义的 trackRefValue、hasChanged 等函数。追加到同一文件:
export class RefImpl<T> {
private _value: T
private _rawValue: T
constructor(value: T) {
this._rawValue = value
this._value = this.convert(value)
}
private convert(value: T): T {
return isPlainObject(value) ? reactive(value) as T : value
}
get value(): T {
track(this, 'value')
return this._value
}
set value(next: T) {
// 把已返回的代理赋回自身也不算改变。
if (Object.is(next, this._rawValue) || Object.is(next, this._value)) return
this._rawValue = next
this._value = this.convert(next)
trigger(this, 'value')
}
}
export function ref<T>(value: T) { return new RefImpl(value) }mini ref 只深转换普通对象,不实现数组/集合与嵌套 ref 自动解包等规则。真实 RefImpl 的 .value 同样是访问器,对象值再交给响应式转换;它不是用 Proxy 直接代理数字或字符串。3.5.43 ref.ts
ref 和 reactive 的关系:
ref(42)→ RefImpl,.value直接返回 42ref({ name: 'Vue' })→ RefImpl,.value返回reactive({ name: 'Vue' })reactive({ name: 'Vue' })→ Proxy
7. effect 的 stop 和 scope
先验证自己的 mini。这个文件与 L31 basic.ts 分开,避免示例里的同名变量互相覆盖:
// labs/mini-reactivity/advanced.ts
import assert from 'node:assert/strict'
import { reactive, effect, ref, computed } from './mini-reactivity'
const state = reactive({ show: true, name: 'Vue', count: 0 })
let runs = 0
const runner = effect(() => { runs++; return state.show ? state.name : state.count })
state.show = false
state.name = '不再依赖'
assert.equal(runs, 2)
state.count++
assert.equal(runs, 3)
runner.effect.stop()
state.count++
assert.equal(runs, 3)
runner() // 停止自动追踪后,仍可显式手动调用
assert.equal(runs, 4)
state.count++
assert.equal(runs, 4)
const count = ref(1)
let computedRuns = 0
const double = computed(() => { computedRuns++; return count.value * 2 })
assert.equal(double.value, 2)
assert.equal(double.value, 2)
assert.equal(computedRuns, 1)
count.value = 2
assert.equal(computedRuns, 1)
assert.equal(double.value, 4)
assert.equal(computedRuns, 2)
let mixedRuns = 0
let mixed = ''
effect(() => { mixedRuns++; mixed = `${count.value}/${double.value}` })
count.value = 3
assert.equal(mixed, '3/6')
assert.equal(mixedRuns, 2)
const object = ref({ value: 1 })
let seen = 0
effect(() => { seen = object.value.value })
object.value.value = 2
assert.equal(seen, 2)运行 npx tsx basic.ts、npx tsx advanced.ts,再用 npx tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext mini-reactivity.ts basic.ts advanced.ts 检查类型。
scope 使用真实 Vue API 单独演示,mini 本课不实现它:
// labs/mini-reactivity/scope-demo.ts
import { effectScope, ref, watch, onScopeDispose } from 'vue'
const count = ref(0)
const scope = effectScope()
scope.run(() => {
watch(count, value => console.log(value), { flush: 'sync' })
const timer = setInterval(() => { count.value++ }, 1000)
onScopeDispose(() => clearInterval(timer))
})
count.value++ // 打印 1
scope.stop() // 停止 watcher,并调用登记的 timer 清理
count.value++ // 不再打印组件卸载会停止所属作用域内的响应式副作用,并运行已登记的清理。定时器、网络连接不会仅因为创建在 setup 就自动消失;异步回调中新建的 watcher 也不自动归属原组件。3.5 的 computed 不宜画成 scope.effects 中被逐个 stop 的 ReactiveEffect;最后的消费者解绑后,它可向上游取消订阅,但不代表 computed 对象永久不能再求值。effectScope、watcher 停止规则
8. 本节总结
检查清单
- [ ] 理解 cleanup 机制解决条件分支依赖清理
- [ ] 理解双向引用(effect.deps 和 dep Set 互相引用)
- [ ] 理解保存/恢复活动上下文,以及避免重复创建内层 effect
- [ ] 理解 scheduler 如何把触发与实际执行分开,以及教学模型与当前源码的区别
- [ ] 能解释 computed 为什么是"惰性"且"缓存"的
- [ ] 能解释 ref 内部如何利用 getter/setter + reactive
- [ ] 理解 effectScope 和自动清理
Git 提交
git add .
git commit -m "L32: 依赖追踪深入 - cleanup/嵌套/scheduler/computed/ref 原理"🔬 深度专题
📖 D06 · 依赖追踪:effect / track / trigger — computed 的缓存到底怎么实现的?
🔗 → 下一节
L33 将讨论当前稳定版 Vue 的 VDOM 渲染路径:响应式变更如何触发组件渲染,再把新旧 VNode 的差异应用到 DOM。