面试官会问的 Vue3 computed 的原理是什么

106 阅读4分钟

一文吃透 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,否则丢响应性。