Skip to content

computed 在类属性中的缓存陷阱

问题描述

在类中通过属性赋值的方式使用 computed

ts
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 值含义说明
16TRACKING初始状态,computed 尚未求值
128NO_DIRTY_CHECKcomputed 求值后发现没有收集到任何响应式依赖,被标记为"不需要脏检查"

当 computed 第一次求值后发现 deps 为空(没有收集到任何响应式依赖),Vue 会将 flags 设置为 128(NO_DIRTY_CHECK)。此后所有访问直接返回缓存值,不再检查 globalVersion,也不再重新计算。

computed 生命周期:
  创建 → flags = 16 (TRACKING)
  第一次求值(无响应式依赖)→ flags = 128 (NO_DIRTY_CHECK)
  后续访问 → 直接返回缓存,永不更新

实验验证

ts
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 第一次求值之前,第一次求值时原始对象的值恰好已经被修改了。

延迟求值假象的示例

ts
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 锁定,返回缓存
ts
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 指向代理对象:

ts
class DemoService {
  x = 1;

  // 属性赋值:this 指向原始对象,computed 无法追踪依赖
  computedX = computed(() => this.x);

  // getter:this 指向 reactive 代理,computed 能正确追踪依赖
  get getComputedX() {
    return computed(() => this.x);
  }
}

但 getter 中每次访问都会创建新的 computed 实例,存在性能问题。

类型不一致问题

除了缓存陷阱之外,在类属性上直接使用 computed 还有一个类型层面的问题:

ts
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 装饰器来代替:

ts
class DemoService {
  x = 1;

  // ❌ 不推荐:缓存陷阱 + 类型不一致
  computedX = computed(() => this.x);

  // ✅ 推荐:使用 @Computed 装饰器
  @Computed()
  get computedX() {
    return this.x;
  }
}

@Computed 装饰器的优势:

  1. getter 的返回类型就是原始类型,类型声明与运行时行为一致
  2. 在 getter 首次调用时才创建 computed,此时 this 已是 reactive 代理,能正确追踪响应式依赖
  3. 通过 Symbol key 缓存 computed 实例,避免重复创建