响应式原理:Proxy、依赖收集与 effect
前几章我们一直「在用」响应式,这一章我们亲手实现它。理解原理后,很多「为什么这样设计」的问题会豁然开朗:为什么 reactive 不能整体替换、为什么解构会丢响应、为什么 Vue 3 比 Vue 2 强。
Vue 3 的响应式核心是四个东西:
reactive:用Proxy包裹对象,拦截读写track:读的时候,把「当前正在运行的副作用」收集到这个属性的依赖集合里trigger:写的时候,找出这个属性的依赖集合,挨个重新运行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 把整套机制写出来跑一遍。
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 证明代理身份唯一。
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 的原因。
对 reactive({ a: 1, b: { c: 2 } }),注册两个 effect:
- eff1 读
a - eff2 读
b.c
写出此时 targetMap 的完整结构(用 { 原始对象 → { 属性 → [effect] } } 表示)。
在 reactive({}) 上,分别执行 obj.x = 1(新增 key)和 obj.x = 2(修改已有 key)。两者都会触发 trigger(obj, 'x') 吗?为什么 set 陷阱里要区分 hadKey?提示:考虑 for...in、数组 length、以及 ITERATE_KEY 这种「集合级依赖」。
实现一个 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 →