文章

依赖收集与触发机制深度解析

Vue3 响应式的发动机是 track 收集依赖与 trigger 触发更新,用 WeakMap 三层结构实现 O(1) 定位。 get 时记账、set 时讨账、effect 重跑前清旧账,理解这套三层数据结构就理解了整个框架高效更新的数学保证。

依赖收集与触发机制深度解析

一句话概括

Vue 3 响应式的发动机是 track(依赖收集)和 trigger(触发更新),用 WeakMap<target, Map<key, Set<effect>>> 三层结构实现 O(1) 定位。get 时记账、set 时讨账、effect 重跑前清旧账——这是整个框架能 60fps 运行的数学保证。

核心知识点

1. 三层数据结构——O(1) 的依赖地图

1
2
3
WeakMap<target, Map<key, Set<effect>>>
  target1 → { count → [effectA, effectB], name → [effectC] }
  target2 → { items → [effectD] }
1
2
3
4
5
6
7
8
9
10
11
const targetMap = new WeakMap();

function track(target, key) {
  if (!activeEffect) return;  // 没有正在运行的 effect,不收集
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));
  let deps = depsMap.get(key);
  if (!deps) depsMap.set(key, (deps = new Set()));
  deps.add(activeEffect);
  activeEffect.deps.push(deps);  // 🔑 反向引用——用于 cleanup
}

为什么 WeakMap? 被代理的对象销毁时,对应的依赖关系自动被 GC 回收。Map 做不到——它的 key 是强引用,阻止回收。

2. effect——副作用的载体

1
2
3
4
5
6
7
8
9
10
11
12
13
let activeEffect = null;

function effect(fn) {
  const _effect = () => {
    cleanup(_effect);       // ① 先清旧依赖(防止"幽灵依赖")
    activeEffect = _effect; // ② 设为当前 effect
    fn();                   // ③ 执行 fn,内部的 get 自动 track
    activeEffect = null;    // ④ 复原
  };
  _effect.deps = [];        // 反向引用数组
  _effect();                // 立即执行收集第一轮依赖
  return _effect;
}

为什么每次重跑前要 cleanup? 分支切换场景:effect(() => state.flag ? state.a : state.b)。flag 从 true 切 false 后,旧依赖 dep(a) 必须从这个 effect 中移除——否则改 state.a 仍然会触发这个不再关心 a 的 effect。

3. track 与 trigger 的配合——记账与讨账

1
2
3
4
5
6
7
8
9
// 每次 get → track(记账)
// 每次 set → trigger(讨账)
const state = reactive({ count: 0 });

effect(() => {
  document.body.textContent = state.count; // get → track(state, 'count')
});

state.count++;  // set → trigger(state, 'count') → effect 重跑

4. 嵌套 effect 的正确恢复——parent 指针

1
2
3
4
5
6
7
8
9
10
11
// 外层 effect 套内层 effect
effect(() => {
  // activeEffect = effectOuter
  console.log(state.a);  // track → effectOuter
  effect(() => {
    // activeEffect = effectInner
    console.log(state.b);  // track → effectInner
  });
  // ⚠️ activeEffect 必须恢复为 effectOuter!
  console.log(state.c);  // 这里的 track 应该用 effectOuter
});

Vue 3 用 parent 指针代替数组栈处理嵌套——每次新 effect 执行前把当前的 activeEffect 存为 parent,执行完恢复。这个设计比数组栈更高效(大多数场景嵌套 ≤ 3)。

5. scheduler——trigger 不直接调用 effect

1
2
3
4
5
6
7
// effect 的第二个参数
effect(fn, {
  scheduler(effect) {
    // 不直接 run,而是推入微任务队列
    queueMicrotask(() => effect());
  }
});

这是异步批量更新的根: 渲染 effect 的 scheduler 走的是异步队列——同一 tick 内 100 次数据修改,渲染 effect 只被推入队列一次。所以用户永远看不到中间态。

其实你每天都在用

  • 模板编译: 组件 render 函数就是一个 effect,模板里访问的每个响应式变量自动被 track
  • computed 的懒计算: trigger 发现 computed 的依赖变了,只设 dirty = true,不立即算值
  • watch 的 flush 控制: trigger 看到 effect 有 scheduler 就委托给它——flush: 'post' 的 watch 在 DOM 更新后才执行
  • 组件卸载清理: onUnmounted 调用 stop(effect) → cleanup 清空所有 dep 引用 → 闭包引用全部断开
  • v-if 分支切换: 条件从 true 切 false,cleanup 剪断旧分支的所有依赖链

常见误解(FAQ)

  • ❌ 误区:「trigger 会立刻执行所有相关的 effect」 不会。trigger 先检查 effect 有没有 scheduler——computed 的 scheduler 只设 dirty,渲染 effect 的 scheduler 推入异步队列。只有普通 effect(fn) 才同步 run。

  • ❌ 误区:「effect 里修改依赖自己的数据没问题」 effect(() => { state.count++ }) 会死循环。虽然 Vue 3 有 activeEffect 比对拦截(trigger 不执行自己),但如果 effect A 改 B 又触发 A,链条长了仍可能跑飞。

  • ❌ 误区:「WeakMap 自动回收就万事大吉了」 WeakMap 的 key 是原始对象(不是 Proxy 包装后的),如果原始对象被全局变量引用,条目不会回收。而且 effect.deps 的 Set 引用是强引用——必须调 stop() 或组件卸载来切断。

  • ❌ 误区:「每次 get 都 track」 有 shouldTrack 全局标志位,Vue 内部在某些场景(如遍历数组、执行 computed getter 的部分阶段)会 pauseTracking(),避免收集到不该收集的依赖。

一句话总结

track 在 get 里记账(谁在用我),trigger 在 set 里讨账(通知所有债主),cleanup 在重跑前清旧账(剪断不再需要的依赖链)——Vue 响应式的核心不是 Proxy,而是这套 O(1) 的依赖会计系统。

本文由作者按照 CC BY 4.0 进行授权