讲解

computed 负责「从状态推导状态」,watch 负责「状态变化后做事」——发请求、写 localStorage、打日志、手动操作 DOM,这些有外部影响的代码都叫副作用,它们是 watch 的地盘。一条判断标准:改完数据后需要「对外做点什么」,用 watch;需要「算出一个新值」,用 computed。

watch 的基本签名是 watch(源, 回调)。源有三种写法:单个 ref(watch(count, cb))、getter 函数(watch(() => user.name, cb),用于监听对象属性)、数组(同时监听多个源)。回调拿到 (新值, 旧值),默认只有源真正变化才触发。两个常用选项:{ immediate: true } 让回调立即执行一次(常用于初始化加载),{ deep: true } 深度监听对象内部变化(有性能成本,能精确监听就别 deep)。

watchEffect 是另一种思路:不指定监听谁,把副作用直接写进函数,Vue 自动收集函数里读到的所有响应式依赖,任何一个变了就重新执行。它适合「依赖很多、一一列举太啰嗦」的场景,比如「页面上所有筛选条件任意变化就重新搜索」。代价是依赖是隐式的,调试时不如 watch 明确。

两个进阶点。其一,停止监听:<script setup> 里的 watch 随组件卸载自动停止,但手动创建的(比如在事件回调里)要用返回值 stop()。其二,清理副作用:回调的第三个参数 onCleanup 可以注册清理函数,在下次触发前执行——搜索防抖场景里用它取消上一次请求,避免旧响应覆盖新结果。

示例

模拟 watch 的依赖触发与 onCleanup 竞态处理——这是真实项目里最值钱的模式(在本教程构建时被真实执行):

import assert from 'node:assert/strict';

// 迷你 watch:源变化时执行回调,支持 onCleanup
function watch(getSource, cb) {
  let cleanup = null;
  let oldValue = getSource();
  return {
    notify() {
      const newValue = getSource();
      if (newValue === oldValue) return; // 没变不触发
      if (cleanup) cleanup(); // 先清理上一次副作用
      cb(newValue, oldValue, (fn) => (cleanup = fn));
      oldValue = newValue;
    },
  };
}

const keyword = { value: '' };
const requests = [];
const cancelled = [];

// 模拟「输入关键词 → 发请求」,旧请求被清理
const w = watch(
  () => keyword.value,
  (kw, _old, onCleanup) => {
    const req = { id: requests.length + 1, keyword: kw, done: false };
    requests.push(req);
    onCleanup(() => {
      if (!req.done) {
        req.done = true;
        cancelled.push(req.id);
      }
    });
  },
);

keyword.value = 'v';
w.notify();
keyword.value = 'vu';
w.notify(); // 1 号请求被清理
keyword.value = 'vue';
w.notify(); // 2 号请求被清理
keyword.value = 'vue';
w.notify(); // 值没变,不触发

assert.deepStrictEqual(
  requests.map((r) => r.keyword),
  ['v', 'vu', 'vue'],
);
assert.deepStrictEqual(cancelled, [1, 2]);

console.log('发出请求:', requests.map((r) => r.keyword).join(' → '));
console.log('被取消的请求:', cancelled.join(', '), '(旧响应不会覆盖新结果)');

真实组件里的防抖搜索:

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

const keyword = ref('');
const result = ref('');

watch(keyword, async (kw, _, onCleanup) => {
  let stale = false;
  onCleanup(() => (stale = true)); // 下次触发前标记旧请求作废
  if (!kw) return (result.value = '');
  const res = await fetch('/api/search?q=' + encodeURIComponent(kw));
  const data = await res.json();
  if (!stale) result.value = data.total + ' 条结果'; // 只接受最新响应
});
</script>

<template>
  <input v-model="keyword" placeholder="输入即搜" />
  <p>{{ result }}</p>
</template>

常见坑

  • 监听对象属性却传了整个对象watch(user, cb) 默认浅监听,属性变化不触发;用 getter watch(() => user.name, cb) 精确监听。
  • 滥用 deep: true:大对象深监听每次都全量遍历比对,能监听具体路径就别 deep。
  • 用 watch 做 computed 的事:看到「A 变了同步 B」先问自己 B 能不能从 A 推出来——能就用 computed。
  • 忽略竞态:连续输入触发多次请求,不做清理的话慢的旧响应会覆盖快的新响应,onCleanup 或 AbortController 二选一。
  • 在 watch 里改自己监听的源:容易死循环,确要这样做务必加条件判断出口。

小结

副作用归 watch,推导归 computed;watch 显式列源、watchEffect 自动收集;异步回调记得用 onCleanup 处理竞态。下一章进入视图层:模板语法。