一文吃透 Vue3 的 computed:依赖收集、惰性缓存与调度
本文面向有一定 Vue3/Pinia 经验的同学,系统讲清 computed 的底层原理、与 watch/watchEffect 的差异、常见坑与最佳实践,并给出简化实现思路,覆盖面试常问点。
computed 是什么(特性概览)
- 派生值:由一个或多个响应式源推导出的值。
- 惰性求值:只有“被读取”时才计算。
- 结果缓存:依赖未变化时返回上次缓存,避免重复计算。
- 精准失效:依赖变化时标记为脏(dirty),下次读取才重算。
- 可写形式:支持 get/set,常用于表单双向绑定。
底层原理:track/trigger + lazy effect + scheduler
- 依赖收集(track)
- 执行 computed 的 getter 时,当前活动副作用(effect runner)被设置为“正在运行”。
- 在 getter 内读取任意响应式属性(如 store.isOmniBus),其 Proxy get 会调用 track,把“属性 → 副作用”的依赖关系登记起来。
- 触发更新(trigger)
- 当这些属性后续被 set 修改,Proxy set 会调用 trigger,找到依赖它们的副作用。
- 对 computed 来说不是立刻执行,而是把它标记为脏(dirty)并通过 scheduler 通知下游订阅者(如组件渲染副作用)。
- 惰性与缓存
- computed 内部是 lazy: true 的 effect。
- 首次读取(或 dirty 后的下一次读取)才真正执行 getter,并缓存结果。
- 未 dirty 时重复读取只是返回缓存。
- 调度(scheduler)
- 依赖变更时不立即重算 computed,而是把组件渲染副作用加入更新队列(微任务/nextTick),下一轮渲染再读取 computed,若发现 dirty 才重算并更新 DOM。
模板里为什么“立刻”更新
- 模板渲染会读取 computed,渲染副作用成为它的订阅者。
- 依赖变化 → 标记 dirty → 触发组件更新调度 → 下一轮渲染读取 computed,发现 dirty → 重算 → DOM patch。视觉上“立即”更新,实为下一次渲染周期。
与 watch / watchEffect 的对比(怎么选)
- computed:产出“派生值”,懒执行、带缓存;适合纯计算、供模板/其他计算读取。
- watchEffect:立即执行副作用函数,内部自动收集依赖;适合快速响应一组隐式依赖。
- watch:显式指定源(ref/getter/数组),变更时触发回调;适合异步、I/O、副作用,不产出新值。
Pinia 场景与解构陷阱
- 在 computed 回调 内部 读取 sexStore.isMan 等,依赖可被正确收集,变化会引发重算。
- 顶层作用域外部 提前解构:会丢失响应性(拿到的是当下的值,不再更新)。
- 如果确需解构,请用 storeToRefs(sexStore) 保持为 ref。
// 正确 回调 内部 读取 sexStore.isMan
const sexStore = useSexStore();
const hasSubAccount = computed(() => {
return sexStore.isMan
});
// 正确 回调 内部 读取 sexStore.isMan
const sexStore = useSexStore();
const hasSubAccount = computed(() => {
const { isEnterprise: e, isMan: m } =sexStore;
return e || m
});
// 正确:用 storeToRefs 保持响应性
const { isEnterprise, isMan } = storeToRefs(sexStore);
const hasSubAccount2 = computed(() => {
return isEnterprise.value || isMan.value;
});
// 错误:外部顶层提前解构,丢失响应性
const { isEnterprise: e, isMan: m } = sexStore(); // ❌ 非 ref
const broken = computed(() => e || m);
可写 computed(get/set)
const firstName = ref('Ada');
const lastName = ref('Lovelace');
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: (val: string) => {
const [first, ...rest] = val.split(' ');
firstName.value = first || '';
lastName.value = rest.join(' ');
}
});
// v-model="fullName" 时,set 会被调用
常见坑与最佳实践
- 只做纯计算,不做副作用(网络请求、修改外部状态等)。
- 复杂昂贵计算放 computed 可享受缓存;一次性轻量计算可用普通函数。
- 嵌套 computed 时注意依赖边界,避免无谓联动。
- 模板中频繁遍历的大列表尽量把复杂表达式提到 computed,减轻渲染开销。
- Pinia 中:避免外部解构 getters/state;必要时 storeToRefs。
面试高频问答清单
- computed 的依赖是怎么收集的?
- getter 执行时读取响应式属性触发 track,记录属性 → 副作用。
- 为什么说 computed 是惰性的?
- 内部 effect 标记 lazy: true,只有读取它的值时才执行 getter。
- 为什么需要缓存?
- 避免重复计算,直到依赖变化才标脏,下一次读取重算。
- 模板为什么能“立刻”响应?
- 渲染副作用订阅 computed;依赖变化触发调度,下一轮渲染读取 computed 发现 dirty 后重算并 patch。
- 与 watch/watchEffect 区别?
- 是否产出新值、是否懒执行、是否缓存、是否适合副作用。
“类实现”级伪代码(讲原理一图流)
// 极简 computed 思路(忽略类型与边界)
function computed(getterOrOptions: Function | { get: Function; set?: Function }) {
const getter = typeof getterOrOptions === 'function' ? getterOrOptions : getterOrOptions.get;
const setter = typeof getterOrOptions === 'function' ? (v: unknown) => {} : (getterOrOptions.set || ((v: unknown) => {}));
let cachedValue: unknown;
let dirty = true;
const runner = effect(getter, {
lazy: true,
scheduler: () => {
if (!dirty) {
dirty = true;
trigger(holder, 'value'); // 通知订阅者(如渲染副作用)
}
}
});
const holder = {
get value() {
track(holder, 'value'); // 允许外部依赖该 computed
if (dirty) {
cachedValue = runner(); // 真正执行 getter,期间完成依赖收集
dirty = false;
}
return cachedValue;
},
set value(v: unknown) {
setter(v);
}
};
return holder;
}
结语
- computed 的本质:lazy effect + 缓存 + scheduler。
- 记住两条实践金句:
- 读取发生在 computed 内部即可被依赖收集;
- 外部解构需要 storeToRefs,否则丢响应性。