Skip to content

04|computed:可缓存的派生状态 ​

第三章:响应式系统纵览 / 前置:Proxy 与 Reflect / 下一篇:watchers

只要一个值能完全由现有 source 推导,就不该再存一份可写副本。computed 把推导声明成只读 ref,并用依赖失效而非手动同步保持一致。

运行上下文 ​

本篇纯响应式实验可在 Node 20+ 运行;模板中 computed ref 会自动解包,JavaScript 中读取 summary.value。主线基于 Vue 3.5+;Vue 3.4+ 还提供 getter 的 previous value,并改进了 computed 结果稳定性。

source → derived → effect ​

ts
import { computed, ref, watch } from 'vue'

const unitPrice = ref(12)                         // source
const quantity = ref(2)                           // source
const subtotal = computed(() => unitPrice.value * quantity.value) // derived

watch(subtotal, (value) => {
  console.log('同步到结算摘要', value)             // effect
})

subtotal 没有自己的业务真相。用户改数量时,应写 quantity;下次消费者读取 subtotal.value,它根据新 source 生成新快照。把 watcher 改写成 subtotal.value = ... 既多存一份状态,又引入更新顺序和初始同步问题。

惰性缓存:先失效,读取时重算 ​

js
import assert from 'node:assert/strict'
import { computed, ref } from 'vue'

const price = ref(10)
let getterRuns = 0
const total = computed(() => {
  getterRuns++
  return price.value * 2
})

assert.equal(getterRuns, 0) // 创建时不执行
assert.equal(total.value, 20)
assert.equal(total.value, 20)
assert.equal(getterRuns, 1) // 依赖未变,命中缓存

price.value = 15
assert.equal(getterRuns, 1) // 写入只让缓存失效
assert.equal(total.value, 30)
assert.equal(getterRuns, 2) // 再读取才重算

这就是 computed 与普通方法的差别:方法每次调用都执行;computed 在响应式依赖未变时返回缓存。Date.now() 不是响应式依赖,所以 computed(() => Date.now()) 不会自己随时间刷新。

条件依赖与中间状态 ​

ts
const includeTax = ref(false)
const net = ref(100)
const taxRate = ref(0.2)

const payable = computed(() =>
  includeTax.value ? net.value * (1 + taxRate.value) : net.value
)

当 includeTax 为 false,getter 没有读取 taxRate,税率变化不会令它失效。切换为 true 并读取一次后,本轮依赖集合才包含 taxRate。这与上一篇 effect 的动态依赖规则相同。

getter 必须保持纯净 ​

computed getter 可以读取 source 并返回值,不应:

  • 修改其他 ref 或 reactive 字段;
  • 发请求、写存储、启动定时器;
  • 操作 DOM;
  • 原地排序或反转 source 数组。
ts
const sorted = computed(() =>
  [...items.value].sort((a, b) => a.price - b.price)
)

复制后排序让 source 保持不变。若 getter 写 source,读取 derived 本身就会产生新 trigger,依赖图会出现难以预测的反馈环。

结果身份也影响下游触发 ​

Vue 3.4+ 在 computed 的最终值与上次值相同(按 Object.is)时,可以避免下游 effect 做无意义工作。返回新对象会破坏这项稳定性:

ts
// 每次重算都创建新身份,即使 isEven 没变
const status = computed(() => ({ isEven: count.value % 2 === 0 }))

// 更稳定:下游只关心布尔值,就直接返回布尔值
const isEven = computed(() => count.value % 2 === 0)

不要为了“优化”把外部可变对象当 previous value 返回后再原地修改。先让返回值与消费者真正需要的粒度一致;需要对象时保持 getter 的完整计算,避免漏读依赖。

失效、重算与发布是三个不同计数 ​

保存为 computed-stability.mjs,运行后应无断言错误:

js
import assert from 'node:assert/strict'
import { computed, nextTick, ref, watchEffect } from 'vue'
const count = ref(2)
let calculations = 0
let publications = 0
const even = computed(() => {
  calculations++
  return count.value % 2 === 0
})
const stop = watchEffect(() => {
  publications++
  void even.value
})
assert.deepEqual([calculations, publications], [1, 1])
count.value = 4
await nextTick()
assert.deepEqual([calculations, publications], [2, 1])
count.value = 5
await nextTick()
assert.deepEqual([calculations, publications], [3, 2])
stop()

2→4触发重新求值才能知道结果还是true,但消费者不必再执行;4→5的结果变成false,消费者才再次运行。若把getter改成返回 { even: ... },每轮新对象使下游也执行。需要对象且确实有热点时,可用getter的previous参数比较完整新结果并复用旧对象;必须先读取所有依赖再比较,不能为复用旧值跳过计算而漏订阅。

可写 computed 是适配器,不是第二份状态 ​

ts
const firstName = ref('Ada')
const lastName = ref('Lovelace')

const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value: string) => {
    const [first = '', ...rest] = value.trim().split(/\s+/)
    firstName.value = first
    lastName.value = rest.join(' ')
  }
})

setter 把外部写入翻译回真正 source。若无法明确反向映射,computed 应保持只读,并提供一个命名 action。

排错顺序 ​

  1. getter 是否实际读取了预期 source,还是读了非响应式快照?
  2. 依赖值是否真的变化,或只是写回同值?
  3. 是否只让 computed 失效,却从未再次读取它?
  4. getter 是否返回新对象,导致下游把语义相同误判成身份变化?
  5. getter 是否包含副作用或异步操作?把它迁移到 watch 并负责清理。

练习:证明缓存,而不是相信缓存 ​

实现购物车派生值:source 为 ref<Array<{ id: string; price: number; quantity: number }>>,derived 为只读的总价与按小计降序的新数组。

  • getter 不得修改 source 数组。
  • 连续读取两次时 getter 只运行一次;修改无关的 ref 不应失效。
  • 修改商品数量后,在读取 derived 前 getter 次数不变,读取后只增加一次。
  • 加入空数组,并断言总价为 0、排序结果为空。
  • 先预测每一步 getter 次数,再运行严格断言验证。

查看第 04 题参考答案。

用心记录每一步成长