Vue2 源码简介

依赖收集与派发更新:Dep 与 Watcher

理解 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() 里的三步:

  1. pushTarget(this):把全局的 Dep.target 设为自己,相当于宣布"现在开始是我在求值"。
  2. 执行求值函数:比如执行渲染函数。渲染过程会访问 data.name 这样的属性,从而触发属性的 get 拦截。
  3. popTarget():求值结束,恢复 Dep.target

addDep 则负责登记:Watcher 记住自己订阅了哪些 Dep,同时让 Dep 把自己加进名单。登记是双向的,这为卸载时的清理和去重提供了基础。


⚡ 依赖收集:求值时自动登记

现在把 Dep、Watcher 和上一篇的 defineReactive 拼起来。defineReactiveget 里其实藏着一行关键代码:

// 简化自 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(); // 变了,通知所有订阅者
    },
});

一次完整的依赖收集,按顺序是这样发生的:

  1. 创建渲染 Watcher:组件挂载时,Vue 为它创建一个 Watcher,求值函数就是渲染函数。
  2. 开始求值get()Dep.target 被设为当前 Watcher。
  3. 访问数据:渲染函数执行到 data.name,触发 nameget 拦截。
  4. 登记get 里发现 Dep.target 存在,调用 dep.depend(),于是 name 的 Dep 记住了这个 Watcher,Watcher 也记住了这个 Dep。
  5. 求值结束popTarget() 恢复现场。渲染函数访问过多少属性,就完成了多少次登记。

整个过程不需要手动声明任何依赖:模板里用到谁,求值时就自动收集谁。这就是"依赖收集"四个字的含义。


⚡ 派发更新:按名单逐个通知

有了名单,派发更新就是水到渠成的事。当我们执行:

this.name = '新名字';

链路是这样的:

  1. 触发 setnameset 拦截发现值变了。
  2. 调用 dep.notify()name 的 Dep 拿出自己的订阅名单。
  3. 逐个 update():名单上的每个 Watcher 收到通知,本篇的简化版本里直接 run(),重新执行求值函数,完成重新渲染。

没用到的组件不在这份名单上,自然不会被惊动。精确更新就是这么来的。

真实的 Vue 里 update() 并不会立刻重新渲染,而是把 Watcher 推进一个队列,等本轮同步代码执行完再统一刷新,避免连续改多个数据导致重复渲染。队列异步更新的细节我们放到下一篇专门讲。

⚖️ 多对多关系小结

Dep 和 Watcher 之间是多对多的关系,两个方向都要记住:

  • 一个 Watcher 依赖多个属性:渲染函数里用到了 nameage,它就同时出现在两个 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 如何用异步队列把多次修改合并成一次渲染,把性能再往上推一步。