computed:派生状态与缓存
响应式基础:ref、reactive 与 computed · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
computed 是 Vue 里替代「手动同步派生状态」的标准答案。这一节要掌握:computed 的依赖追踪与缓存机制、可写 computed 的用法、以及为什么大多数「用 watch 维护派生值」的代码都应该改成 computed。
问题场景
购物车需要显示「小计、折扣、税费、总计」,四个值都由商品列表推导出来。用 React 你会写 useMemo 或直接在渲染里算;用 Vue 如果写成 watch(items, () => { total.value = ... }),会得到一堆互相触发的 watcher,改一个数量触发四次计算,还容易出现「初始值没算」的 bug。派生状态就该用派生 API。
核心解释
computed 接收一个 getter,返回一个只读的 ref。它的两个特性决定了它比普通函数更适合模板:
- 缓存:依赖没变时重复读取直接返回上次结果,不重新执行。模板里多次使用同一个 computed 只算一次。
- 自动追踪:getter 里读过的响应式值会被记录,任何一个变了才重新计算。
<script setup>
import { computed, reactive } from "vue";
const cart = reactive({
items: [
{ name: "键盘", price: 399, qty: 1 },
{ name: "鼠标", price: 129, qty: 2 },
],
discountRate: 0.1, // 10% 折扣
});
const subtotal = computed(() =>
cart.items.reduce((s, it) => s + it.price * it.qty, 0)
);
const discount = computed(() => subtotal.value * cart.discountRate);
const total = computed(() => subtotal.value - discount.value);
const itemCount = computed(() =>
cart.items.reduce((s, it) => s + it.qty, 0)
);
</script>
<template>
<p>共 {{ itemCount }} 件,小计 {{ subtotal }} 元</p>
<p>折扣 {{ discount.toFixed(2) }} 元,应付 {{ total.toFixed(2) }} 元</p>
</template>
computed 之间可以链式依赖:total 依赖 subtotal 和 discount,discount 又依赖 subtotal。依赖图由 Vue 自动维护,改一个 qty 只会让受影响的节点失效,下次读取时按需重算。
可写 computed:需要「读时派生、写时反推」的场景(比如全选复选框),可以传 { get, set }:
<script setup>
import { computed, reactive } from "vue";
const state = reactive({
items: [
{ name: "Vue", done: true },
{ name: "Pinia", done: false },
],
});
const allDone = computed({
get: () => state.items.every((it) => it.done),
set: (val) => state.items.forEach((it) => (it.done = val)),
});
</script>
<template>
<label>
<input type="checkbox" v-model="allDone" />
全部完成
</label>
<ul>
<li v-for="it in state.items" :key="it.name">
<input type="checkbox" v-model="it.done" /> {{ it.name }}
</li>
</ul>
</template>
什么时候不该用 computed:getter 里不要做副作用(请求、写 localStorage、改其他状态)。computed 的语义是「纯函数式的派生」,有副作用会让缓存和依赖追踪变得不可预测。需要副作用就用 watch(下一节)。
示例拆解
一个带搜索和排序的课程列表,全部用 computed 派生:
<script setup>
import { computed, ref } from "vue";
const courses = ref([
{ code: "V01", title: "Vue 3 全景", hours: 1 },
{ code: "V02", title: "模板语法", hours: 1.25 },
{ code: "V03", title: "响应式基础", hours: 1.25 },
{ code: "V10", title: "Vue Router", hours: 1.5 },
]);
const keyword = ref("");
const sortBy = ref("code"); // code | hours
const filtered = computed(() => {
const k = keyword.value.trim().toLowerCase();
if (!k) return courses.value;
return courses.value.filter(
(c) => c.title.toLowerCase().includes(k) || c.code.toLowerCase().includes(k)
);
});
const sorted = computed(() => {
const list = [...filtered.value];
return sortBy.value === "hours"
? list.sort((a, b) => b.hours - a.hours)
: list.sort((a, b) => a.code.localeCompare(b.code));
});
const totalHours = computed(() =>
filtered.value.reduce((s, c) => s + c.hours, 0)
);
</script>
<template>
<input v-model="keyword" placeholder="搜索课程" />
<select v-model="sortBy">
<option value="code">按编号</option>
<option value="hours">按时长</option>
</select>
<ul>
<li v-for="c in sorted" :key="c.code">{{ c.code }} · {{ c.title }}({{ c.hours }}h)</li>
</ul>
<p>命中 {{ filtered.length }} 门,共 {{ totalHours }} 小时</p>
</template>
注意 sorted 里先 [...filtered.value] 复制再排序——sort 会原地修改数组,直接对 filtered.value 排序会污染上游数据。这是 computed 链里最容易忽略的副作用。
动手任务
- 给
totalHours加一个格式化 computed:不足 1 小时显示分钟(如45 分钟),否则显示小时。 - 把
allDone的可写 computed 例子跑通,再改成三态(全选 / 部分选 / 全不选)的显示文案。 - 故意在 computed 里
console.log,在模板里读它三次,观察控制台只打印一次——验证缓存。
常见错误
- computed 里改状态:在 getter 里写
other.value++或发请求,导致无限循环或难以追踪的更新。getter 必须是纯的。 - 把 computed 当函数调用:
{{ total() }}会报错,computed 是 ref,模板里直接用{{ total }}。 - 依赖了非响应式数据:computed 依赖普通变量时不会重新计算,因为追踪不到。依赖必须来自 ref/reactive/props。
- 对 computed 的数组直接 sort:原地修改会污染源数据,先复制再操作。
检查与复盘
- 能说出 computed 与「普通函数」在模板中的两点差异(缓存、依赖追踪)
- 能写出可写 computed,并说明 setter 什么时候触发
- 能判断一段 watch 代码是否应该改写成 computed
参考资料
- Vue 官方文档「计算属性」:缓存、可写 computed、最佳实践
- Vue 官方文档「响应式基础」:依赖追踪的基本模型
读完这一课了?
标记完成后,工作台会安排接下来的学习。