Skip to content

Vue computed() 缓存与重算机制解析 ​

译注:本文翻译自 dev.to 文章《Vue computed(): What It Caches and When It Reruns》,作者 parsajiravand,原文发布于 2026 年 9 月 30 日。原文链接见文末。本文基于 Vue 3.5.x(npm 上验证为 3.5.43)撰写,Vue 3.6 目前处于 RC 阶段,新增基于 alien-signals 的响应式内核与可选的 Vapor Mode 编译器,但均不改变本文描述的缓存契约。

在一次渲染中调用 computed() 的 getter 五十次,它只运行一次;修改它依赖的 ref,什么都不会发生——至少不是立刻发生;直到你读取 .value,它才重新计算。这套行为看似魔法,实则源于一条明确的规则:computed() 不是一个普通函数,而是一个带缓存、惰性求值、拥有独立订阅列表的 effect。

两个独立事件:失效与重算 ​

理解 computed() 的关键,是把它拆成两个不同的事件:

  • 失效(Invalidation):在被追踪的依赖被写入的瞬间同步发生,代价极低。Vue 只是把该 computed 标记为脏(在 3.5 中,若无人订阅,甚至不会收到通知,而是在下次读取时对比依赖的版本计数器)。此时不会发生任何重算。
  • 重算(Recomputation):惰性发生,仅在脏标记被设置后的下一次 .value 读取时触发。如果此后无人再读取它,getter 永远不会重跑——你付出的只是一次标记翻转,而非一次计算。

这正是 computed 在重复读取场景下比普通方法调用更便宜的原因,也是开发者第一次在 getter 里打日志、却发现它在意料之外时机触发时的困惑来源。

缓存是真实存在的 ​

vue
<script setup>
import { ref, computed } from 'vue'

const a = ref(2)
const b = ref(3)
let getterRuns = 0

const sum = computed(() => {
  getterRuns++
  return a.value + b.value
})
</script>

<template>
  <p>&#123;&#123; sum &#125;&#125; — &#123;&#123; sum &#125;&#125; — &#123;&#123; sum &#125;&#125;</p>
  <p>Getter ran &#123;&#123; getterRuns &#125;&#125; time(s)</p>
</template>

模板在一次渲染中读取 sum 三次,但 getterRuns 为 1。首次读取完成求值并缓存,此后在失效之前的每次读取都直接返回缓存值,完全不触碰 getter。

失效同步,重算惰性 ​

javascript
a.value = 10          // 失效:脏标记已设置,getter 尚未运行
console.log(getterRuns) // 仍为 1

console.log(sum.value)  // 重算在此发生
console.log(getterRuns) // 现在为 2

写入 a 不会运行 getter,只是把缓存标记为过期。getter 在下一次读取时运行——可能立刻,可能几个 tick 之后,也可能永远不运行(例如使用它的组件已先卸载)。

这也解释了为什么在无人读取前连续修改依赖十次,代价仍只是一次重算:

javascript
a.value++
a.value++
a.value++
console.log(sum.value) // getter 只运行一次,使用最终值

依赖追踪每次运行都会重建 ​

computed 的依赖列表并非在创建时固定,而是在每次求值时重建,因此分支逻辑会影响订阅关系:

javascript
const useB = ref(false)
const result = computed(() => (useB.value ? b.value : a.value))

当 useB 为 false 时,本次求值只读取了 useB 和 a,写入 b 不会使 result 失效,因为 b 在上次求值中从未被触碰。把 useB 翻转为 true 后,下一次求值改为读取 b,此后 b 成为真实依赖,而 a 不再是。订阅列表是「上次实际读取了什么」的快照,而非「理论上可能读取什么」。

可写 computed ​

传入 { get, set } 即可让 computed 接受写入:

javascript
const first = ref('Ada')
const last = ref('Lovelace')

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

fullName.value = 'Grace Hopper' // 运行 setter,写入 first 与 last

get 侧的缓存规则与只读形式完全一致;set 侧则完全不缓存,每次赋值都是一次普通函数调用。

computed()、watch() 与 watchEffect() 的取舍 ​

三者都是响应式 effect,追踪依赖的方式相同,但用途不同:

  • computed():从其他响应式状态同步派生一个值,无副作用。适用于「从已有数据算出新数据」。
  • watchEffect():执行副作用(请求、日志、DOM 测量),自动追踪其读取的响应式状态。创建时立即运行一次,之后依赖变化时再次运行。
  • watch():响应你显式指定的来源执行副作用,可访问新旧值,并可控制时机(flush: 'post')与是否立即触发(immediate: true)。

如果你发现自己在 computed getter 里写 fetch()、console.log() 或路由跳转,那说明你真正需要的是 watch 或 watchEffect。computed getter 必须保持纯粹,因为 Vue 会根据缓存策略决定调用它、跳过它或推迟它,带副作用的 getter 会让这些决策变得不可预测。

边界情况与陷阱 ​

  • 不纯的 getter 会破坏契约。 由于 Vue 决定 getter 何时(乃至是否)重跑,其中的副作用执行次数不可预测——失效后若无人读取,甚至可能为零。
  • 异步 getter 不可用。 computed() 期望同步返回值;async 函数返回 Promise,Vue 无法「等待」缓存填充。异步派生状态应使用 watchEffect(或 watch)自行填充一个普通 ref,或采用专门的异步状态组合式函数。
  • 解构 reactive() 对象会破坏追踪,进而静默破坏基于解构变量构建的 computed,因为已不存在可订阅的响应式属性。解构前请使用 toRefs()(或通过对象读取)。
  • 自 Vue 3.4 起,computed 仅在其值真正变化时才向下传播,而非依赖一变就传播。若 isEven 为 computed(() => count.value % 2 === 0),count 从 2 变为 4,getter 会重跑但返回同样的 true,因此 isEven 的下游不会重跑——这对 computed 链式依赖尤其有用,可避免依赖变化而派生结果未变时的级联计算。自 3.4 起,还可以把上一次结果作为 getter 的第一个参数读取:computed((prev) => …)。
  • 没有活跃订阅者的 computed 仍可被直接读取(例如在 <script setup> 中定义、在模板外使用的变量)。它不需要组件渲染作为消费者,任何读取 .value 的 effect 都能保持缓存规则成立。

最佳实践 ​

  • 当值「纯粹派生自已持有的状态」且无需日志、请求等外部行为时,使用 computed()。
  • 需要旧值、需要响应某个特定来源、或需要控制 DOM 更新前后的 flush 时机时,使用 watch()。
  • 副作用依赖可交给 Vue 自动推断、且需要立即运行一次时,使用 watchEffect()。
  • 永远不要在 computed getter 内修改其他响应式状态。它今天可能碰巧能用,但一旦被修改的状态也是该 computed 自身的依赖,就会变成无限失效的 bug。
  • 让 computed getter 足够轻量,使偶尔重算可以接受——缓存省下的是失效之间的重复读取,而不是单次运行的成本。

FAQ ​

每次读取 .value 都会重跑吗? 不会。只有当上次求值后某个被追踪依赖发生变化,且随后发生读取时才会重跑。未失效的 computed 无论读取多少次都返回缓存值。

为什么我的 computed 没有更新? 几乎总是两个原因之一:你修改的属性在上次 getter 运行中从未被读取(因此不是被追踪的依赖,见上文依赖重建部分),或者你修改的是解构出来的普通变量而非响应式源本身。第三种较少见的情况是 getter 读取了完全非响应式的内容——computed(() => Date.now()) 永远不会更新。

computed getter 可以是异步的吗? 不可以,getter 必须同步返回值。异步派生状态应通过 watchEffect 或 watch 更新一个普通 ref 来建模。

原文链接 ​

https://dev.to/parsajiravand/vue-computed-what-it-caches-and-when-it-reruns-1e03