Skip to content

依赖收集与派发更新:track 和 trigger 的工作机制

在第一节中,我们了解到 Proxy 会在 get(读取)和 set(写入)时拦截对象操作。在第二节节中,我们知道 ref 通过 .value 访问器也实现了 getset 的拦截。

现在,我们来分析这两个拦截器是如何工作的:

  1. get 被拦截时,Vue 究竟做了什么来“收集依赖”?(track
  2. set 被拦截时,Vue 又是如何“派发更新”的?(trigger

track(依赖收集)和 trigger(派发更新)是连接“读取”与“写入”的核心,是整个响应式系统的中枢。

核心:ReactiveEffect —— 副作用函数

在 Vue 中,任何需要响应数据变化而重新执行的函数,都被称为“副作用”(Side Effect)。

最典型的两个例子:

  1. 组件的 render 函数(数据变化,UI 自动更新)。
  2. 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.countProxy.get 拦截。get 拦截器会立刻调用 track(state, 'count')

track 函数的核心任务是:将“当前正在运行的 activeEffect” 和 “state 对象的 count 属性” 建立订阅关系。

为此,Vue 需要一个“全局依赖地图”,这就是 targetMap

targetMap:全局依赖地图

这是一个三层嵌套的数据结构: WeakMap< target, Map< key, Dep > >

  • 第 1 层 (WeakMap)targetMap
    • key: 响应式对象 target (例如 state 对象)
    • value: 一个 Map
  • 第 2 层 (Map)depsMap
    • key: 属性名 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 运行时:

  1. 它找到了 render 对应的 ReactiveEffect
  2. 它调用 effect.scheduler(),即 queueJob(effect.run)
  3. queueJobeffect.run(组件更新函数)放入一个微任务队列中,并进行去重。
  4. trigger 函数同步执行完毕。
  5. 在将来的微任务中,effect.run 才会被执行,组件重新渲染。

这就是 trigger 如何与 Vue 的“异步批量更新”机制(nextTick)协同工作的:trigger 负责“触发”,scheduler 负责“执行时机”

依赖清理 (Cleanup)

一个 effect 的依赖可能在每次运行时发生变化。

场景const text = state.show ? state.name : ''

  1. 第 1 次运行 (state.show = true):track 收集了 state.showstate.name 两个依赖。
  2. 修改state.show = false
  3. trigger 触发 state.show 的依赖,effect 重新 run()
  4. 第 2 次运行run() 只访问了 state.show
  5. 问题:如果不做处理,state.name 的“订阅者集合”里依然有这个 effect
  6. 后果:当你修改 state.name 时,这个 effect不必要地被再次触发,浪费性能。

解决方案ReactiveEffectrun() 时,会先清除自己所有的旧依赖,然后再运行 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) 性能,但其核心逻辑与上述 Setdelete/add 是一致的。)

总结

tracktrigger 是 Vue 响应式系统的两大支柱,它们构建了一个“发布-订阅”模型:

  1. 订阅者ReactiveEffect 封装了 render 等副作用函数。
  2. 订阅时机effect.run() 时,设置全局 activeEffect,标记“开始收集依赖”。
  3. track (订阅)Proxy.get 调用 tracktrack 读取 activeEffect,并将它存入 targetMap[target][key] 对应的 SetDep)里。
  4. trigger (发布)Proxy.set 调用 triggertriggertargetMap 中找到 [target][key] 对应的所有 effect
  5. 调度trigger 不直接运行 effect,而是调用它们的 scheduler(如果存在),将执行权交给 Vue 的异步更新队列(queueJob),实现批量更新。
  6. 清理effect 在每次重新运行时,都会先清空自己的旧订阅,然后再重建新订阅,确保依赖关系的精确性。

微信公众号二维码

Last updated: