Appearance
依赖收集与派发更新:track 和 trigger 的工作机制
在第一节中,我们了解到 Proxy 会在 get(读取)和 set(写入)时拦截对象操作。在第二节节中,我们知道 ref 通过 .value 访问器也实现了 get 和 set 的拦截。
现在,我们来分析这两个拦截器是如何工作的:
- 当
get被拦截时,Vue 究竟做了什么来“收集依赖”?(track) - 当
set被拦截时,Vue 又是如何“派发更新”的?(trigger)
track(依赖收集)和 trigger(派发更新)是连接“读取”与“写入”的核心,是整个响应式系统的中枢。
核心:ReactiveEffect —— 副作用函数
在 Vue 中,任何需要响应数据变化而重新执行的函数,都被称为“副作用”(Side Effect)。
最典型的两个例子:
- 组件的
render函数(数据变化,UI 自动更新)。 computed(计算属性)的getter函数(依赖变化,computed值重新计算)。
Vue 将这些副作用函数封装在一个 ReactiveEffect 类的实例中。ReactiveEffect 可以理解为一个“订阅者”。
typescript
// core/packages/reactivity/src/effect.ts
// 全局变量,用于存放“当前正在运行”的 effect
export let activeEffect: ReactiveEffect | undefined
export class ReactiveEffect {
// 1. 原始的副作用函数 (如 render)
public fn: () => T
// 2. 调度器 (可选),用于控制执行时机
public scheduler?: EffectScheduler
// 3. 存储此 effect 订阅了哪些依赖
deps: Set<Dep> = new Set()
constructor(fn: () => T, scheduler?: EffectScheduler) {
this.fn = fn
this.scheduler = scheduler
}
// 执行副作用
run() {
// 【关键】在运行前,将“当前实例”注册到全局
activeEffect = this
// 执行原始函数 (例如 render())
// 在此期间,任何响应式数据的 get 都会“看到” activeEffect
const result = this.fn()
// 运行完毕,清除全局状态
activeEffect = undefined
return result
}
}run() 方法是整个机制的核心:它通过设置一个全局变量 activeEffect,标记“我正在运行,我接下来访问的所有响应式数据,都需要收集我这个依赖。”
依赖收集 track:建立订阅关系
现在我们有了“订阅者”(activeEffect)。当 render 函数执行到
{{ state.count }}
时,会触发 state.count 的 Proxy.get 拦截。get 拦截器会立刻调用 track(state, 'count')。track 函数的核心任务是:将“当前正在运行的 activeEffect” 和 “state 对象的 count 属性” 建立订阅关系。
为此,Vue 需要一个“全局依赖地图”,这就是 targetMap。
targetMap:全局依赖地图
这是一个三层嵌套的数据结构: WeakMap< target, Map< key, Dep > >
- 第 1 层 (WeakMap):
targetMapkey: 响应式对象target(例如state对象)value: 一个Map
- 第 2 层 (Map):
depsMapkey: 属性名key(例如"count")value: 一个Dep
- 第 3 层 (Dep):
dep- 这是一个
Set集合,存放着所有订阅了[target, key]的ReactiveEffect(订阅者)实例。
- 这是一个
这个结构可以理解为: state 对象 -> "count" 属性 -> [ effect1(render), effect2(computed), ... ]
track 的实现
track 函数的工作就是在 targetMap 上登记这个订阅关系:
typescript
// core/packages/reactivity/src/operations.ts
export function track(target: object, key: unknown): void {
// 1. 检查:是否有 activeEffect 在运行?
// 如果没有,说明只是一个普通的 get,不需要收集依赖。
if (!activeEffect) {
return
}
// 2. 找到 state 对象的依赖表 (depsMap)
let depsMap = targetMap.get(target)
if (!depsMap) {
// 首次被 track,创建它
depsMap = new Map()
targetMap.set(target, depsMap)
}
// 3. 找到 state.count 属性的订阅者集合 (dep)
let dep = depsMap.get(key)
if (!dep) {
// 首次被 track,创建它
dep = new Set() // 源码中是一个 Dep 类,其核心是 Set
depsMap.set(key, dep)
}
// 4. 【登记】
// 将“当前订阅者”添加到“订阅者集合”中
dep.add(activeEffect)
// 5. 【反向登记】
// 让 effect 也“记住”它订阅了哪些 dep
// 这用于后续的“依赖清理”
activeEffect.deps.add(dep)
}track 的工作流就是:定位到 targetMap 中 [target][key] 对应的 Set,并将 activeEffect 添加进去。
派发更新 trigger:通知变更
track 建立了订阅关系。当 state.count++ 发生时,Proxy.set 拦截器会调用 trigger(state, 'count')。
trigger 函数的核心任务是:去 targetMap 里,找到所有订阅了 [state, 'count'] 的 effect,并让它们全部重新运行。
typescript
// core/packages/reactivity/src/operations.ts
export function trigger(target: object, key: unknown): void {
// 1. 找到 state 对象的依赖表
const depsMap = targetMap.get(target)
if (!depsMap) {
// 该对象从未被 track 过,无需操作
return
}
// 2. 找到 state.count 属性的订阅者集合
const dep = depsMap.get(key)
if (!dep) {
// 该属性从未被 track 过,无需操作
return
}
// 3. 【通知】
// 遍历所有订阅者 (ReactiveEffect),并执行它们
// (创建一个副本 Set(dep) 来遍历,
// 防止在遍历时修改原始集合引发的无限循环)
const effects = new Set(dep)
effects.forEach(effect => {
// 【关键】
// 如果 effect 存在调度器 (scheduler),则调用 scheduler
if (effect.scheduler) {
effect.scheduler()
} else {
// 否则,直接运行
effect.run()
}
})
}核心优化:scheduler 与“依赖清理”
trigger 的实现揭示了 Vue 响应式系统的关键设计:trigger 只负责“通知”,不一定负责“立即执行”。
scheduler(调度器):解耦“通知”与“执行”
在 3.3.2 节中,我们看到 trigger 优先调用 effect.scheduler()。
为什么? 组件的 render 函数被封装成 ReactiveEffect 时,它的 scheduler 被指定为 queueJob(即 2.2 节中的异步更新队列)。
当 trigger 运行时:
- 它找到了
render对应的ReactiveEffect。 - 它调用
effect.scheduler(),即queueJob(effect.run)。 queueJob将effect.run(组件更新函数)放入一个微任务队列中,并进行去重。trigger函数同步执行完毕。- 在将来的微任务中,
effect.run才会被执行,组件重新渲染。
这就是 trigger 如何与 Vue 的“异步批量更新”机制(nextTick)协同工作的:trigger 负责“触发”,scheduler 负责“执行时机”。
依赖清理 (Cleanup)
一个 effect 的依赖可能在每次运行时发生变化。
场景:const text = state.show ? state.name : ''
- 第 1 次运行 (
state.show = true):track收集了state.show和state.name两个依赖。 - 修改:
state.show = false。 trigger触发state.show的依赖,effect重新run()。- 第 2 次运行:
run()只访问了state.show。 - 问题:如果不做处理,
state.name的“订阅者集合”里依然有这个effect。 - 后果:当你修改
state.name时,这个effect会不必要地被再次触发,浪费性能。
解决方案: ReactiveEffect 在 run() 时,会先清除自己所有的旧依赖,然后再运行 this.fn() 来收集新依赖。
typescript
// ReactiveEffect.run() 的真实逻辑
run() {
// 1. 【清理】
// 遍历 this.deps (在 3.3.2 节中“反向登记”的集合),
// 从所有订阅列表 (dep) 中把自己 (this) 移除。
this.deps.forEach(dep => dep.delete(this))
this.deps.clear()
// 2. 注册:设置 activeEffect
activeEffect = this
// 3. 运行 (fn):重新执行 render(),
// track() 会重新建立“新”的订阅关系
this.fn()
// 4. 清理:清除 activeEffect
activeEffect = undefined
}(注:Vue 3 源码使用双向链表和版本号(Link / prepareDeps / cleanupDeps)来实现此清理,目的是达到 O(1) 性能,但其核心逻辑与上述 Set 的 delete/add 是一致的。)
总结
track 和 trigger 是 Vue 响应式系统的两大支柱,它们构建了一个“发布-订阅”模型:
- 订阅者:
ReactiveEffect封装了render等副作用函数。 - 订阅时机:
effect.run()时,设置全局activeEffect,标记“开始收集依赖”。 track(订阅):Proxy.get调用track。track读取activeEffect,并将它存入targetMap中[target][key]对应的Set(Dep)里。trigger(发布):Proxy.set调用trigger。trigger在targetMap中找到[target][key]对应的所有effect。- 调度:
trigger不直接运行effect,而是调用它们的scheduler(如果存在),将执行权交给 Vue 的异步更新队列(queueJob),实现批量更新。 - 清理:
effect在每次重新运行时,都会先清空自己的旧订阅,然后再重建新订阅,确保依赖关系的精确性。
