watch 与 watchEffect:响应变化
响应式基础:ref、reactive 与 computed · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
watch 是 Vue 里处理「状态变化引发副作用」的工具:请求数据、写 localStorage、操作 DOM、打点。这一节要掌握 watch 与 watchEffect 的分工、深度侦听与 immediate 的用法,以及最重要的判断力——哪些代码根本不该用 watch。
问题场景
搜索框输入关键词后要请求接口。用 watch(keyword, fetchResults) 写完后发现:每敲一个字符发一次请求,慢的请求后回来会覆盖快的结果;页面首次加载时列表是空的,因为 watch 默认不立即执行;侦听一个对象时改了属性却不触发,因为默认只比较引用。三个问题对应 watch 的三个配置:防抖、immediate、deep。
核心解释
watch 侦听一个或多个响应式源,变化时执行回调 (newValue, oldValue):
<script setup>
import { ref, watch } from "vue";
const keyword = ref("");
const results = ref([]);
const loading = ref(false);
let timer = null;
watch(keyword, (value) => {
clearTimeout(timer);
timer = setTimeout(async () => {
loading.value = true;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(value)}`);
results.value = await res.json();
} finally {
loading.value = false;
}
}, 300);
}, { immediate: true });
</script>
<template>
<input v-model="keyword" placeholder="搜索" />
<p v-if="loading">搜索中…</p>
<ul>
<li v-for="r in results" :key="r.id">{{ r.title }}</li>
</ul>
</template>
immediate: true 让回调在建立侦听时立刻跑一次(解决首屏空白);手动防抖避免每个字符都请求。侦听对象的属性需要 deep: true:
对象属性默认不会被追踪:watch(form, ...) 只在引用变化时触发,属性变化需要 { deep: true };更精确的写法是侦听具体路径,例如 watch(() => form.profile.name, (name) => ...)。
watchEffect 是另一套写法:不指定源,回调里读了什么就自动侦听什么,且立即执行一次:
import { ref, watchEffect } from "vue";
const theme = ref("light");
const fontSize = ref(16);
watchEffect(() => {
document.documentElement.dataset.theme = theme.value;
document.documentElement.style.fontSize = `${fontSize.value}px`;
});
两者的选择:需要旧值、需要精确控制侦听源、需要 lazy 时用 watch;依赖较多且都是「读了就要跟」的副作用用 watchEffect。
最重要的判断:如果一段 watch 只是在维护另一个响应式值(watch(a, () => b.value = a.value * 2)),那是 computed 的活;如果 watch 里做的事情不产生新的响应式状态(请求、存储、DOM、日志),才是 watch 的活。React 迁移过来的人最容易把「派生」写成 watch。
清理:watch 回调里可以注册清理函数,避免竞态:
watch(keyword, async (value, _old, onCleanup) => {
const controller = new AbortController();
onCleanup(() => controller.abort()); // 下一次触发或组件卸载时执行
const res = await fetch(`/api/search?q=${encodeURIComponent(value)}`, {
signal: controller.signal,
});
results.value = await res.json();
});
示例拆解
一个「自动保存草稿」的功能,把 watch 的典型用法串起来:
<script setup>
import { reactive, ref, watch } from "vue";
const draft = reactive({ title: "", body: "" });
const savedAt = ref("");
// 深度侦听 + 防抖 + 保存后写回时间
let timer = null;
watch(
draft,
() => {
clearTimeout(timer);
timer = setTimeout(() => {
localStorage.setItem("draft", JSON.stringify(draft));
savedAt.value = new Date().toLocaleTimeString();
}, 500);
},
{ deep: true }
);
// 只在组件建立时恢复一次,不需要侦听,用 immediate 的 watch 或直接执行
const raw = localStorage.getItem("draft");
if (raw) Object.assign(draft, JSON.parse(raw));
</script>
<template>
<input v-model="draft.title" placeholder="标题" />
<textarea v-model="draft.body" placeholder="正文"></textarea>
<p>{{ savedAt ? `已自动保存于 ${savedAt}` : "尚未保存" }}</p>
</template>
拆解三个决策:为什么用 deep——draft 是 reactive 对象,改的是属性;为什么防抖——每次按键都写 localStorage 是浪费;为什么恢复逻辑不放进 watch——它只需要执行一次,用 watch 反而要处理「自己写自己读」的循环风险。
动手任务
- 把搜索例子加上
AbortController,在输入框快速输入时用 Network 面板确认旧请求被取消。 - 写一个
watchEffect版本的主题同步,再改成watch([theme, fontSize], ...)版本,比较两种写法的依赖声明差异。 - 找出下面这段代码的问题并改写:
watch(firstName, () => fullName.value = firstName.value + " " + lastName.value)——提示:它同时漏了 lastName,且本该是 computed。
常见错误
- 用 watch 维护派生状态:
watch(a, () => b.value = ...)应改成 computed,否则初始值、依赖遗漏、重复计算全是坑。 - 侦听对象却忘了 deep:默认只比较引用,属性变化不触发。症状是「明明改了值但回调没跑」。
- 忘记清理定时器/请求:组件卸载后回调还在跑,造成内存泄漏或报错。用
onCleanup或onUnmounted。 - 在 watch 回调里再次修改被侦听的源:形成循环触发,控制台刷屏。需要条件判断或改用 computed。
检查与复盘
- 能说出 watch 与 watchEffect 的三点差异(源声明、立即执行、旧值)
- 能判断一段逻辑该用 computed 还是 watch,并给出理由
- 能写出带
deep、immediate、onCleanup的 watch
参考资料
- Vue 官方文档「侦听器」:watch/watchEffect 的完整参数与清理机制
- Vue 官方文档「计算属性」:与侦听器的取舍建议
读完这一课了?
标记完成后,工作台会安排接下来的学习。