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
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 = ... 既多存一份状态,又引入更新顺序和初始同步问题。
惰性缓存:先失效,读取时重算
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()) 不会自己随时间刷新。
条件依赖与中间状态
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 数组。
const sorted = computed(() =>
[...items.value].sort((a, b) => a.price - b.price)
)复制后排序让 source 保持不变。若 getter 写 source,读取 derived 本身就会产生新 trigger,依赖图会出现难以预测的反馈环。
结果身份也影响下游触发
Vue 3.4+ 在 computed 的最终值与上次值相同(按 Object.is)时,可以避免下游 effect 做无意义工作。返回新对象会破坏这项稳定性:
// 每次重算都创建新身份,即使 isEven 没变
const status = computed(() => ({ isEven: count.value % 2 === 0 }))
// 更稳定:下游只关心布尔值,就直接返回布尔值
const isEven = computed(() => count.value % 2 === 0)不要为了“优化”把外部可变对象当 previous value 返回后再原地修改。先让返回值与消费者真正需要的粒度一致;需要对象时保持 getter 的完整计算,避免漏读依赖。
失效、重算与发布是三个不同计数
保存为 computed-stability.mjs,运行后应无断言错误:
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 是适配器,不是第二份状态
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。
排错顺序
- getter 是否实际读取了预期 source,还是读了非响应式快照?
- 依赖值是否真的变化,或只是写回同值?
- 是否只让 computed 失效,却从未再次读取它?
- getter 是否返回新对象,导致下游把语义相同误判成身份变化?
- getter 是否包含副作用或异步操作?把它迁移到
watch并负责清理。
练习:证明缓存,而不是相信缓存
实现购物车派生值:source 为 ref<Array<{ id: string; price: number; quantity: number }>>,derived 为只读的总价与按小计降序的新数组。
- getter 不得修改 source 数组。
- 连续读取两次时 getter 只运行一次;修改无关的 ref 不应失效。
- 修改商品数量后,在读取 derived 前 getter 次数不变,读取后只增加一次。
- 加入空数组,并断言总价为
0、排序结果为空。 - 先预测每一步 getter 次数,再运行严格断言验证。