Vue2 源码简介

computed 与 watch 的实现原理

理解 computed 缓存与 watch 监听的内部实现,弄清两者在源码层面的核心区别。

🎯 引言

computedwatch 是 Vue2 里使用频率很高的两个 API,平时我们用得很顺手,但源码里它们到底是怎么实现的?

这篇我们回到源码(Vue 2.7.16),弄清楚两件事:computed 的缓存是怎么做到的,以及 watch 的 immediate 和 deep 选项是怎么生效的。学完之后,你不仅能从源码角度解释这两个 API,还能给出有依据的选型建议。

本课程基于 vue@2.7.16 源码讲解,文中的迷你实现示例使用 Node.js v22 运行即可,无需额外安装依赖。

🧱 回顾:它们都是 Watcher

在第 4 篇我们讲过 Watcher:它是响应式系统里的"订阅者",负责求值、收集依赖、在依赖变化时收到通知。

computedwatch 并不是什么新机制,它们俩都是 Watcher,只是创建时传入的配置不同

  • computed:一个 lazy(惰性) 的 Watcher,不立刻求值,值会被缓存。
  • watch:一个 user(用户) 类型的 Watcher,依赖变化时执行用户传入的回调。

记住这句话,后面的源码就都顺了。


🧩 computed 的实现:lazy Watcher

组件初始化时会执行 initComputed,它给每一个 computed 属性创建一个 Watcher:

// 简化自 src/core/instance/state.js 的 initComputed
function initComputed(vm, computed) {
    const watchers = (vm._computedWatchers = Object.create(null));

    for (const key in computed) {
        const getter = computed[key];

        // 每个 computed 属性对应一个 lazy Watcher
        watchers[key] = new Watcher(vm, getter, noop, { lazy: true });

        if (!(key in vm)) {
            defineComputed(vm, key, getter);
        }
    }
}

关键点在 { lazy: true }。再看 Watcher 构造函数对这个选项的处理:

// 简化自 src/core/observer/watcher.js 的 constructor
constructor(vm, expOrFn, cb, options) {
    this.vm = vm;
    this.getter = expOrFn;
    this.lazy = options.lazy;
    this.dirty = this.lazy; // lazy Watcher 初始 dirty 为 true

    // lazy 时创建阶段不求值,value 先是 undefined
    this.value = this.lazy ? undefined : this.get();
}

普通 Watcher 创建时会立刻 this.get() 求值,而 lazy Watcher 创建时什么都不算,只把 dirty 标记为 true,意思是"我的值还没算,是脏的"。


🧠 缓存的本质:dirty 开关

initComputed 里的 defineComputed 会把 computed 属性挂载到 vm 上,访问 this.xxx 时实际走的是这个 get:

// 简化自 src/core/instance/state.js 的 defineComputed
function defineComputed(target, key, userDef) {
    Object.defineProperty(target, key, {
        get() {
            const watcher = target._computedWatchers[key];
            if (watcher) {
                if (watcher.dirty) {
                    watcher.evaluate(); // 只有 dirty 为 true 才求值
                }
                return watcher.value; // 否则直接返回缓存的值
            }
        },
        set() {},
    });
}

配套的 evaluate 方法:

// 简化自 src/core/observer/watcher.js
evaluate() {
    this.value = this.get(); // 真正执行 getter 求值
    this.dirty = false;      // 算完了,标记为干净
}

逻辑非常直白:

  • dirtytrue:执行 getter 算出值,缓存到 watcher.value,把 dirty 置为 false
  • dirtyfalse:跳过计算,直接返回 watcher.value

computed 的缓存,本质就是一个 dirty 布尔开关加一个 value 缓存。 第一次访问时算一次,之后只要依赖没变,访问多少次都直接返回缓存。


⚡ 缓存什么时候失效

缓存不能一直用下去,computed 依赖的响应式数据变化时,缓存必须失效。看 Watcher 的 update 方法(依赖变化时被 Dep 通知调用):

// 简化自 src/core/observer/watcher.js 的 update
update() {
    if (this.lazy) {
        // lazy Watcher 不立刻重算,只把 dirty 置回 true
        this.dirty = true;
    } else {
        this.run();
    }
}

注意这里的设计:依赖变化时并不立刻重新计算,只是把 dirty 置回 true,相当于贴了一张"值已过期"的标签。真正的重算发生在下次有人访问这个 computed 属性的时候(也就是上面 get 里的 evaluate)。

这样做的好处是:如果数据变了但模板这次根本没用到这个 computed,就一次多余的计算都不会发生。

对比一下 methods:模板里调用方法没有任何缓存,每次组件重新渲染,方法都会重新执行一遍。这就是"能用 computed 派生的值就不要用 methods"的源码依据。


🛠 watch 的实现:普通 Watcher + 回调

watch 选项在 initWatch 中被处理,最终都汇聚到 vm.$watch

// 简化自 src/core/instance/state.js 的 $watch
Vue.prototype.$watch = function (expOrFn, cb, options) {
    const vm = this;
    options = options || {};
    options.user = true; // 标记为用户 Watcher

    const watcher = new Watcher(vm, expOrFn, cb, options);

    if (options.immediate) {
        cb.call(vm, watcher.value); // immediate:拿到初始值立刻回调一次
    }

    return function unwatchFn() {
        watcher.teardown(); // 返回取消监听的函数
    };
};

核心就一行:new Watcher(vm, 监听的表达式, 回调)。创建 Watcher 时会对表达式求值(比如 'a.b' 会被解析成取 vm.a.b 的函数),求值过程触发响应式数据的 get,依赖就被收集进了这个 Watcher。

依赖变化时,Watcher 执行 run

// 简化自 src/core/observer/watcher.js 的 run
run() {
    const value = this.get();
    const oldValue = this.value;
    this.value = value;

    if (value !== oldValue || typeof value === 'object') {
        this.cb.call(this.vm, value, oldValue); // cb(newVal, oldVal)
    }
}

新旧值对比,有变化就调用用户回调,并把 (newVal, oldVal) 传进去。这就是 watch 回调两个参数的由来。


🔍 immediate 与 deep 是怎么做到的

immediate:创建后立刻执行一次

上面 $watch 的代码里已经看到了:immediate 没有任何魔法,就是创建 Watcher、拿到初始值之后,立刻用初始值调用一次回调。它等价于"watch + 初始化时手动执行一遍同样的逻辑"。

deep:递归遍历触发所有子属性的 get

deep 的处理在 Watcher 的 get 里:

// 简化自 src/core/observer/watcher.js 的 get
get() {
    pushTarget(this); // 把当前 Watcher 挂到 Dep.target
    let value = this.getter.call(this.vm, this.vm);

    if (this.deep) {
        traverse(value); // 深度遍历这个值
    }

    popTarget();
    return value;
}

traverse 会递归访问这个对象(或数组)的每一个子属性:

// 简化自 src/core/observer/traverse.js
function traverse(val, seen = new Set()) {
    if (typeof val !== 'object' || val === null || seen.has(val)) return;

    seen.add(val); // 用 Set 防止循环引用导致死循环

    if (Array.isArray(val)) {
        val.forEach((item) => traverse(item, seen));
    } else {
        Object.keys(val).forEach((key) => traverse(val[key], seen));
    }
}

为什么要递归"访问"一遍?因为响应式的 get 会触发依赖收集,把每个子属性读一遍,就能让所有子属性的 Dep 都把这个 Watcher 收集进去。之后任何一个深层属性变化,都能通知到这个 watch。

代价也很明显:对象越大、嵌套越深,递归遍历和依赖收集的开销就越大。所以 deep 适合监听结构不大的对象,监听大对象时更推荐直接监听具体路径(如 'form.name')。


⚖️ 选型建议

理解了实现,选型就不是背结论了:

  • 模板中由其他数据派生的值,用 computed。 它有缓存,依赖不变时重复访问不会重新计算,渲染性能更好。
  • 数据变化后要执行副作用,用 watch。 比如发异步请求、操作 localStorage、执行耗时操作。computed 的 getter 里不适合写副作用,它只应该做纯计算。

一句话区分:computed 是"算出一个值",watch 是"值变了做点事"。


🧾 小节总结

  • computed 和 watch 底层都是 Watcher,区别只在创建配置。
  • computed 对应 lazy Watcher:创建时不求值,首次访问才算,结果缓存在 watcher.value
  • 缓存的本质是 dirty 开关:依赖变化时把 dirty 置回 true,下次访问才重新计算,而不是立刻重算。
  • watch 就是 new Watcher(vm, 表达式, 回调),依赖变化时对比新旧值并执行 cb(newVal, oldVal)
  • immediate 是创建后立刻用初始值调一次回调;deep 是对值做 traverse 递归遍历,收集所有子属性的依赖,对象越大代价越高。

❓ 知识问答

Q1:computed 依赖的数据变了,为什么没有立刻重新计算?

A:lazy Watcher 收到通知时只把 dirty 置为 true,真正的重算推迟到下次访问这个属性时。这样避免了"算了却没人用"的浪费。

Q2:methods 和 computed 在模板里都能返回值,区别在哪?

A:computed 有缓存,依赖不变时重复访问直接返回缓存值;methods 没有缓存机制,组件每次重新渲染都会把方法重新执行一遍。

Q3:watch 一个对象,不加 deep 能监听到内部属性变化吗?

A:不能。不加 deep 时只收集了对象本身的依赖,替换整个对象才会触发;加上 deep 后 traverse 会把所有子属性的 Dep 都收集进来,修改深层属性也能触发。

Q4:watch 的回调里 newVal 和 oldVal 为什么有时是同一个对象?

A:监听的是引用类型且内部属性被修改时,新旧值指向同一个对象引用,所以两者内容相同。源码里对引用类型的判断 typeof value === 'object' 会让回调照常执行,但传进来的两个值是同一个引用。

Q5:immediate 和"watch 外加初始化时手动调一次"有区别吗?

A:本质上没有区别,immediate 就是创建 Watcher 拿到初始值后立刻执行一次回调,相当于把这个常用模式内置成了一个选项。


🧪 小练习

参照本篇讲的 dirty 缓存思路,用第 4 篇的迷你 Dep / Watcher,补全一个带缓存的 computed 函数,并用 Node.js v22 运行验证:依赖不变时重复访问不重复执行 getter。

mini-computed.js
// 迷你响应式:带 dirty 缓存的 computed
class Dep {
    constructor() {
        this.subs = [];
    }
    depend() {
        if (Dep.target && !this.subs.includes(Dep.target)) {
            this.subs.push(Dep.target);
        }
    }
    notify() {
        this.subs.forEach((w) => w.update());
    }
}
Dep.target = null;

class Watcher {
    constructor(getter, { lazy = false } = {}) {
        this.getter = getter;
        this.lazy = lazy;
        this.dirty = lazy;
        this.value = lazy ? undefined : this.get();
    }
    get() {
        Dep.target = this;
        const value = this.getter();
        Dep.target = null;
        return value;
    }
    evaluate() {
        // 请在这里编写代码:求值并重置 dirty
    }
    update() {
        // 请在这里编写代码:lazy 时只标记 dirty 为 true
    }
}

function defineReactive(obj, key, val) {
    const dep = new Dep();
    Object.defineProperty(obj, key, {
        get() {
            dep.depend();
            return val;
        },
        set(newVal) {
            val = newVal;
            dep.notify();
        },
    });
}

function computed(vm, key, getter) {
    const watcher = new Watcher(() => getter.call(vm), { lazy: true });
    Object.defineProperty(vm, key, {
        get() {
            // 请在这里编写代码:dirty 为 true 才 evaluate,最后返回 watcher.value
        },
    });
}

// 验证
const state = { count: 1 };
defineReactive(state, 'count', 1);

const vm = { state };
computed(vm, 'double', function () {
    console.log('getter 执行了');
    return this.state.count * 2;
});

console.log(vm.double); // getter 执行了 → 2
console.log(vm.double); // 命中缓存,不再执行 getter → 2
state.count = 10; // 触发 notify,dirty 置回 true
console.log(vm.double); // 重新计算 → 20

🎉 恭喜你已经掌握了 computed 与 watch 的实现原理!从 lazy Watcher 到 dirty 缓存,再到 immediate 和 deep,这些 API 背后的机制其实并不神秘,都是 Watcher 体系的不同配置而已。