Vue 3 响应式核心:effect、scheduler、cleanup 与双向依赖设计
前言
上一篇我把 Vue 3 响应式的主线——从 Proxy 到 track/trigger,从 targetMap 三层结构到四种 effect 的调度分叉——完整串了一遍。
但写完之后我发现,effect 内部有几个设计,单独拿出来都懂,合在一起就容易乱。比如:
key.dep和effect.deps到底什么关系?为什么互相指着?- scheduler 到底怎么决定执行策略?分叉点在哪里?
- cleanup 为什么必须做?不做会怎样?
这篇文章专讲这三个问题。它们不是三个独立的知识点,而是一套互相咬合的设计——scheduler 决定“什么时候执行”,cleanup 确保“只依赖该依赖的”,双向记录为 cleanup 提供“快速找到自己”的能力。
一、effect:不只是“要跑的函数”,它是一个完整的上下文对象
1.1 effect 的直觉理解
很多人(包括以前的我)会把 effect 简单理解成“数据变了要重新跑的函数”。这个理解不算错,但太粗糙。
更准确地说:effect 是一个“带依赖追踪和调度能力的副作用上下文”。
class ReactiveEffect {
fn: Function // 要执行的函数(副作用本身)
scheduler?: () => void // 调度器(决定怎么执行)
deps: Dep[] = [] // 记录自己被哪些 Dep 收集了(双向记录)
parent?: ReactiveEffect // 指向外层 effect(嵌套时用)
run() {
// 执行 fn,并在执行期间把自己设为 activeEffect
// 这样 fn 里读到的所有响应式数据,都能通过 track 找到自己
}
}
一个 effect 在生命周期里经历三个阶段:
- 创建:决定它是哪种类型(配什么 scheduler)
- 收集:
run()时把自己挂成 activeEffect,读数据时被 track 收录 - 触发:数据变了,trigger 找到它,有 scheduler 就调 scheduler,没有就调 run
1.2 activeEffect:为什么需要一个全局变量
这是很多人卡住的第一个点——“正在执行的 effect”为什么要用全局变量标记?
答案很简单:Proxy 的 get trap 是自动触发的,不是手动传参调用的。
// 假设没有 activeEffect,你只能这样传:
function track(target, key, effect) { ... }
// 但 Proxy get 的签名是固定的,没地方传 effect:
get(target, key, receiver) {
track(target, key) // 谁来调?谁在读?track 怎么知道?
}
所以必须有一个全局变量作为“副作用通道”:
let activeEffect = null
class ReactiveEffect {
run() {
activeEffect = this // 执行前,告诉全世界“现在是我在跑”
const result = this.fn()
activeEffect = null // 执行完,恢复
return result
}
}
function track(target, key) {
if (!activeEffect) return // 不是 effect 触发的读,不收集
// 现在 track 就知道是谁在读这个数据了
dep.add(activeEffect)
}
activeEffect 就是“谁在用这个数据”的运行时上下文。
嵌套 effect 时,用 parent 指针+栈式结构保证内外层不混淆:
run() {
// 保存外层
this.parent = activeEffect
// 把自己设为当前
activeEffect = this
// ...
// 恢复外层
activeEffect = this.parent
}
二、双向依赖:dep → effect 和 effect → deps
2.1 什么是双向记录
先看一个具体例子:
const person = reactive({ name: '张三', age: 18 })
effect(() => {
console.log(person.name, person.age)
}) // 假设这个 effect 叫 effect1
此时数据结构长这样:
targetMap (WeakMap):
person → Map {
'name' → Set { effect1 }, ← 这就是 name.dep
'age' → Set { effect1 } ← 这就是 age.dep
}
effect1.deps:
[ name.dep, age.dep ]
dep 知道“谁依赖我”,effect 知道“我依赖谁”——互相指着,这就是双向记录。
2.2 正向:dep → effect,给 trigger 用
// 数据变了,要通知依赖者
person.name = '李四'
// trigger 要做的事:
function trigger(target, key) {
const dep = targetMap.get(target)?.get(key) // 通过 target+key 找到 dep
if (!dep) return
dep.forEach(effect => {
// dep 里直接存着所有依赖这个 key 的 effect
// O(1) 找到,O(n) 遍历触发
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
})
}
有了 dep → effect,trigger 时不用遍历所有 effect 去找谁依赖了这个 key。
2.3 反向:effect → deps,给 cleanup 用
为什么需要反向记录?因为依赖不是静态的。
effect(() => {
if (person.flag) {
console.log(person.name) // 依赖 name
} else {
console.log(person.age) // 依赖 age
}
})
第一次执行(flag = true):
person.name.dep = Set { effect }
person.age.dep = Set { } // 没读,空的
effect.deps = [ person.name.dep ]
flag 变成 false,第二次执行——此时必须把 effect 从 person.name.dep 里删掉:
function cleanupEffect(effect) {
// 遍历 effect.deps,从每个 dep 中删除自己
for (const dep of effect.deps) {
dep.delete(effect) // 从 dep(Set) 中移除
}
effect.deps.length = 0 // 清空 deps
}
在这个例子里,cleanup 只遍历了 effect.deps = [person.name.dep],直接找到 person.name.dep 这个 Set,把自己删掉。O(n),n 是当前 effect 依赖的 key 数量,通常很小。
2.4 如果没有反向记录
如果只有 dep → effect,没有 effect → deps,cleanup 会变成什么?
// 想清理旧依赖,你只能:
// 遍历 targetMap 里的所有 WeakMap key(所有响应式对象)
// → 遍历每个 key 的 Map(对象的所有属性)
// → 遍历每个 dep Set
// → 检查 "这个 effect 在不在里面"
// 这就不是 O(n) 了,是 O(所有响应式对象 × 所有属性 × 所有依赖)
双向记录的本质,是给两个查找方向各建一条索引:
- dep → effect:trigger 时,知道“这个 key 变了要通知谁”
- effect → deps:cleanup 时,知道“我之前存在哪些 dep 里,该从哪里删自己”
所以这不是双向链表,是两个独立的数据结构各自存一份引用。dep 是 Set 的一个成员关系,effect.deps 是数组里的一个元素,它们之间没有 prev/next 指针,也不构成链表结构。
三、cleanup:每次重新收集,确保依赖永不过期
3.1 完整的执行流程
把 cleanup 放进 effect.run() 的完整流程:
class ReactiveEffect {
run() {
// 第一步:清理旧依赖
cleanupEffect(this)
// 此时:effect 不再存在于任何 dep 中,effect.deps = []
// 第二步:标记自己为当前 effect
this.parent = activeEffect
activeEffect = this
// 第三步:执行 fn,重新建立依赖
const result = this.fn()
// fn 里读到 person.name
// → track → person.name.dep.add(this)
// → this.deps.push(person.name.dep)
// 第四步:恢复
activeEffect = this.parent
return result
}
}
每一次 effect.run() 都是一次完整的“清旧—建新”循环。 执行完以后,effect.deps 里只包含“这一次执行真正读到的 key 的 dep”。
3.2 一个完整的分支依赖例子
const state = reactive({ flag: true, a: 'a', b: 'b' })
const myEffect = new ReactiveEffect(() => {
if (state.flag) {
console.log(state.a)
} else {
console.log(state.b)
}
})
// === 第一次执行:flag = true ===
effect.run()
// cleanupEffect: effect.deps = [],没什么可清
// activeEffect = effect
// 执行 fn → 读 state.flag → track,state.flag.dep 加入 effect
// 读 state.a → track,state.a.dep 加入 effect
// 结果:
// state.flag.dep = Set { effect }
// state.a.dep = Set { effect }
// effect.deps = [ state.flag.dep, state.a.dep ]
// === 修改 state.flag = false ===
state.flag = false
// trigger(state, 'flag')
// → 找到 state.flag.dep = Set { effect }
// → effect.scheduler 不存在,直接 effect.run()
effect.run()
// 1. cleanupEffect: 遍历 effect.deps = [state.flag.dep, state.a.dep]
// → state.flag.dep.delete(effect) → state.flag.dep = Set { }
// → state.a.dep.delete(effect) → state.a.dep = Set { }
// → effect.deps = []
// 2. activeEffect = effect
// 3. 执行 fn → 读 state.flag → track,state.flag.dep 再次加入 effect
// 读 state.b → track,state.b.dep 加入 effect
// 4. 恢复
// 结果:
// state.flag.dep = Set { effect }
// state.a.dep = Set { } ← 空!不再被 effect2 依赖
// state.b.dep = Set { effect } ← 新依赖
// effect.deps = [ state.flag.dep, state.b.dep ]
现在 state.a 变了,这个 effect 不会被触发——因为它现在根本不读 a 了。
3.3 不 clean 会怎样
上面的例子,如果不清:
state.a.dep = Set { effect } ← 旧依赖还在
之后 state.a 每次修改都会触发这个 effect 重新执行,但它进去走 else 分支读 state.b,state.a 的值根本用不到。
结果:多余的重复执行 + 依赖集合越来越大 = 性能和语义双重错误。
四、scheduler:trigger 之后的分叉口
4.1 四种 effect 的区分——不是在运行时识别,而是在创建时配好
Vue 3 没有给 effect 加一个 type 字段,不存在 if (effect.type === 'component') 这种判断。
区分四种 effect 的方式只有一个:创建时配了不同的 scheduler。
// 四种创建方式
// 1. 普通 effect
new ReactiveEffect(fn) // scheduler = undefined
// 2. 组件渲染 effect
const e = new ReactiveEffect(componentUpdateFn)
e.scheduler = () => queueJob(e) // 放异步队列
// 3. computed effect
const e = new ReactiveEffect(getter)
e.scheduler = () => { _dirty = true; trigger(...) } // 标记脏
// 4. watch effect
const e = new ReactiveEffect(getter)
e.scheduler = () => { callback(effect.run()) } // 读新值 + 调回调
trigger 的逻辑从来没变过:
function trigger(target, key) {
const dep = targetMap.get(target)?.get(key)
if (!dep) return
const effects = new Set(dep) // 拷贝一份,避免遍历时结构变化
effects.forEach(effect => {
if (effect.scheduler) {
effect.scheduler() // 有调度器就交给你
} else {
effect.run() // 没调度器就直接跑
}
})
}
分叉点在创建 effect 的地方——四个 API,四种 scheduler。
4.2 四种 scheduler 的完整对比
trigger(target, key)
│
▼
找到 dep,遍历其中的 effect
│
├─ 没有 scheduler(普通 effect)
│ effect.run()
│ 同步立即执行 fn
│ 场景:用户直接调用 effect()
│ 特点:改一次跑一次,没有任何中间层
│
├─ 组件渲染 scheduler
│ queueJob(effect)
│ 把 effect 放进异步队列,去重,微任务批量执行
│ 场景:组件模板渲染
│ 特点:连续改三次,只渲染最后一次
│
├─ computed scheduler
│ _dirty = true
│ trigger(computedRef, 'value')
│ 只标记脏,不重新计算
│ 场景:computed
│ 特点:等读取 value 时才懒计算,依赖不变直接返回缓存
│
└─ watch scheduler
const newVal = effect.run() // 重新读值
const oldVal = ...
callback(newVal, oldVal) // 调用户的回调
场景:watch / watchEffect
特点:按 flush 策略决定执行时机
4.3 为什么调度器是灵魂
没有这套调度系统:
- 一个循环改 100 次数据,组件就渲染 100 次
- computed 每次 get 都要重新计算,没有缓存
- watch 的
flush: 'post'根本没法实现(不知道什么时候组件渲染完) - 所有 effect 行为完全一样,无法区分使用场景
Proxy + track/trigger 是骨架,scheduler 是血肉,cleanup 是免疫系统。 三者缺一不可。
五、一张完整结构图
const targetMap = new WeakMap() // 全局依赖容器
let activeEffect = null // 全局上下文标记
// === 结构 ===
targetMap:
target1 (WeakMap key) → Map {
key1 → Set [ effectA, effectB ] ← dep (key1.dep)
key2 → Set [ effectB, effectC ] ← dep (key2.dep)
}
target2 (WeakMap key) → Map {
key3 → Set [ effectA, effectC ] ← dep (key3.dep)
}
effectB:
fn: () => { ... }
scheduler: () => { ... } (可选)
deps: [ key1.dep, key2.dep ] ← 反向记录,指向它依赖的 dep
// === 运行时流程 ===
effectB.run()
├─ cleanupEffect(this)
│ for dep of this.deps:
│ dep.delete(this) // 从旧 dep 中移除自己
│ this.deps = []
│
├─ activeEffect = this // 标记
├─ fn()
│ └─ 读 target1.key1 → get → track(target1, key1)
│ ├─ dep.add(this) // dep → effect
│ └─ this.deps.push(dep) // effect → dep(双向记录完成)
│
└─ activeEffect = null(或恢复外层)
trigger(target1, key1)
├─ 找到 dep = Set [ effectA, effectB ]
└─ 遍历 dep,每个 effect:
├─ 有 scheduler → scheduler()
│ ├─ 组件:queueJob(effect)
│ ├─ computed: dirty = true
│ └─ watch: callback(run())
└─ 没有 scheduler → effect.run()(回到上面的 run 流程)
六、常见误解清单
1. “核心数据结构是双向链表”
是 WeakMap<target, Map<key, Set<effect>>> 三层映射,加上 effect.deps 数组做反向记录。dep 是 Set,deps 是数组,不存在 prev/next 指针,不构成链表。
2. “Vue 3 能识别 effect 类型”
不是识别,是不同 API(effect()/computed()/watch()/组件渲染)在创建 effect 时,各自配了不同 scheduler。ReactiveEffect 类本身没有 type 字段。
3. “watch 初始化会执行 callback”
初始化执行的是 effect.run(),里面跑的是 getter(读数据建依赖),不是 callback。除非配了 immediate: true。
4. “组件渲染会自动合并更新” 不是自动,是 scheduler + queueJob 去重实现的。同一个 effect 多次进队列只留一份,微任务统一执行。
5. “dep → effect 就够了,不需要 effect → deps” 没有反向记录,cleanup 就没法做——你必须遍历所有响应式对象的所有 dep 去找哪个包含当前 effect,时间复杂度爆炸。
6. “双向记录 = 双向链表” “双向记录”指 dep 持有 effect 引用、effect 持有 dep 引用,是逻辑上的互相指向。“双向链表”是特定数据结构,节点有 prev/next 指针串联。两者完全不同。
七、总结:三个设计咬合在一起
scheduler、cleanup、双向记录不是三个独立的功能,而是一套互相依赖的设计:
- 双向记录为 cleanup 提供了“从哪个 dep 删自己”的快速索引
- cleanup 确保每次执行后依赖都是精确的,不多不少
- scheduler 在 cleanup 和 track 之后决定“怎么执行、什么时候执行”
合在一起,回答了一个核心问题:数据变了,怎么精确、高效地让该更新的东西更新——不多跑、不漏跑、不立刻跑。
这三块搞明白了,Vue 3 响应式系统就不再有黑盒。代码量不大,设计却极其精妙。
评论
登录后即可发表评论