依赖收集与派发更新:Dep 与 Watcher
🎯 引言
上一篇我们用 defineReactive 给属性加上了 get / set 拦截,数据一改就能"感知到"。但感知到之后呢?改了一个数据,Vue 是怎么精确找到需要更新的视图的?
学完这篇,你就能完整回答这个问题。我们会认识响应式系统里的另外两个主角:Dep 负责管理"谁在用这个数据",Watcher 代表"一个需要更新的地方"。理解了它们的分工,依赖收集和派发更新这两条链路就自然串起来了。
🪤 问题引入:到底该通知谁
回忆上一篇的 set 拦截:
set(newVal) {
if (newVal === val) return;
val = newVal;
// 触发更新……但更新谁?
}
这里留下了一个悬念:一个属性可能被页面上多个地方用到。比如 data.name,可能头部组件在显示它,侧边栏组件也在显示它。name 变了,这两处都要更新;而完全没用到 name 的组件,一行代码都不应该动。
所以每个属性需要维护一份"订阅名单":谁在用我,我就记下来;我变了,按名单逐个通知。这份名单就是 Dep,名单上的每个订阅者就是 Watcher。
💡 生活化比喻:报社与订报人
用订报纸来理解这组关系会很直观:
- Dep 像报社的订阅名单:报社(属性)手里有一份名单,记着所有订报人。
- Watcher 像订报的读者:每个读者(组件、计算属性)都订了若干份报纸。
- 报纸更新时:报社不会挨家挨户敲门,而是按名单把新报纸送到订报人手里。
还有一个巧妙的设计:订报这个动作是"自动"完成的。读者读报(访问属性)的那一刻,报社顺手就把他的名字记上了名单,这就是依赖收集。读者也会记下自己订了哪些报社,方便以后退订,这就是 Watcher 记录 Dep。
🧱 Dep 类:属性的订阅名单
Dep 定义在 src/core/observer/dep.js,核心结构非常精简(简化自 Vue 2.7.16 源码,去掉了边界分支):
// 简化自 src/core/observer/dep.js
let uid = 0;
export default class Dep {
constructor() {
this.id = uid++;
this.subs = []; // 订阅者名单(Watcher 数组)
}
addSub(sub) {
this.subs.push(sub);
}
depend() {
if (Dep.target) {
Dep.target.addDep(this);
}
}
notify() {
const subs = this.subs.slice();
for (let i = 0; i < subs.length; i++) {
subs[i].update();
}
}
}
Dep.target = null; // 当前正在求值的 Watcher
逐个方法看:
- subs:订阅者数组,这个属性被哪些 Watcher 依赖,就存哪些。
- addSub:把一个 Watcher 加进名单。
- depend:依赖收集的入口。注意它不直接
addSub,而是反过来调用Dep.target.addDep(this),让 Watcher 主动来登记,原因在下一节讲。 - notify:数据变了,遍历名单,逐个调用 Watcher 的
update()。
最特殊的是 Dep.target:它是一个静态属性,全应用共享,指向"当前正在求值的 Watcher"。同一时间只有一个 Watcher 在求值,所以用一个全局指针就够了。它是连接 Dep 和 Watcher 的桥梁。
🧱 Watcher 类:一个需要更新的地方
Watcher 定义在 src/core/observer/watcher.js。组件渲染、计算属性、用户侦听器,底层都是 Watcher。先看简化版核心:
// 简化自 src/core/observer/watcher.js
export default class Watcher {
constructor(vm, expOrFn) {
this.vm = vm;
this.deps = []; // 自己订阅了哪些 Dep
this.getter = expOrFn; // 求值函数,如渲染函数
this.value = this.get();
}
get() {
pushTarget(this); // Dep.target = 当前 Watcher
const value = this.getter.call(this.vm, this.vm);
popTarget(); // 求值结束,恢复 Dep.target
return value;
}
addDep(dep) {
if (!this.deps.includes(dep)) {
this.deps.push(dep);
dep.addSub(this); // 同时让 Dep 记住自己
}
}
update() {
this.run();
}
run() {
this.value = this.get(); // 重新求值,触发重新渲染
}
}
关键在 get() 里的三步:
pushTarget(this):把全局的Dep.target设为自己,相当于宣布"现在开始是我在求值"。- 执行求值函数:比如执行渲染函数。渲染过程会访问
data.name这样的属性,从而触发属性的get拦截。 popTarget():求值结束,恢复Dep.target。
addDep 则负责登记:Watcher 记住自己订阅了哪些 Dep,同时让 Dep 把自己加进名单。登记是双向的,这为卸载时的清理和去重提供了基础。
⚡ 依赖收集:求值时自动登记
现在把 Dep、Watcher 和上一篇的 defineReactive 拼起来。defineReactive 的 get 里其实藏着一行关键代码:
// 简化自 src/core/observer/index.js 的 defineReactive
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.depend(); // 当前有 Watcher 在求值,把它收集进来
}
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
dep.notify(); // 变了,通知所有订阅者
},
});
一次完整的依赖收集,按顺序是这样发生的:
- 创建渲染 Watcher:组件挂载时,Vue 为它创建一个 Watcher,求值函数就是渲染函数。
- 开始求值:
get()里Dep.target被设为当前 Watcher。 - 访问数据:渲染函数执行到
data.name,触发name的get拦截。 - 登记:
get里发现Dep.target存在,调用dep.depend(),于是name的 Dep 记住了这个 Watcher,Watcher 也记住了这个 Dep。 - 求值结束:
popTarget()恢复现场。渲染函数访问过多少属性,就完成了多少次登记。
整个过程不需要手动声明任何依赖:模板里用到谁,求值时就自动收集谁。这就是"依赖收集"四个字的含义。
⚡ 派发更新:按名单逐个通知
有了名单,派发更新就是水到渠成的事。当我们执行:
this.name = '新名字';
链路是这样的:
- 触发
set:name的set拦截发现值变了。 - 调用
dep.notify():name的 Dep 拿出自己的订阅名单。 - 逐个
update():名单上的每个 Watcher 收到通知,本篇的简化版本里直接run(),重新执行求值函数,完成重新渲染。
没用到的组件不在这份名单上,自然不会被惊动。精确更新就是这么来的。
update() 并不会立刻重新渲染,而是把 Watcher 推进一个队列,等本轮同步代码执行完再统一刷新,避免连续改多个数据导致重复渲染。队列异步更新的细节我们放到下一篇专门讲。⚖️ 多对多关系小结
Dep 和 Watcher 之间是多对多的关系,两个方向都要记住:
- 一个 Watcher 依赖多个属性:渲染函数里用到了
name、age,它就同时出现在两个 Dep 的名单里。 - 一个属性被多个 Watcher 依赖:
name被头部组件和侧边栏组件同时使用,它的 Dep 名单里就有多个 Watcher。
正因为是多对多,双方才都要互相登记:Dep 存 subs,Watcher 存 deps。组件卸载时,Watcher 就能拿着自己的 deps 列表,逐个把自己从对方的 subs 里移除,避免无用的通知。
🧾 小节总结
- Dep 是属性的"订阅名单",
subs存订阅者,depend负责收集,notify负责通知。 - Watcher 代表"一个需要更新的地方",求值前把
Dep.target设为自己,求值中访问属性即被收集。 - 依赖收集发生在读数据时:渲染函数访问属性触发
get,自动完成登记。 - 派发更新发生在写数据时:
set触发notify,按名单逐个调用update。 - Dep 与 Watcher 是多对多关系,双方互相登记,卸载时可精确清理。
❓ 知识问答
Q1:为什么 depend 里不直接 dep.addSub(Dep.target),而是绕一圈调 Dep.target.addDep(this)?
A:为了让 Watcher 也能记住自己订阅了哪些 Dep。由 Watcher 发起登记,可以同时维护自己的 deps 列表,并顺便去重,组件卸载时的清理也依赖这份列表。
Q2:Dep.target 为什么用静态属性而不是传参?
A:求值函数(如渲染函数)是 Vue 调用业务代码,中间会层层嵌套访问很多属性。用全局指针,任何深度的 get 拦截都能拿到"当前 Watcher",不用一路透传参数,代码简洁很多。同一时间只有一个 Watcher 在求值,也保证了安全性。
Q3:一个属性根本不修改,也会被收集依赖吗?
A:会。依赖收集只和"读取"有关,只要渲染时访问了就会登记。登记成本低,而后续如果真的修改了,就能立刻找到需要更新的人。
Q4:this.name = 'x' 之后页面是立刻更新的吗?
A:不是。真实 Vue 会把 Watcher 放进异步队列,等同步代码跑完再统一刷新,所以修改数据后马上读 DOM 读到的还是旧值,这也是为什么有 this.$nextTick。本篇的简化代码是同步执行,方便理解主链路。
🧪 小练习
在上一篇的迷你 defineReactive 基础上,补上迷你版 Dep 和 Watcher,让"修改数据后自动重新执行回调"跑起来。本地环境:Node.js v22,对照源码版本 vue@2.7.16,代码可直接用 node 文件名.js 运行。
// 简化版 defineReactive(来自上一篇)
function defineReactive(obj, key, val) {
const dep = new Dep();
Object.defineProperty(obj, key, {
get() {
if (Dep.target) {
dep.depend();
}
return val;
},
set(newVal) {
if (newVal === val) return;
val = newVal;
dep.notify();
},
});
}
class Dep {
// 请在这里编写代码:subs 数组、addSub、depend、notify
}
class Watcher {
// 请在这里编写代码:构造时调用 get(),get 里设置 Dep.target 再执行回调
}
// 验证:回调应打印两次,第二次拿到新值
const data = {};
defineReactive(data, 'name', '张三');
new Watcher(() => console.log('name 变成了:', data.name));
data.name = '李四';
提示:Dep 类约 15 行,Watcher 类约 15 行,加上静态属性 Dep.target = null 即可。写完后运行,第二次打印应该是 name 变成了:李四。
🎉 恭喜你已经掌握 Dep 与 Watcher 的分工啦!响应式的主链路已经完整:拦截感知变化、Dep 管名单、Watcher 管更新。下一篇我们看 Vue 如何用异步队列把多次修改合并成一次渲染,把性能再往上推一步。
