Learn
Vue/07-reactivity-in-depth

响应式原理:Proxy、依赖收集与 effect

前几章我们一直「在用」响应式,这一章我们亲手实现它。理解原理后,很多「为什么这样设计」的问题会豁然开朗:为什么 reactive 不能整体替换、为什么解构会丢响应、为什么 Vue 3 比 Vue 2 强。

Vue 3 的响应式核心是四个东西:

  1. reactive:用 Proxy 包裹对象,拦截读写
  2. track:读的时候,把「当前正在运行的副作用」收集到这个属性的依赖集合里
  3. trigger:写的时候,找出这个属性的依赖集合,挨个重新运行
  4. effect:一个会收集依赖、发生变化时重跑的函数

1. 三层依赖表 targetMap

Vue 用一张三层表维护「谁依赖了谁」:

targetMap: WeakMap<原始对象, Map<属性名, Set<副作用>>>
             ① 对象       ② 属性        ③ 订阅该属性的副作用集合

读 obj.x 时:在 ① 找到 obj 的表,在 ② 找到 x 的依赖集合,把「当前 effect」加进去。 写 obj.x 时:在 ② 找到 x 的依赖集合,把里面的 effect 全部重跑。

2. Proxy 的 get/set 陷阱

Proxy 让我们能拦截对象的所有读写:

const proxy = new Proxy(raw, {
  get(obj, key, receiver) {
    track(obj, key);                       // 读时收集依赖
    return Reflect.get(obj, key, receiver);
  },
  set(obj, key, value, receiver) {
    const old = obj[key];
    const result = Reflect.set(obj, key, value, receiver);
    if (!Object.is(old, value)) trigger(obj, key); // 变了才通知
    return result;
  },
});

要点:

  • 用 Reflect.get/set 而不是 obj[key],并且把 receiver 传进去——这样当对象有继承(如 extends、或属性来自原型)时,this 指向正确,原型上的 getter 也能正常工作
  • Reflect.set 返回布尔值,表示设置是否成功,原样返回
  • 同值不触发:Object.is(old, value) 为 true 就跳过,这就是 Vue 避免无谓重渲染的第一道关卡(第 4 章见过)

2.1 惰性深度代理

get 里发现读到的属性是个对象,才递归 reactive() 它。好处:只有真正被读到的嵌套对象才被代理,大对象不用的部分不会被深度遍历。而且用 reactiveMap 缓存,保证同一个原始对象永远对应同一个代理(否则 a.b === a.b 会 false)。

2.2 嵌套 effect 用栈

如果 effect 里又运行了另一个 effect(组合式函数很常见),activeEffect 不能只是单个变量,否则内层 effect 会把外层的依赖搞乱。用栈:进内层前 push,出内层后 pop 还原。

3. 依赖清理与 stop

每次 effect 重跑前,要先把上一次收集的依赖全部删掉,再重新收集。为什么?看一个分支:

effect(() => {
  document.title = state.ok ? state.text : 'nothing';
});
  • 第一次 ok=false:只读了 ok,依赖是 { ok },没读 text
  • 后来 ok=true:这次读了 text,依赖应是 { ok, text }
  • 如果不清理旧依赖,text 被加上了,但 ok=false 时的依赖残留还在——之后 text 变不相关了,effect 却还会被 text 的变化错误触发

所以每次重跑都要 cleanup(从所有依赖集合里删掉自己),再重新 track。stop() 则是彻底卸载:清理依赖 + 标记 active=false,之后任何写入都不再触发它。

4. 亲手实现 reactive + effect

下面两个 Playground 把整套机制写出来跑一遍。

reactive() 的 Proxy 陷阱:track / trigger 全过程
type Dep = Set<ReactiveEffect>;
type KeyToDepMap = Map<PropertyKey, Dep>;
 
const targetMap = new WeakMap<object, KeyToDepMap>();
let activeEffect: ReactiveEffect | null = null;
const effectStack: ReactiveEffect[] = [];
 
interface ReactiveEffect {
  deps: Dep[];
  active: boolean;
  run: () => void;
  stop: () => void;
}
 
function cleanup(e: ReactiveEffect): void {
  for (const dep of e.deps) {
    dep.delete(e);
  }
  e.deps.length = 0;
}
 
function effect(fn: () => void): ReactiveEffect {
  const e: ReactiveEffect = {
    deps: [],
    active: true,
    run: () => {
      if (!e.active) {
        fn();
        return;
      }
      if (!effectStack.includes(e)) {
        cleanup(e);
        try {
          effectStack.push(e);
          activeEffect = e;
          fn();
        } finally {
          effectStack.pop();
          activeEffect = effectStack[effectStack.length - 1] ?? null;
        }
      }
    },
    stop: () => {
      if (e.active) {
        cleanup(e);
        e.active = false;
      }
    },
  };
  e.run();
  return e;
}
 
function track(target: object, key: PropertyKey): void {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }
  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }
  dep.add(activeEffect);
  activeEffect.deps.push(dep);
}
 
function trigger(target: object, key: PropertyKey): void {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const dep = depsMap.get(key);
  if (!dep) return;
  const effects = new Set(dep);
  for (const e of effects) {
    if (e !== activeEffect) e.run();
  }
}
 
const RAW = Symbol('raw');
 
function isObject(v: unknown): v is object {
  return typeof v === 'object' && v !== null;
}
 
const reactiveMap = new WeakMap<object, object>();
 
function reactive<T extends object>(target: T): T {
  if (!isObject(target)) return target;
  if ((target as Record<symbol, unknown>)[RAW]) return target;
  const existing = reactiveMap.get(target);
  if (existing) return existing as T;
 
  const proxy = new Proxy(target, {
    get(obj: object, key: string | symbol, receiver: any) {
      if (key === RAW) return target;
      const res = Reflect.get(obj, key, receiver);
      track(obj, key);
      if (isObject(res)) return reactive(res as object) as object;
      return res;
    },
    set(obj: object, key: string | symbol, value: any, receiver: any) {
      const oldVal = (obj as Record<string | symbol, unknown>)[key];
      const hadKey = Object.prototype.hasOwnProperty.call(obj, key);
      const result = Reflect.set(obj, key, value, receiver);
      if (!hadKey) {
        trigger(obj, key);
      } else if (!Object.is(oldVal, value)) {
        trigger(obj, key);
      }
      return result;
    },
  });
 
  reactiveMap.set(target, proxy);
  return proxy as T;
}
 
// ---- 测试 ----
const state = reactive({ count: 1, user: { name: 'Ada' } });
 
let aRuns = 0;
let bRuns = 0;
 
const effA = effect(() => {
  aRuns++;
  console.log("effectA: " + state.count);
});
const effB = effect(() => {
  bRuns++;
  console.log("effectB: " + state.user.name);
});
 
console.log("--- 改 count ---");
state.count = 2;
 
console.log("--- 改 user.name ---");
state.user.name = 'Grace';
 
console.log("--- 赋相同值 ---");
state.count = 2;
 
console.log("aRuns=" + aRuns + " bRuns=" + bRuns);
 
const raw = (state as unknown as Record<symbol, unknown>)[RAW] as object;
const userProxy = state.user;
const userRaw = (userProxy as unknown as Record<symbol, unknown>)[RAW] as object;
const depsForCount = targetMap.get(raw)?.get('count');
const depsForName = targetMap.get(userRaw)?.get('name');
console.log("count 的依赖数: " + (depsForCount ? depsForCount.size : 0));
console.log("user.name 的依赖数: " + (depsForName ? depsForName.size : 0));
 
const r1 = reactive(state);
const r2 = reactive(state);
console.log("reactive(o) === reactive(o): " + (r1 === r2));

输出证明:改 count 只触发 effectA,改 user.name 只触发 effectB(惰性深度代理生效,两个属性各自有独立依赖集合),赋相同值不触发。reactive(o) === reactive(o) 为 true 证明代理身份唯一。

依赖清理与 stop():分支切换的过期依赖
type Dep = Set<ReactiveEffect>;
const targetMap = new WeakMap<object, Map<PropertyKey, Dep>>();
let activeEffect: ReactiveEffect | null = null;
 
interface ReactiveEffect {
  deps: Dep[];
  active: boolean;
  run: () => void;
  stop: () => void;
}
 
function cleanup(e: ReactiveEffect): void {
  for (const dep of e.deps) dep.delete(e);
  e.deps.length = 0;
}
 
function effect(fn: () => void, doCleanup = true): ReactiveEffect {
  const e: ReactiveEffect = {
    deps: [],
    active: true,
    run: () => {
      if (!e.active) { fn(); return; }
      if (doCleanup) cleanup(e);
      activeEffect = e;
      try { fn(); } finally { activeEffect = null; }
    },
    stop: () => { if (e.active) { cleanup(e); e.active = false; } },
  };
  e.run();
  return e;
}
 
function track(target: object, key: PropertyKey): void {
  if (!activeEffect) return;
  let m = targetMap.get(target);
  if (!m) { m = new Map(); targetMap.set(target, m); }
  let dep = m.get(key);
  if (!dep) { dep = new Set(); m.set(key, dep); }
  dep.add(activeEffect);
  activeEffect.deps.push(dep);
}
 
function trigger(target: object, key: PropertyKey): void {
  const m = targetMap.get(target);
  if (!m) return;
  const dep = m.get(key);
  if (!dep) return;
  for (const e of new Set(dep)) if (e !== activeEffect) e.run();
}
 
function isObject(v: unknown): v is object { return typeof v === 'object' && v !== null; }
const reactiveMap = new WeakMap<object, object>();
const RAW = Symbol('raw');
function reactive<T extends object>(target: T): T {
  if (!isObject(target)) return target;
  if ((target as Record<symbol, unknown>)[RAW]) return target;
  const existing = reactiveMap.get(target);
  if (existing) return existing as T;
  const proxy = new Proxy(target, {
    get(obj: object, key: string | symbol, receiver: any) {
      if (key === RAW) return target;
      const res = Reflect.get(obj, key, receiver);
      track(obj, key);
      if (isObject(res)) return reactive(res as object) as object;
      return res;
    },
    set(obj: object, key: string | symbol, value: any, receiver: any) {
      const oldVal = (obj as Record<string | symbol, unknown>)[key];
      const hadKey = Object.prototype.hasOwnProperty.call(obj, key);
      const result = Reflect.set(obj, key, value, receiver);
      if (!hadKey || !Object.is(oldVal, value)) trigger(obj, key);
      return result;
    },
  });
  reactiveMap.set(target, proxy);
  return proxy as T;
}
 
// ===== 场景:分支切换导致依赖变化 =====
const stateA = reactive({ ok: false, text: 'hi' });
 
// 版本一:开启 cleanup(正确行为)
let runCount1 = 0;
const eff1 = effect(() => {
  runCount1++;
  const v = stateA.ok ? stateA.text : 'nothing';
  console.log("[cleanup] #" + runCount1 + " ok=" + stateA.ok + " -> " + v);
});
// ok=false 时依赖只有 {ok}
stateA.ok = true;   // 触发,依赖变 {ok, text}
stateA.text = 'a';  // 触发(ok=true 依赖 text)
stateA.ok = false;  // 触发,依赖变回 {ok}(text 被清理)
stateA.text = 'b';  // 不触发(ok=false 不读 text)
console.log("开启 cleanup 时总 runCount = " + runCount1);
 
// 版本二:关闭 cleanup(残留过期依赖,错误行为),用独立对象避免互相干扰
const stateB = reactive({ ok: false, text: 'hi' });
let runCount2 = 0;
const eff2 = effect(() => {
  runCount2++;
  const v = stateB.ok ? stateB.text : 'nothing';
  console.log("[no-cleanup] #" + runCount2 + " ok=" + stateB.ok + " -> " + v);
}, false);
stateB.ok = true;
stateB.text = 'a';
stateB.ok = false;
stateB.text = 'b';  // 错误地触发了!因为 text 仍残留在 deps 里(此时 ok=false,本不该读 text)
console.log("关闭 cleanup 时总 runCount = " + runCount2);
 
// ===== stop 演示 =====
const stateC = reactive({ n: 1 });
let runCount3 = 0;
const eff3 = effect(() => {
  runCount3++;
  console.log("[stop-demo] #" + runCount3 + " n = " + stateC.n);
});
stateC.n = 2;
eff3.stop();
stateC.n = 3; // 不再触发
console.log("stop 后 runCount = " + runCount3);

关键对比:ok 切回 false 后,state.text 已不再被读取。开启 cleanup 的版本不再因 text 变化而重跑(runCount 停在 4);关闭 cleanup 的版本因为 text 依赖残留,错误地多跑了一次(runCount = 5,多打印了一行 [no-cleanup] render: b)。这正是 Vue 每次重跑 effect 前必须 cleanup 的原因。

🎯练习 1:画出 targetMap

对 reactive({ a: 1, b: { c: 2 } }),注册两个 effect:

  • eff1 读 a
  • eff2 读 b.c

写出此时 targetMap 的完整结构(用 { 原始对象 → { 属性 → [effect] } } 表示)。

🎯练习 2:新增 key 与修改 key 的触发差异

在 reactive({}) 上,分别执行 obj.x = 1(新增 key)和 obj.x = 2(修改已有 key)。两者都会触发 trigger(obj, 'x') 吗?为什么 set 陷阱里要区分 hadKey?提示:考虑 for...in、数组 length、以及 ITERATE_KEY 这种「集合级依赖」。

🎯练习 3:手写 readonly

实现一个 readonly(obj):读时照常 track,但写/删只 warn 不生效。并说明它为什么不需要 track(即只读数据为什么通常不会被当作响应式依赖的来源,除非你主动读它)。

小结

  • 响应式四件套:reactive(Proxy 拦截) + track(读时收集) + trigger(写时通知) + effect(会重跑的副作用)
  • 依赖表三层:targetMap: WeakMap<对象, Map<属性, Set<effect>>>
  • get/set 用 Reflect 并传 receiver,保证继承场景下 this 正确;同值(Object.is)跳过 trigger
  • 惰性深度代理:只在读到嵌套对象时 reactive 它,并用 reactiveMap 保证代理身份唯一
  • 嵌套 effect 用栈(effectStack)而非单个变量
  • 每次重跑前必须 cleanup 旧依赖,否则分支切换会留下过期依赖;stop() 彻底卸载
  • 下一章讲 composables,并亲手实现 effectScope 批量管理 effect →