effect 与依赖收集:track 和 trigger 的实现
🎯 引言
上一篇我们实现了 reactive 的 get/set 拦截,也就是:只要你读取或修改数据,Vue 都能「半路截住」这次操作。但拦截器里留下了两个占位:get 里要调用 track 收集依赖,set 里要调用 trigger 触发更新。这两个函数到底在收集什么、又在更新什么?这篇我们就把坑填上。
这篇会出现几个新词,先混个脸熟,后面都会挨个解释:
- 依赖:大白话就是「谁在用这个数据」。
- 依赖收集:把「谁在用」记下来的过程。
- 触发更新:数据变了,通知使用者重新干活的过程。
学完本篇,你将能:
- 说清楚
effect是干什么的。 - 讲明白「谁在用数据」这份记录存在哪、长什么样。
- 手写
track和trigger的简化实现。 - 把上一篇的
reactive和本篇的effect串成一条完整的响应式链路。
本篇源码基于 Vue 3.5.x(GitHub vuejs/core 仓库),主要涉及 packages/reactivity/src/effect.ts。示例代码可以直接用 Node.js v22 运行。
🧱 effect 是什么
先回答一个问题:数据变了,Vue 凭什么知道要「更新哪里」?答案是:它不靠猜,而是靠记录。谁在读取数据,Vue 就把谁记下来。数据一变,就按记录逐个通知。负责「被记录」的角色就是 effect。
effect 是一个包装器。你传一个函数给它,它会立即执行这个函数,并且记住一件事:函数里读到的响应式数据,以后一变,就重新执行这个函数:
const obj = reactive({ name: 'noxussj' });
effect(() => {
// 这个函数会先立即执行一次
// 之后 obj.name 每次变化,它都会自动重新执行
console.log('name 是:', obj.name);
});
这个流程和订阅报纸很像:你先订阅一次(执行函数,顺便登记),之后报纸一更新(数据变化),就自动送报给你(重新执行),不用你每天自己去问。
接下来的问题是:函数执行期间,Vue 怎么知道「正在执行的是哪个 effect」?靠一个全局变量 activeEffect。effect 执行你的函数之前,先把自己存进 activeEffect,执行完再清空。这样,函数执行期间任何数据被读取,都能通过这个变量找到「当前是谁在读我」:
// 简化自 packages/reactivity/src/effect.ts
// 全局变量:指向当前正在执行的 effect
let activeEffect = null;
function effect(fn) {
// 把传入的函数包装成一个带 run 方法的 effect 对象
const _effect = {
fn,
run() {
try {
// 执行前:把 activeEffect 指向自己
activeEffect = _effect;
return fn(); // 执行用户的函数
} finally {
// 执行后:不管成功失败,都清空 activeEffect
activeEffect = null;
}
},
};
_effect.run(); // 立即执行一次,顺便完成第一次登记
return _effect;
}
注意 run 里的三步:先登记自己、再执行、最后清空。这就是整个依赖收集的开关:只有 activeEffect 有值的时候,读取数据才会被记录。
🧱 targetMap:记录存在哪
上一节说了要「记录」,那这份记录具体存在哪里?这一节就来回答这个问题。
Vue3 准备了一个全局容器 targetMap,它只有一句话的功能:记住「哪个对象的哪个属性,被哪些 effect 使用」。为了做到这一点,它里面套了三层:
// 简化自 packages/reactivity/src/dep.ts
const targetMap = new WeakMap();
// 三层结构,从外到内:
// 第一层:按「对象」存,比如 obj
// 第二层:按「属性名」存,比如 'name'、'age'
// 第三层:存所有读过这个属性的 effect
光看文字有点抽象,我们画一张图。假设 obj = reactive({ name: 'noxussj', age: 18 }),effect1 读了 name 和 age,effect2 只读了 name,那记录长这样:
targetMap
└── obj(第一层:按对象存)
├── 'name' → effect1、effect2(第二层按属性名存,第三层是 effect 集合)
└── 'age' → effect1
这样,以后 obj.name 一变,Vue 查表就知道要通知 effect1 和 effect2。三层各用一种容器,也不是随便选的:
- 第一层用
WeakMap:它对 key 是「弱引用」,意思是它不拦着垃圾回收。垃圾回收就是 JS 自动清理没人用的内存的机制。如果某个响应式对象没有任何地方再用了,它会被自动回收,对应的整条依赖记录也会一起消失,不会白白占着内存。 - 第二层用
Map:按属性名分组,这样更新可以精确到属性。obj.name变了,只通知读了name的 effect,不会误伤读了age的。 - 第三层用
Set:Set的特点是自动去重。同一个 effect 在函数里读两次obj.name,也只会被登记一次,通知时不会重复执行。
targetMap 表,按需查表。思路一致,只是存储方式不同。Vue2 的写法可以回看 Vue2 源码课程。🧱 track:把「谁在用我」记下来
记录的结构想清楚了,接下来的问题是:登记动作在哪一刻发生?答案是读取数据的那一刻,也就是上一篇 get 拦截器里调用的 track。
track 干的事一句话就能说完:把当前正在执行的 effect(也就是 activeEffect),加进「这个对象的这个属性」对应的 Set 里。三层结构可能有缺层,它就沿着三层逐级查找,缺哪层建哪层:
// 简化自 packages/reactivity/src/dep.ts
function track(target, key) {
// activeEffect 为空:说明这次读取不在任何 effect 里,不用收集
if (!activeEffect) return;
// 第一层:找这个对象对应的 Map,没有就新建
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
// 第二层:找这个属性对应的 Set,没有就新建
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
// 第三层:把当前 effect 加进去(Set 自动去重)
dep.add(activeEffect);
}
整个函数就做一件事:「谁在执行期间读了我,就把谁记下来」。开头那句 if (!activeEffect) return 很关键:如果某次读取发生在 effect 之外,比如普通函数里随手打印一下 obj.name,说明没有人在「订阅」这个数据,直接跳过,不浪费内存。
🧱 trigger:逐个通知
有登记就有通知。这一节回答:数据被修改后,Vue 怎么把消息送到?这就是 set 拦截器里调用的 trigger。
trigger 是 track 的反向操作:查出「这个对象的这个属性」对应的 Set,把里面的 effect 逐个重新执行:
// 简化自 packages/reactivity/src/dep.ts
function trigger(target, key) {
// 第一层:找这个对象对应的 Map,没有说明没人依赖它
const depsMap = targetMap.get(target);
if (!depsMap) return;
// 第二层:找这个属性对应的 Set
const dep = depsMap.get(key);
if (!dep) return;
// 第三层:复制一份,逐个重新执行
new Set(dep).forEach((effect) => {
effect.run();
});
}
这里有个容易忽略的细节:遍历时先 new Set(dep) 复制一份。为什么要复制?因为 effect 重新执行时会再次读取数据,又会触发 track 往同一个 Set 里加记录。边遍历边往里加东西,可能导致死循环。复制一份再遍历,就不会出问题。
而「重跑时又触发 track」恰恰是这套系统的巧妙之处:每次重跑都会重新收集一次依赖,记录永远和最新的实际情况保持一致。
🧱 串起完整链路
前面几节分别讲了登记的人、登记的地方、登记的动作、通知的动作。这一节把它们和上一篇的 reactive 拼起来,组成一套能直接运行的迷你响应式系统:
// ===== 上一篇的 reactive(用 Proxy 拦截读写)=====
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver);
track(target, key); // 读取时:收集依赖
return result;
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key); // 修改时:触发更新
return result;
},
});
}
// ===== 本篇的 effect / track / trigger =====
const targetMap = new WeakMap(); // 存放所有依赖记录
let activeEffect = null; // 当前正在执行的 effect
function effect(fn) {
const _effect = {
fn,
run() {
try {
activeEffect = _effect; // 登记自己
return fn();
} finally {
activeEffect = null; // 执行完清空
}
},
};
_effect.run(); // 立即执行一次
return _effect;
}
function track(target, key) {
if (!activeEffect) return; // 没有正在执行的 effect,不用收集
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); // 把当前 effect 加进去
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (!dep) return;
new Set(dep).forEach((effect) => {
effect.run(); // 逐个重新执行
});
}
// ===== 实际使用 =====
const obj = reactive({ name: 'noxussj' });
effect(() => {
console.log('name 是:', obj.name);
});
// 打印:name 是: noxussj(effect 立即执行一次)
obj.name = '小明';
// 打印:name 是: 小明(trigger 通知 effect 自动重跑)
保存为 demo.js,用 Node.js v22 运行 node demo.js,你会看到 obj.name 一修改,effect 里的函数自动又跑了一遍。把这条链路串成一句话:effect 执行 → 读取 obj.name → get 拦截 → track 把 effect 记录下来 → 修改 obj.name → set 拦截 → trigger 按记录重跑 effect → 重跑时再次读取,完成新一轮记录。一轮闭环到此完成,之后每次修改都会自动重复这个过程。
effect。你在 Vue3 使用课程 里学到的 watchEffect(() => { ... }),本质就是「把你的函数交给 effect 管理」。⚠️ 嵌套 effect 与清理(了解即可)
上面的简化版还有两个真实场景没覆盖。这一节只需知道「有这回事、为什么要处理」,具体实现先不用记。
嵌套 effect。 一个 effect 的函数里可能又调用了 effect。这时 activeEffect 会被内层覆盖,内层执行完后,外层的值就丢了,后续的依赖就收不到了。真实源码会把每一层的 activeEffect 按顺序存起来(栈结构),执行完一层就恢复上一层。
分支切换时的清理。 看这段代码:
effect(() => {
// show 为 true 时读了 name,为 false 时不读
console.log(obj.show ? obj.name : 'hidden');
});
当 obj.show 从 true 变成 false,effect 重跑后不再读取 obj.name。此时 obj.name 再变化,就不该通知这个 effect 了。真实源码在每次重跑前,会先把这个 effect 从所有旧记录里清掉,然后边执行边重新登记,保证记录和当前分支一致。
🧾 小节总结
effect(fn)会立即执行fn,执行期间通过全局变量activeEffect暴露自己,让被读取的数据知道「谁在用我」。- 依赖存在
targetMap里,共三层:第一层按对象存(WeakMap,不挡垃圾回收),第二层按属性名存(Map,更新精确到属性),第三层存 effect 集合(Set,自动去重)。 track在读取时把activeEffect加进对应Set;trigger在修改时取出Set逐个重跑 effect,遍历前复制一份避免死循环。- 完整闭环:effect 执行 → 读取触发
track登记 → 修改触发trigger重跑 → 重跑时重新登记。 - 嵌套 effect 需要保存和恢复
activeEffect,分支切换需要先清理旧依赖,真实源码均已处理,了解即可。
❓ 知识问答
Q1:为什么第一层用 WeakMap 而不是普通 Map?
A:因为 key 是响应式对象本身。WeakMap 对 key 是弱引用,对象没人用了就会被垃圾回收自动清掉,依赖记录也跟着消失。普通 Map 会一直拽着这个对象不放,导致它永远无法释放,白白占用内存,也就是常说的内存泄漏。
Q2:为什么第三层用 Set 而不是数组?
A:为了去重。一个 effect 可能在函数里多次读取同一个属性,用数组会登记好几次,数据一变就重复执行好几次。Set 自动去重,保证一个 effect 只执行一次。
Q3:Vue3 的 effect 和 Vue2 的 Watcher 是什么关系?
A:可以理解成同一角色的两个版本:effect 大致对应 Watcher,targetMap 里的 Set 大致对应 Dep。Vue2 是每个属性存一份记录,Vue3 改成一张全局表统一查。职责没变,都是「数据变化时通知使用者重新执行」。
Q4:普通函数里读取响应式数据,会被收集依赖吗?
A:不会。track 做的第一件事就是判断 activeEffect 是否存在,只有 effect 正在执行期间的读取才会被记录。普通读取只是普通取值。
Q5:为什么 effect 每次重跑都要重新收集依赖?
A:因为依赖关系可能是动态的。比如 obj.show ? obj.name : 'hidden',条件不同,读到的属性就不同。每次重跑都清掉旧记录重新登记,通知才能永远对准当前真正用到的数据。
🧪 小练习
不查资料,补全下面三个函数,实现「修改 obj.name 后 effect 自动重跑」。用 Node.js v22 保存为 .js 文件直接运行验证:
const targetMap = new WeakMap();
let activeEffect = null;
function effect(fn) {
// 请在这里编写代码(提示:立即执行 fn,执行期间把 activeEffect 指向自己)
}
function track(target, key) {
// 请在这里编写代码(提示:按对象、属性名、Set 三层逐级查找或新建,最后把 activeEffect 加进 Set)
}
function trigger(target, key) {
// 请在这里编写代码(提示:查出对应属性的 Set,复制一份后逐个执行 effect.run)
}
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver);
track(target, key);
return result;
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key);
return result;
},
});
}
const obj = reactive({ name: 'noxussj', age: 18 });
effect(() => {
console.log('name 是:', obj.name);
});
// 期望输出:
// name 是: noxussj
// name 是: 小明
obj.name = '小明';
// 期望:修改 age 不会触发重跑(依赖精确到属性)
obj.age = 20;
完成后给自己加个挑战:在 effect 里同时读取 obj.name 和 obj.age,验证修改任意一个都会触发重跑,并想一想这两份依赖在 targetMap 里是怎么存的。
🎉 恭喜你已经掌握 effect 与依赖收集的核心原理啦!下一篇我们讲 ref 与 computed,看看基本类型没有 Proxy 可用时 Vue3 是怎么做响应式的,以及 computed 的缓存又是怎么实现的。
