讲解
界面上大部分数据其实不是「源数据」,而是「源数据的推导」:购物车总价来自商品列表,全名来自姓和名,过滤后的表格来自原始数据和筛选条件。这类推导状态的正经归宿是 computed——computed 接收一个 getter 函数,返回一个只读的 ref,它的值由依赖的响应式数据自动推导。
computed 有两个关键特性,恰好对应手写推导逻辑最容易写错的两个点。第一是缓存:只要依赖没变,多次访问 computed 不会重新执行 getter,直接返回上次结果;模板里引用十次全名,拼接逻辑也只跑一次。第二是惰性求值:getter 只在被读取时才执行,没人用的 computed 一分钱的计算成本都不花。
和「在模板里直接写表达式」相比,computed 的价值是可复用、可测试、可缓存;和「用 watch 监听源数据再手动赋值」相比,computed 是声明式的——你描述「是什么」而不是「怎么同步」,代码里没有手动赋值的命令式噪音。一条经验法则:能用 computed 推导的,就不要用 watch 同步。
computed 默认只读,强行赋值会警告。少数场景确实需要「可写计算属性」——典型是 v-model 包装:给 computed 传 { get, set } 对象,set 里把值拆回源数据。比如把「元」为单位的金额绑定到以「分」存储的状态上。
示例
先实现一个带缓存和惰性求值的迷你 computed,用执行次数证明这两个特性(在本教程构建时被真实执行):
import assert from 'node:assert/strict';
function computed(getter) {
let dirty = true; // 依赖是否可能变了
let cached;
return {
invalidate() {
dirty = true;
},
get value() {
if (dirty) {
cached = getter(); // 只有脏了才重新算(惰性 + 缓存)
dirty = false;
}
return cached;
},
};
}
let runs = 0;
const price = { value: 100 };
const discount = { value: 0.8 };
const finalPrice = computed(() => {
runs++;
return (price.value * discount.value).toFixed(2);
});
// 惰性:没人读,getter 一次都没跑
assert.strictEqual(runs, 0);
// 缓存:连读三次只算一次
assert.strictEqual(finalPrice.value, '80.00');
assert.strictEqual(finalPrice.value, '80.00');
assert.strictEqual(finalPrice.value, '80.00');
assert.strictEqual(runs, 1);
// 依赖变化 → 失效 → 下次读取重算
discount.value = 0.5;
finalPrice.invalidate();
assert.strictEqual(finalPrice.value, '50.00');
assert.strictEqual(runs, 2);
// 可写 computed:拆回源数据
const yuan = {
get value() {
return (price.value / 100).toFixed(2);
},
set value(v) {
price.value = Math.round(parseFloat(v) * 100);
},
};
yuan.value = '19.99';
assert.strictEqual(price.value, 1999);
console.log('getter 总执行次数:', runs);
console.log('写入 19.99 元 → 源数据存', price.value, '分');
真实组件中的样子(列表过滤是最经典的 computed 场景):
<script setup>
import { ref, computed } from 'vue';
const keyword = ref('');
const todos = ref([
{ id: 1, text: '学习 ref', done: true },
{ id: 2, text: '学习 computed', done: false },
]);
const filtered = computed(() =>
todos.value.filter((t) => t.text.includes(keyword.value)),
);
const remaining = computed(() => todos.value.filter((t) => !t.done).length);
</script>
<template>
<input v-model="keyword" placeholder="搜索" />
<p>还剩 {{ remaining }} 项</p>
<li v-for="t in filtered" :key="t.id">{{ t.text }}</li>
</template>
常见坑
- 在 computed 里做副作用:发请求、改别的状态、弹提示都不该出现在 getter 里——它必须纯净,副作用请用 watch。
- 忘记 .value 依赖不追踪:getter 里写
todos.filter而不是todos.value.filter,依赖根本没被读到,永远不会重算。 - 推导链里塞异步:computed 是同步的,
computed(async () => ...)得到的是 Promise 的 ref,模板没法直接用。 - 把 computed 当方法调用:
{{ filtered() }}是错的,它是 ref,模板里写{{ filtered }};写成方法还会丢掉缓存。 - 该用 computed 时用 watch 手动同步:多一个状态副本就多一份不一致的风险,推导关系请优先 computed。
小结
computed = 带缓存和惰性求值的推导 ref;纯推导用它,副作用用 watch;可写 computed 走 get/set。下一章讲处理副作用的正主:watch 与 watchEffect。