computed 与 watch 的实现原理
🎯 引言
computed 和 watch 是 Vue2 里使用频率很高的两个 API,平时我们用得很顺手,但源码里它们到底是怎么实现的?
这篇我们回到源码(Vue 2.7.16),弄清楚两件事:computed 的缓存是怎么做到的,以及 watch 的 immediate 和 deep 选项是怎么生效的。学完之后,你不仅能从源码角度解释这两个 API,还能给出有依据的选型建议。
vue@2.7.16 源码讲解,文中的迷你实现示例使用 Node.js v22 运行即可,无需额外安装依赖。🧱 回顾:它们都是 Watcher
在第 4 篇我们讲过 Watcher:它是响应式系统里的"订阅者",负责求值、收集依赖、在依赖变化时收到通知。
computed 和 watch 并不是什么新机制,它们俩都是 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; // 算完了,标记为干净
}
逻辑非常直白:
dirty为true:执行 getter 算出值,缓存到watcher.value,把dirty置为false。dirty为false:跳过计算,直接返回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。
// 迷你响应式:带 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 体系的不同配置而已。
