Vue 计算属性与侦听器

响应式数据让我们"改数据 → 视图自动变"。但很多场景下,我们还需要"基于数据派生新值"或"数据变化时执行点别的"。这就是计算属性(computed)侦听器(watch)的职责。它们是 Vue 响应式系统的两大延伸。

1. 计算属性:computed

当一个值是从其他响应式数据"算出来"的,用 computed。它的两大特点:自动追踪依赖、自动缓存结果。

<script setup>
import { ref, computed } from "vue";

const price = ref(100);
const qty = ref(2);

// computed:基于依赖自动计算,且会缓存结果
const total = computed(() => price.value * qty.value);

// 多行也行,只要返回一个值
const summary = computed(() => {
  const tax = total.value * 0.1;
  return `小计:${total.value},税:${tax},总计:${total.value + tax}`;
});

// 可写 computed(少见,多用于 v-model 双向绑定的中间层)
const fullName = computed({
  get: () => `${first.value} ${last.value}`,
  set: (val) => {
    [first.value, last.value] = val.split(" ");
  },
});
</script>

<template>
  <p>{{ summary }}</p>
</template>

缓存机制是 computed 最重要的特性。total 只在 priceqty 变化时才重算;即使模板里有 100 处用到 total,只要依赖没变,都是读同一个缓存值。这与"在模板里写表达式"完全不同。

2. 何时该用 computed?

记一条原则:优先用 computed,而非在模板里写复杂表达式。模板应该保持"声明式、好读",计算逻辑抽到 computed 里。

3. 侦听器:watch

当一个值变化时你想"做点副作用"——发请求、写 localStorage、改其他状态——用 watch。它接收两个回调:变化后的新值、变化前的旧值。

<script setup>
import { ref, watch } from "vue";

const keyword = ref("");

// 1. 监听 ref:直接传 ref
watch(keyword, (newVal, oldVal) => {
  console.log(`搜索词从 "${oldVal}" 变成 "${newVal}"`);
  // 这里可以做副作用:发请求、改 localStorage、调 API
  fetchResults(newVal);
});

// 2. 监听多个源:传数组
const a = ref(1);
const b = ref(2);
watch([a, b], ([newA, newB], [oldA, oldB]) => {
  console.log(`a 或 b 变了:${newA}, ${newB}`);
});

// 3. 监听 reactive 对象的某个字段:用 getter 函数
const state = reactive({ user: { name: "" } });
watch(
  () => state.user.name,
  (newName) => console.log("名字变了:", newName)
);
</script>

注意监听 reactive 字段时,必须用 getter 函数(() => state.user.name),直接传 state.user.name 会拿到一个普通字符串,Vue 无法追踪它。

4. watch 的配置选项

watch 的第三个参数是配置对象,三个常用选项:

<script setup>
import { ref, watch } from "vue";

const obj = ref({ count: 0 });

// 默认:浅监听 —— obj.value 整体被替换才触发
watch(obj, (newVal) => console.log("替换了"));

// 深度监听:嵌套属性变化也触发(性能开销大,慎用)
watch(
  obj,
  (newVal) => console.log("深层变化了", newVal),
  { deep: true }
);

// 立即执行:创建时就触发一次(否则只在变化时才触发)
const userId = ref(1);
watch(
  userId,
  (id) => fetchUser(id),
  { immediate: true }
);

// 回调时机:默认组件更新前触发,可改为更新后
watch(userId, (id) => {
  document.querySelector("#x"); // 此时 DOM 已是新值
}, { flush: "post" });
</script>

5. watchEffect:自动收集依赖

Vue 3.2+ 提供了 watchEffect,它和 watch 的区别是不用手动声明依赖——回调里用到哪些响应式数据,就追踪哪些。

<script setup>
import { ref, watchEffect } from "vue";

const a = ref(1);
const b = ref(2);

// watchEffect:不用声明依赖,自动追踪用到的响应式数据
watchEffect(() => {
  // 这里用到 a 和 b,所以两者变化都会触发
  console.log(`a + b = ${a.value + b.value}`);
});

// 与 watch 的区别:
// 1. 自动收集依赖,不用手动写 () => a.value
// 2. 创建时立即执行一次(相当于 immediate: true)
// 3. 拿不到 oldVal(只关心当前值)
// 4. 适合"多个依赖混合副作用"的场景

// 清理副作用:返回的函数会在下次执行前调用
const stop = watchEffect((onCleanup) => {
  const timer = setTimeout(() => doSomething(a.value), 500);
  onCleanup(() => clearTimeout(timer));  // 防抖
});

// 手动停止侦听
stop();
</script>

选择上有个简单原则:

6. 新手常见的三个陷阱

<script setup>
import { ref, computed, watch } from "vue";

const list = ref([1, 2, 3]);

// ❌ 陷阱一:在模板里写复杂表达式(每次渲染都重算,无缓存)
// <li>{{ list.filter(n => n > 1).map(n => n * 2).join(",") }}</li>

// ✅ 用 computed 缓存:依赖不变就不重算
const doubled = computed(() =>
  list.value.filter((n) => n > 1).map((n) => n * 2)
);

// ❌ 陷阱二:用 watch 改另一个数据(应该用 computed)
// watch(list, (newList) => { result.value = newList.length; });

// ✅ 直接 computed
const count = computed(() => list.value.length);

// ❌ 陷阱三:在 computed 里产生副作用(发请求、改数据)
// computed 必须是纯函数,副作用放 watch 里
</script>

把这三条记住,你就避开了 90% 的坑。核心原则:computed 是纯函数(只算返回值),副作用放 watch。如果发现 watch 里只是把新值赋给另一个 ref,大概率应该改用 computed。

7. computed vs watch:对照表

8. 实战:搜索防抖

一个高频场景:用户在搜索框输入时,等他停下来 300ms 才发请求。用 watch + setTimeout 配合清理函数就能做:

<script setup>
import { ref, watchEffect } from "vue";

const a = ref(1);
const b = ref(2);

// watchEffect:不用声明依赖,自动追踪用到的响应式数据
watchEffect(() => {
  // 这里用到 a 和 b,所以两者变化都会触发
  console.log(`a + b = ${a.value + b.value}`);
});

// 与 watch 的区别:
// 1. 自动收集依赖,不用手动写 () => a.value
// 2. 创建时立即执行一次(相当于 immediate: true)
// 3. 拿不到 oldVal(只关心当前值)
// 4. 适合"多个依赖混合副作用"的场景

// 清理副作用:返回的函数会在下次执行前调用
const stop = watchEffect((onCleanup) => {
  const timer = setTimeout(() => doSomething(a.value), 500);
  onCleanup(() => clearTimeout(timer));  // 防抖
});

// 手动停止侦听
stop();
</script>

每次输入变化,上一次的定时器被 clearTimeout 清掉,只有最后一次输入的定时器能跑完——这就是经典的"防抖"模式。也可以直接用 lodash 的 debounce 函数包一层。

小结

这一章你掌握了 Vue 响应式的两大派生能力:computed 派生数据(带缓存、纯函数)、watch 处理副作用(发请求、改 localStorage)。记住口诀"能用 computed 就别用 watch"。下一篇我们系统过一遍 Vue 的所有内置指令。

← 上一篇 Vue 响应式数据

下一篇 Vue 内置指令

✈️💬