讲解

界面上大部分数据其实不是「源数据」,而是「源数据的推导」:购物车总价来自商品列表,全名来自姓和名,过滤后的表格来自原始数据和筛选条件。这类推导状态的正经归宿是 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。