跳到主要内容

computed 的原理

computed 是 Vue 面试里非常高频的一道题,但很多回答会停在“它有缓存”这一层。这个结论没错,但如果继续追问:

  • 为什么它能缓存?
  • 它什么时候重新计算,什么时候不算?
  • 它和 methodswatch 的本质差异是什么?
  • 为什么说 computed 是“懒执行”的?
  • Vue 2 和 Vue 3 的实现思路有什么区别?

如果这几层答不出来,说明还没有真正吃透。

面试速答(30 秒版 TL;DR)

  • computed 的本质是:基于响应式 effect 封装出来的“带缓存的派生值”
  • 它默认是懒执行的:不是依赖一变就立刻重新算,而是先把自己标记为“脏”,等下次有人读取时再计算。
  • 它之所以能缓存,是因为内部会保存上一次计算结果,并配合 dirty 标记判断要不要重算。
  • 它之所以能响应更新,是因为 getter 执行时会收集依赖;依赖变化后,调度器只负责把它标记为 dirty,并通知依赖它的副作用重新读取。
  • 一句话记忆:computed = lazy effect + dependency tracking + dirty flag + cached value

先记主链路:第一次读取 -> 执行 getter -> 收集依赖 -> 缓存结果;依赖变化时只标脏,不立刻重算。

先背一句

computed 不是“数据一变立刻重算”,而是“数据一变先标脏,等你下次读时再重算”。

1. computed 解决的到底是什么问题?

它解决的是:某个值不是原始状态,而是由其他响应式状态同步推导出来,并且这个推导结果会被反复读取。

例如:

import { ref, computed } from 'vue'

const price = ref(100)
const count = ref(2)

const total = computed(() => price.value * count.value)

这里 total 不是源数据,而是派生数据。

这种场景如果不用 computed,常见有两种低质量写法:

  • 在模板里直接写复杂表达式,导致渲染时重复计算
  • watch 手动维护一个结果值,代码更绕,也更容易出错

所以 computed 的定位很明确:

  • 输入:一组响应式依赖
  • 输出:一个同步可读取、可缓存的派生值

2. 为什么说它是“懒执行”的?

看一个最小例子:

const count = ref(1)

const double = computed(() => {
console.log('computed run')
return count.value * 2
})

count.value = 2
count.value = 3

如果这时你从来没有读取过 double.value,控制台不会打印任何东西。

原因是:

  • 创建 computed 时,只是把 getter 包装起来
  • 并不会立刻执行 getter
  • 只有第一次有人读取 double.value,它才真正计算

这就是“懒执行”。

所以 computedwatchEffect 的一个核心区别就是:

  • computed:先不跑,等读的时候再跑
  • watchEffect:创建后立即执行

3. 为什么它能缓存?

缓存的关键不在“Vue 很聪明”,而在于内部有两个状态:

  • 一个保存上一次结果的 value
  • 一个表示缓存是否失效的 dirty

可以抽象成下面的伪代码:

let value: unknown
let dirty = true

const runner = new ReactiveEffect(getter, () => {
if (!dirty) {
dirty = true
trigger(computedRef, 'value')
}
})

function get value() {
track(computedRef, 'value')

if (dirty) {
value = runner.run()
dirty = false
}

return value
}

这里有三层关键点:

  1. getter 真正执行时,才会读取依赖并完成依赖收集
  2. 依赖变化后,不直接重新执行 getter,而是先把 dirty 设为 true
  3. 下次读取 computed.value 时,如果发现 dirty === true,才重新计算并覆盖缓存

所以“缓存”不是一个独立能力,而是 懒执行 + 标脏重算 共同实现出来的。

4. 依赖变化后,为什么不是立刻重算?

这是 computed 和很多人直觉最不一样的地方。

依赖变化时,内部调度器干的事通常只有两步:

  1. 把当前 computed 标记为脏
  2. 通知“谁依赖了这个 computed”,让它们在合适时机重新取值

例如:

const count = ref(1)
const double = computed(() => count.value * 2)

watchEffect(() => {
console.log(double.value)
})

流程是:

  1. watchEffect 首次执行,读取 double.value
  2. double 发现自己是脏的,于是执行 getter,拿到结果并缓存
  3. count.value 变化
  4. double 不会马上重新算,只会把自己标脏
  5. 因为 watchEffect 依赖了 double.value,所以它会被重新调度
  6. watchEffect 再次执行时重新读取 double.value
  7. 此时 double 才真正重算

这套设计的好处是:

  • 避免依赖频繁变化时反复无意义重算
  • 只有“结果真的被消费”时才付出计算成本

5. computed 为什么能参与响应式链路?

很多人以为 computed 只是一个“带缓存的函数”,其实它本身也是响应式系统里的一个节点。

你可以把它理解成:

  • 上游:它依赖其他 ref / reactive
  • 下游:组件渲染、副作用函数又会依赖它

所以它既会:

  • 在 getter 里 track 上游依赖
  • 在自己的 .value 被读取时 track 下游消费者
  • 在上游变化时 trigger 下游消费者

这也是为什么 computed 可以继续被别的 computed 依赖:

const count = ref(1)
const double = computed(() => count.value * 2)
const label = computed(() => `double: ${double.value}`)

这里 label 并不直接依赖 count,而是依赖 double。一层一层可以连起来,形成派生链。

6. computedmethods 有什么本质区别?

这是经典追问。

6.1 相同点

  • 都可以基于现有数据得出结果

6.2 不同点

维度computed方法调用
是否缓存没有
是否自动追踪依赖
调用时机读取时按需执行每次调用都执行
适用场景稳定的同步派生值一次性计算或带参数逻辑

例如模板里:

<template>
<p>{{ total }}</p>
<p>{{ total }}</p>
<p>{{ getTotal() }}</p>
<p>{{ getTotal() }}</p>
</template>

如果依赖没变:

  • total 对应的 computed 通常只算一次
  • getTotal() 每次 render 调用都会执行两次

所以面试里最稳的一句话是:

computed 适合“无副作用、可缓存、同步的派生值”;方法更适合“临时计算或需要传参的逻辑”。

7. computedwatch 的边界是什么?

这是另一道必问题。

API本质定位是否返回值是否适合副作用
computed派生状态
watch观察变化后执行动作

举个标准区分:

  • 商品总价、过滤后的列表、格式化展示文案:用 computed
  • 发请求、写本地缓存、手动操作 DOM、同步第三方实例:用 watch

一句话背法:

  • computed 管结果
  • watch 管副作用

如果你用 watch 去手动维护一个派生状态,通常说明设计开始绕了。

8. 可写 computed 是怎么回事?

大多数 computed 只有 getter:

const fullName = computed(() => `${firstName.value} ${lastName.value}`)

但它也支持 get/set

const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(value: string) {
const [first, last] = value.split(' ')
firstName.value = first
lastName.value = last
},
})

这里要注意两点:

  • setter 不是“直接改缓存”
  • setter 的本质仍然是回写源状态

也就是说,可写 computed 只是给派生值增加了一个“反向映射入口”,并没有改变它依赖源数据、通过 getter 计算结果的本质。

9. Vue 2 和 Vue 3 的实现思路差异

如果面试问到底层实现,建议先说共同点,再补版本差异。

9.1 共同点

  • 都是“懒求值 + 缓存 + 依赖收集 + 脏值标记”
  • 都不是依赖一变就立刻重算

9.2 Vue 2

Vue 2 里更接近:

  • Watcher
  • lazy
  • dirty
  • Dep

computed 内部本质上就是一个 lazy watcher:

  • 第一次读取时执行 evaluate()
  • 依赖变化后把 dirty 设回 true
  • 再次读取时重新求值

9.3 Vue 3

Vue 3 里核心角色更接近:

  • ReactiveEffect
  • ComputedRefImpl
  • track / trigger
  • scheduler

表达方式更现代,但思想没变:getter 负责收集依赖,scheduler 负责标脏,读取时按需重算。

面试口径建议这样说:

Vue 2 用的是 lazy watcher,Vue 3 用的是 computed ref + reactive effect,但核心模型都一样,本质都是基于依赖收集和脏值缓存实现的惰性派生值。

10. 为什么 computed 不适合异步和副作用?

computed 更适合:

  • 同步
  • 可重复计算
  • 无副作用

不适合:

  • await
  • 发请求
  • 改别的状态
  • 操作 DOM

原因有两层:

  1. 它的模型是“读取时立刻返回一个值”,而不是“返回一个未来某时才完成的过程”
  2. getter 最好保持纯函数特征,否则缓存和依赖收集的语义都会变脏

所以项目里更稳的做法是:

  • 异步和副作用交给 watch / watchEffect
  • 同步派生结果交给 computed

11. 常见坑

11.1 在 computed 里做副作用

例如在 getter 里发请求、改别的 ref、写日志依赖外部状态。

问题是:

  • getter 会在读取时触发
  • 读取次数未必符合你的副作用预期
  • 容易产生循环依赖或不可控行为

11.2 误以为依赖一变就立刻重算

不是。多数情况下只是先标脏,等下次读取时才重算。

11.3 把高成本且不常读取的逻辑写成普通函数,错失缓存

如果一个结果在一次渲染中会被多处读取,并且依赖稳定,computed 往往比方法更合适。

11.4 把带参数逻辑硬塞进 computed

computed 更适合“固定依赖 -> 固定结果”的派生值。

如果你需要:

  • 动态参数
  • 每次都要重新算
  • 不需要缓存

那方法通常更直接。

12. 面试高频答法

Q1:computed 的原理是什么?

computed 本质上是基于响应式 effect 实现的惰性派生值。它内部会保存一个缓存值和 dirty 标记。第一次读取时执行 getter、收集依赖并缓存结果;依赖变化后不会立刻重算,而是通过 scheduler 把它标记为脏。下次再读取时才重新计算并更新缓存。

Q2:为什么 computed 有缓存,而方法没有?

:因为 computed 会记录依赖,并缓存上一次结果。只要依赖没变,后续读取直接返回缓存;方法调用本质上只是函数执行,每次 render 调用都会重新跑,框架不会替你记忆结果。

Q3:computedwatch 怎么选?

:看你是要“值”还是要“动作”。如果是由已有状态同步推导出另一个值,用 computed;如果是监听变化后发请求、写缓存、操作 DOM 这种副作用,用 watch

Q4:为什么说 computed 是懒执行?

:因为创建时不会立刻执行 getter,而是等第一次读取 .value 时才计算。依赖变化后也不是马上重算,而是先标记为脏,等下次被读取时再重新求值。

速记要点

  • computed = 派生值,不是副作用
  • computed = lazy effect + dirty + cache
  • 首次读取才计算,依赖变化先标脏
  • 下次读取时才真正重算
  • Vue 2 是 lazy watcher,Vue 3 是 reactive effect + computed ref