computed 的原理
computed 是 Vue 面试里非常高频的一道题,但很多回答会停在“它有缓存”这一层。这个结论没错,但如果继续追问:
- 为什么它能缓存?
- 它什么时候重新计算,什么时候不算?
- 它和
methods、watch的本质差异是什么? - 为什么说
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,它才真正计算
这就是“懒执行”。
所以 computed 和 watchEffect 的一个核心区别就是:
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
}
这里有三层关键点:
- getter 真正执行时,才会读取依赖并完成依赖收集
- 依赖变化后,不直接重新执行 getter,而是先把
dirty设为true - 下次读取
computed.value时,如果发现dirty === true,才重新计算并覆盖缓存
所以“缓存”不是一个独立能力,而是 懒执行 + 标脏重算 共同实现出来的。
4. 依赖变化后,为什么不是立刻重算?
这是 computed 和很多人直觉最不一样的地方。
依赖变化时,内部调度器干的事通常只有两步:
- 把当前 computed 标记为脏
- 通知“谁依赖了这个 computed”,让它们在合适时机重新取值
例如:
const count = ref(1)
const double = computed(() => count.value * 2)
watchEffect(() => {
console.log(double.value)
})
流程是:
watchEffect首次执行,读取double.valuedouble发现自己是脏的,于是执行 getter,拿到结果并缓存count.value变化double不会马上重新算,只会把自己标脏- 因为
watchEffect依赖了double.value,所以它会被重新调度 watchEffect再次执行时重新读取double.value- 此时
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. computed 和 methods 有什么本质区别?
这是经典追问。
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. computed 和 watch 的边界是什么?
这是另一道必问题。
| 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 里更接近:
WatcherlazydirtyDep
computed 内部本质上就是一个 lazy watcher:
- 第一次读取时执行
evaluate() - 依赖变化后把
dirty设回true - 再次读取时重新求值
9.3 Vue 3
Vue 3 里核心角色更接近:
ReactiveEffectComputedRefImpltrack/trigger- scheduler
表达方式更现代,但思想没变:getter 负责收集依赖,scheduler 负责标脏,读取时按需重算。
面试口径建议这样说:
Vue 2 用的是 lazy watcher,Vue 3 用的是 computed ref + reactive effect,但核心模型都一样,本质都是基于依赖收集和脏值缓存实现的惰性派生值。
10. 为什么 computed 不适合异步和副作用?
computed 更适合:
- 同步
- 可重复计算
- 无副作用
不适合:
await- 发请求
- 改别的状态
- 操作 DOM
原因有两层:
- 它的模型是“读取时立刻返回一个值”,而不是“返回一个未来某时才完成的过程”
- 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:computed 和 watch 怎么选?
答:看你是要“值”还是要“动作”。如果是由已有状态同步推导出另一个值,用 computed;如果是监听变化后发请求、写缓存、操作 DOM 这种副作用,用 watch。
Q4:为什么说 computed 是懒执行?
答:因为创建时不会立刻执行 getter,而是等第一次读取 .value 时才计算。依赖变化后也不是马上重算,而是先标记为脏,等下次被读取时再重新求值。
速记要点
computed = 派生值,不是副作用computed = lazy effect + dirty + cache- 首次读取才计算,依赖变化先标脏
- 下次读取时才真正重算
- Vue 2 是 lazy watcher,Vue 3 是 reactive effect + computed ref