05|侦听器:给副作用安排一位靠谱的调度员
第三章:响应式系统纵览 / 前置:计算属性 / 下一篇:调度器与刷新时机
watch 不负责“把数据算出来”,它负责在数据变了之后通知浏览器、网络、缓存或第三方组件:该你们上场了,记得收拾现场。
学完这一篇,你应该能做到
- 把源状态、派生状态和副作用分开;不会拿
watch充当一台笨重的computed。 - 为搜索、自动保存、订阅等异步任务处理竞态、超时和组件卸载。
- 解释
watch、watchEffect、immediate、once、deep与flush的选择,而不是在选项里抽盲盒。 - 用 Vue DevTools 或日志确认“谁触发了谁”,而不靠对着屏幕虔诚刷新。
一句话心智模型
把响应式系统想成餐厅:ref / reactive 是食材,computed 是现做但可缓存的菜,watch 是服务员把菜端到餐桌、通知外卖员或清理桌面的流程。服务员不应该决定菜谱,也不应把上一次顾客点的菜端给下一位顾客。
先判断:这里真的需要 watcher 吗?
| 需求 | 首选 | 原因 |
|---|---|---|
| 由已有状态得出显示值 | computed | 保持纯函数、缓存结果,不产生外部动作 |
| 用户点击后直接修改本地状态 | 事件处理函数 | 动作与意图在同一个地方,最容易读 |
状态变化后请求接口、写 localStorage、操控 DOM、订阅外部系统 | watch | 这些都是副作用,需要时机与清理 |
| 副作用刚好读取了若干响应式值,且不需要精确声明来源 | watchEffect | 自动收集同步执行期间读到的依赖 |
下面是一个不该写的例子。completedCount 是 todos 的确定性投影,额外存一份再靠 watcher 同步,只是在未来准备了一场“为什么两个数字不一样”的推理赛。
import { computed, ref, watch } from 'vue'
type Todo = { done: boolean }
const todos = ref<Todo[]>([])
// 不推荐:派生值被复制成可变状态
const copiedCompletedCount = ref(0)
watch(todos, (items) => {
copiedCompletedCount.value = items.filter((item) => item.done).length
}, { deep: true })
// 推荐:只有一份事实来源
const actualCompletedCount = computed(() =>
todos.value.filter((item) => item.done).length
)可运行实验:可取消的搜索框
新建一个 Vue 3 项目后,把下面内容保存为 src/components/SearchBooks.vue,再在 App.vue 中渲染 <SearchBooks />。它不依赖真实接口;searchBooks() 刻意模拟了快慢不一的网络,所以可以稳定复现竞态。
npm create vue@latest watcher-lab
cd watcher-lab
npm install
npm run dev<!-- src/components/SearchBooks.vue -->
<script setup lang="ts">
import { computed, onWatcherCleanup, ref, watch } from 'vue'
type Book = { id: number; title: string }
const query = ref('')
const books = ref<Book[]>([])
const loading = ref(false)
const errorMessage = ref('')
const normalizedQuery = computed(() => query.value.trim().toLowerCase())
let latestRun = 0
function pause(milliseconds: number, signal: AbortSignal) {
return new Promise<void>((resolve, reject) => {
const timer = window.setTimeout(finish, milliseconds)
function finish() {
signal.removeEventListener('abort', abort)
resolve()
}
function abort() {
window.clearTimeout(timer)
signal.removeEventListener('abort', abort)
reject(signal.reason ?? new DOMException('request aborted', 'AbortError'))
}
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) abort()
})
}
async function searchBooks(term: string, signal: AbortSignal): Promise<Book[]> {
// “vue”故意比较慢:先输入 vue,再迅速输入 vite,就能看到旧请求被取消。
await pause(term === 'vue' ? 650 : 180, signal)
if (term === 'error') throw new Error('模拟的服务器打了个喷嚏')
return [{ id: term.length, title: `《${term || '空关键词'} 的响应式冒险》` }]
}
watch(normalizedQuery, async (term) => {
const run = ++latestRun
if (!term) {
books.value = []
errorMessage.value = ''
loading.value = false
return
}
const controller = new AbortController()
const deadline = window.setTimeout(() => {
controller.abort(new DOMException('搜索超过 800ms', 'TimeoutError'))
}, 800)
// Vue 3.5+:必须在第一个 await 之前注册。下一次运行或组件卸载前会调用它。
onWatcherCleanup(() => {
// 停止 watcher 时不会有下一轮自增,因此清理本身也必须撤销本轮写入资格。
if (run === latestRun) latestRun += 1
window.clearTimeout(deadline)
controller.abort(new DOMException('搜索已失效', 'AbortError'))
})
loading.value = true
errorMessage.value = ''
try {
const response = await searchBooks(term, controller.signal)
// 取消通常已经阻止旧请求完成;这个序号仍是最后一道门,保护不接受 signal 的旧 API。
if (run === latestRun && !controller.signal.aborted) books.value = response
} catch (error) {
if (run !== latestRun) return
if (controller.signal.reason?.name === 'TimeoutError') {
errorMessage.value = '搜索超时,请缩小关键词后重试'
books.value = []
} else if (!controller.signal.aborted) {
errorMessage.value = error instanceof Error ? error.message : '搜索失败'
books.value = []
}
} finally {
window.clearTimeout(deadline)
if (run === latestRun) loading.value = false
}
})
</script>
<template>
<section aria-labelledby="search-heading">
<h2 id="search-heading">书架侦查员</h2>
<label>
搜索关键词
<input v-model="query" autocomplete="off" placeholder="试试 vue、vite 或 error" />
</label>
<p v-if="loading" role="status">图书管理员正在翻抽屉……</p>
<p v-else-if="errorMessage" role="alert">{{ errorMessage }}</p>
<ul v-else>
<li v-for="book in books" :key="book.id">{{ book.title }}</li>
</ul>
</section>
</template>怎样验证它真的工作了
- 输入
vue,立即改成vite:最终只应显示vite的结果;慢吞吞的vue不能反杀回来。 - 输入
error:出现错误信息,loading最终恢复为false。 - 清空输入:结果立即清空,不发网络任务。
- 用忽略 signal 的受控 Promise 替换
searchBooks:请求尚未完成时停止组件作用域,再 resolve 或 reject。books、errorMessage和loading必须保持停止瞬间的值;成功、catch 与 finally 都不能再写入。 - 打开 Vue DevTools 的组件面板,连续输入并观察
query、normalizedQuery、loading和books的变化顺序。
成功分支也检查 signal.aborted:超时只中止 signal,不会开启新轮次;忽略 signal 的旧 API 即使晚到成功,也不能提交结果。示例的 pause 会响应 Abort 并抛出超时原因,因此能显示超时文案。若替换的 API 完全忽略 signal,Abort 无法强迫其 Promise 结束,loading 可能一直保持到它自行结束;这段代码只保证拒绝其超时结果,不保证这种适配器也能及时显示超时。
清理时递增 latestRun,同时使成功、catch 和 finally 的轮次检查失效。关键词变化会开启新一轮;组件卸载则只停止旧一轮,两种情况都必须撤销旧请求的写入资格。Abort 用于释放外部资源,不能替代这个本地检查。
这里的职责分配很刻意:query 是源状态;normalizedQuery 是派生值;watch 只桥接“关键词变化”与异步资源;latestRun 和 AbortController 则负责不让退休请求回来抢麦克风。
翻车现场:慢请求覆盖快请求
最常见的写法表面没错,网络一慢就露馅:
watch(query, async (term) => {
books.value = await fetch(`/api/books?q=${encodeURIComponent(term)}`)
.then((response) => response.json())
})| 时刻 | 发生的事 | 错误后果 |
|---|---|---|
| T1 | 用户输入 vue,请求 A 出发 | A 很慢 |
| T2 | 用户输入 vite,请求 B 出发 | B 很快 |
| T3 | B 返回,页面显示 vite | 暂时正确 |
| T4 | A 返回,直接写入 books | 页面倒退成 vue |
修复不能只靠“希望 A 比 B 快”。优先把同一个 AbortSignal 传给支持取消的 API;若遗留 SDK 根本不接受 signal,再用递增序号忽略过期结果。两层都放上不是多疑,是给不确定的网络留一把雨伞。
API 选择:精确来源与自动收集
watch:我知道谁是开关
watch 的第一个参数可以是 ref、reactive 对象、getter,或它们组成的数组。getter 是监听对象字段或组合条件时最清楚的写法。
import { reactive, ref, watch } from 'vue'
const selection = reactive({ scopeId: 'demo' })
const page = ref(1)
watch(
[() => selection.scopeId, page],
([scopeId, currentPage]) => {
void loadIssues(scopeId, currentPage)
},
{ immediate: true }
)
async function loadIssues(scopeId: string, currentPage: number) {
console.log(`load ${scopeId}, page ${currentPage}`)
}数组的第一项是 getter,追踪 selection.scopeId;第二项是 ref,追踪 page.value。在模板按钮中分别执行 selection.scopeId = 'next' 和 page = 2,日志依次为 load demo, page 1、load next, page 1、load next, page 2。若在同一同步函数内同时修改两者,默认调度会合并为一次回调,取得两项最新值。不要写 [selection.scopeId, page]:第一项会先求值得到普通字符串,失去可追踪的读取。
路由来源是第六章 params/query后的回访:安装并注册 Router 后,可把第一个 getter 换成 () => route.params.scopeId;监听数组的机制相同,本节先用本地状态完成实验。
immediate: true 让回调在创建后先执行一次,适合“初次加载和后续重载走同一条路”;它不是“每个 watcher 的开机仪式”。once: true(Vue 3.4+)则适合只对第一次变化有意义的迁移提示或一次性埋点,别把它拿去管理关键业务状态。
watchEffect:我只想声明一段会读状态的副作用
import { ref, watchEffect } from 'vue'
const unreadCount = ref(0)
function refreshBadge() {
console.log('refresh badge')
}
watchEffect((onCleanup) => {
document.title = `${unreadCount.value} 条未读消息`
const timer = window.setInterval(refreshBadge, 30_000)
onCleanup(() => window.clearInterval(timer))
})它会跟踪同步执行期间读取到的 unreadCount。异步 effect 在第一个 await 后才读取的值不会被自动追踪,因此下面的 selectedId 变化不会重新运行这段 effect:
import { ref, watchEffect } from 'vue'
const selectedId = ref('first')
watchEffect(async () => {
await Promise.resolve()
console.log(selectedId.value) // 读得太晚,依赖收集已经下班
})遇到这种场景,显式写成 watch(selectedId, ...)。可读性通常也更好:后来接手的人一眼就知道这个请求由什么驱动。
清理、停止与组件生命周期
在 <script setup> 中同步创建的 watcher 会绑定到当前组件,组件卸载时自动停止;异步回调里才创建的 watcher 则没有这个保护罩。
import { onUnmounted, ref, watch } from 'vue'
const enabled = ref(false)
function startPolling() { /* 启动或停止外部轮询 */ }
// 同步创建的 watcher 会随组件卸载自动停止。
watch(enabled, startPolling)
// 异步创建的 watcher 要自己保存并停止,否则可能留下幽灵订阅。
let stopLateWatcher: (() => void) | undefined
const delayedStop = window.setTimeout(() => {
stopLateWatcher = watch(enabled, startPolling)
}, 500)
onUnmounted(() => {
window.clearTimeout(delayedStop)
stopLateWatcher?.()
})onWatcherCleanup() 是 Vue 3.5 的便捷 API,必须在 watcher 回调同步阶段调用。若你的清理注册要跨过 await,用回调参数中的 onCleanup:它绑定在 watcher 实例上,不受这条同步限制。
允许登记的时机与资源是否仍属于当前运行,是两个问题。若先等待连接,再登记清理,组件可能在等待期间已经卸载;已经发生的停止不会补执行后来登记的回调。使用同步登记的失效标志覆盖这个窗口:
import { ref, watch } from 'vue'
const userId = ref('ada')
type Connection = { close(): void }
async function connectToPresence(id: string): Promise<Connection> {
await new Promise(resolve => setTimeout(resolve, 100))
return { close: () => console.log('close', id) }
}
const stop = watch(userId, async (id, _oldId, onCleanup) => {
let invalidated = false
let connection: Connection | undefined
onCleanup(() => {
invalidated = true
connection?.close()
})
try {
const opened = await connectToPresence(id)
if (invalidated) opened.close()
else connection = opened
} catch (error) {
if (!invalidated) console.error('连接失败', error)
}
})有两种完成顺序。连接先到达,保存到 connection,下次失效时关闭它;失效先发生,标志先变true,连接随后到达就立即关闭。每轮回调各有自己的闭包,因此旧连接不会被新一轮覆盖。这个模式保证迟到资源被释放,并不取消底层连接过程;支持AbortSignal的API还应接入取消。
可把 userId.value 改为 'grace',等待一次 nextTick() 让watcher启动,再调用 stop(),观察100ms后连接仍被关闭。完整受控断言见答案第05题的迟到连接实验。
深度监听与旧值:看得深,不等于看得清
直接监听一个 reactive 对象会隐式深度监听。大型表单、树或服务端缓存被整棵监听时,Vue 必须遍历嵌套属性;这位热心保安会很累。
import { reactive, watch } from 'vue'
const filters = reactive({ keyword: '', tags: [], advanced: { owner: '' } })
function syncFiltersToUrl() {
console.log(filters.keyword)
}
// Vue 3.5+:最多遍历一层;比无界 deep: true 更容易评估成本。
watch(filters, syncFiltersToUrl, { deep: 1 })
// 需求只是关键词变化?把观察范围缩到一根牙签,而不是搬整座衣柜。
watch(() => filters.keyword, syncFiltersToUrl)当深层对象只是属性被修改而没有整体替换时,回调里的 newValue 与 oldValue 往往是同一个代理引用。它们不是两张历史快照;若业务确实需要差异,应该缩小 source、保存业务需要的标量,或在边界处做可控快照,而不是惊讶地对着 newValue === oldValue 发呆。
刷新时机:什么时候读 DOM 才不会读到昨天的新闻
默认 watcher 在父组件更新后、当前组件 DOM 更新前运行。要读取当前组件已经刷新的 DOM,使用 flush: 'post' 或下一篇会讲的 nextTick():
<script setup lang="ts">
import { ref, useTemplateRef, watch } from 'vue'
const count = ref(0)
const heading = useTemplateRef<HTMLHeadingElement>('heading')
watch(count, () => {
console.log(heading.value?.textContent) // 这里已经是新 DOM
}, { flush: 'post' })
</script>
<template>
<h2 ref="heading">已点击 {{ count }} 次</h2>
<button @click="count++">再点一下</button>
</template>flush: 'sync' 会绕开批处理,在每次同步修改后立刻触发。它适合极少数需要同步桥接的简单布尔值,不适合数组、循环修改或高频输入;否则 watcher 会像听到一粒米掉地也要马上开会的同事。
调试路线:从“没反应”回到责任边界
- 检查 source。 回调监听的是
ref、getter,还是一份已经脱离响应式的普通值? - 检查触发。 同一个 tick 内的多次赋值可能被批处理;先记录次数,再怀疑 Vue。
- 检查时机。 需要的是旧 DOM、更新后 DOM,还是与外部库同步的即时副作用?
- 检查清理。 路由切换、关键词变化、失败和卸载时,计时器、请求、订阅分别由谁关门?
- 检查范围。
deep: true是否只是为了偷懒?能否用 getter 指向具体字段?
开发阶段可给 watchEffect 加 onTrack / onTrigger,或在 watcher 入口记录 source、run 序号与 signal.aborted。生产环境别把对象全文打印到日志里;数据与日志费用都不喜欢被围观。
版本与旧代码提醒
- 主线以 Vue 3.5+ 的 Composition API 为准;
onWatcherCleanup()需要 3.5+,once需要 3.4+,数值形式的deep也从 3.5+ 开始。 - Vue 2 的
watch选项仍可能出现在维护任务中,但响应式缺口、this上下文和组合式函数的资源边界都不同;不要把 Vue 3 的ref/onWatcherCleanup原样搬过去。 - SSR 中不要在 setup 顶层无条件触碰
window、document或浏览器存储。上面的window代码只在 watcher 的客户端交互时运行;若使用immediate,要明确保护运行环境。
练习:草稿保存员不能把旧草稿写回去
做一个 DraftEditor.vue,字段包含标题与正文。要求:
- 用一个明确的
watchsource 在内容变化后延迟 500ms 保存;派生字数必须用computed,不能用 watcher 回写。 - 用户连续输入、切换文章或组件卸载时,旧保存任务不得覆盖新文章;为网络请求与定时器设计清理。
- UI 至少有“未保存 / 保存中 / 已保存 / 保存失败”四种状态,并让屏幕阅读器能获知状态变化。
- 用一次慢请求和一次失败请求证明:最终保存的是最新文章的最新内容。
完成标准: 你能画出 source → computed → watcher → 外部保存服务的箭头;代码中每一个计时器、请求或订阅都找得到清理责任人;没有把最终实现贴在本页。
查看第 3 章第 05 题参考答案(请先独立完成,再让答案当代码审稿人)。
官方延伸阅读
- Vue Watchers 指南:source 类型、清理、深度监听与 flush 的官方语义。
- Vue 响应式深入:依赖追踪、调试事件和外部状态集成。