computed 在类属性中的缓存陷阱
问题描述
在类中通过属性赋值的方式使用 computed:
class DemoService {
x = 1;
computedX = computed(() => this.x);
}当实例被 reactive() 包装后,computedX 无法响应 x 的变更。
根本原因
this 指向问题
computedX = computed(() => this.x) 在构造函数执行时创建 computed,此时 this 绑定到原始对象(非 reactive 代理)。因此 computed 内部读取的 this.x 是普通属性访问,不会触发 reactive 的 get trap,computed 收集不到任何响应式依赖。
Vue 3.5+ 的 NO_DIRTY_CHECK 机制
Vue 3.5+ 的 ComputedRefImpl 内部有一个 flags 字段,用于控制缓存策略:
| flags 值 | 含义 | 说明 |
|---|---|---|
| 16 | TRACKING | 初始状态,computed 尚未求值 |
| 128 | NO_DIRTY_CHECK | computed 求值后发现没有收集到任何响应式依赖,被标记为"不需要脏检查" |
当 computed 第一次求值后发现 deps 为空(没有收集到任何响应式依赖),Vue 会将 flags 设置为 128(NO_DIRTY_CHECK)。此后所有访问直接返回缓存值,不再检查 globalVersion,也不再重新计算。
computed 生命周期:
创建 → flags = 16 (TRACKING)
第一次求值(无响应式依赖)→ flags = 128 (NO_DIRTY_CHECK)
后续访问 → 直接返回缓存,永不更新实验验证
import { reactive, computed, toRaw } from 'vue';
class DemoService {
x = 1;
computedX = computed(() => this.x);
}
const t = new DemoService();
const rt = reactive(t);
const rawC = toRaw(rt).computedX;
// 访问前
console.log(rawC.effect.flags); // 16 (TRACKING)
console.log(rawC.effect.globalVersion); // -1(未初始化)
// 第一次通过 reactive 访问(触发求值)
console.log(rt.computedX); // 1
// 访问后
console.log(rawC.effect.flags); // 128 (NO_DIRTY_CHECK)
// computed 被永久锁定,后续修改 rt.x 不会影响 rt.computedX关键结论:computedX 永远不会真正响应式更新
computed(() => this.x) 作为类属性使用时,this 绑定到原始对象,computed 永远无法追踪到响应式变化。这不是一个"有时能更新有时不能"的问题,而是一个"永远不能真正响应式更新"的问题。
之前观察到的某些场景下"能更新"的现象,完全是延迟求值的时序巧合——如果修改发生在 computed 第一次求值之前,第一次求值时原始对象的值恰好已经被修改了。
延迟求值假象的示例
const t = new DemoService();
const rt = reactive(t);
// ✅ 看起来能更新(假象)
rt.x = 42; // 先修改,此时 computed 还没求值过
console.log(rt.computedX); // 42 — 第一次求值,this.x 已经是 42
// ❌ 但再次修改就不更新了
rt.x = 100; // 再修改
console.log(rt.computedX); // 42 — NO_DIRTY_CHECK 锁定,返回缓存const t = new DemoService();
const rt = reactive(t);
// ❌ 先访问就锁定了
console.log(rt.computedX); // 1 — 第一次求值,flags 变为 NO_DIRTY_CHECK
rt.x = 42; // 修改
console.log(rt.computedX); // 1 — 永远返回缓存完整的行为矩阵
| 场景 | 修改前是否访问过 computedX | 结果 | 原因 |
|---|---|---|---|
| 纯 reactive,先访问再修改 | ✅ 是 | ❌ 不更新 | NO_DIRTY_CHECK 锁定 |
| 纯 reactive,先修改再访问 | ❌ 否 | ✅ 看起来更新了 | 延迟求值的时序巧合 |
| 组件模板渲染了 computedX | ✅ 是(渲染时访问) | ❌ 不更新 | NO_DIRTY_CHECK 锁定 |
| 组件模板未渲染 computedX,但代码中先访问 | ✅ 是 | ❌ 不更新 | NO_DIRTY_CHECK 锁定 |
| 组件模板未渲染 computedX,先修改再访问 | ❌ 否 | ✅ 看起来更新了 | 延迟求值的时序巧合 |
与 getter 中 computed 的对比
getter 中的 computed(() => this.x) 行为不同,因为 getter 在 reactive 代理上调用时 this 指向代理对象:
class DemoService {
x = 1;
// 属性赋值:this 指向原始对象,computed 无法追踪依赖
computedX = computed(() => this.x);
// getter:this 指向 reactive 代理,computed 能正确追踪依赖
get getComputedX() {
return computed(() => this.x);
}
}但 getter 中每次访问都会创建新的 computed 实例,存在性能问题。
类型不一致问题
除了缓存陷阱之外,在类属性上直接使用 computed 还有一个类型层面的问题:
class DemoService {
x = 1;
computedX = computed(() => this.x); // 类型是 ComputedRef<number>
}computedX 的 TypeScript 类型是 ComputedRef<number>,但当实例被 reactive() 包装后,this.computedX 实际上会被自动解包为 number 类型。这导致类型声明与运行时行为不一致:
- 编辑器中
this.computedX提示类型为ComputedRef<number>,IDE 会建议使用.value访问 - 但运行时
this.computedX已经被reactive自动解包,实际是number,直接使用.value反而会报错
这种类型不一致会在编辑器中误导开发者,增加心智负担。
对应的单元测试
详细的测试用例见 tests/test23 目录:
测试覆盖了以下场景:
- 纯 Vue API 层面的行为验证(普通对象 vs reactive 对象)
- NO_DIRTY_CHECK 锁定行为(先访问 vs 不先访问)
- 延迟求值的时序巧合验证
- 组件渲染对 computed 更新的影响
- DI 框架注入后的完整行为验证
结论:应使用 @Computed 装饰器
综合以上两个问题(缓存陷阱 + 类型不一致),在类属性上直接使用 computed 不是一个可靠的方案。应该使用 @Computed 装饰器来代替:
class DemoService {
x = 1;
// ❌ 不推荐:缓存陷阱 + 类型不一致
computedX = computed(() => this.x);
// ✅ 推荐:使用 @Computed 装饰器
@Computed()
get computedX() {
return this.x;
}
}@Computed 装饰器的优势:
- getter 的返回类型就是原始类型,类型声明与运行时行为一致
- 在 getter 首次调用时才创建
computed,此时this已是reactive代理,能正确追踪响应式依赖 - 通过 Symbol key 缓存
computed实例,避免重复创建