Skip to content

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 同步,只是在未来准备了一场“为什么两个数字不一样”的推理赛。

ts
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() 刻意模拟了快慢不一的网络,所以可以稳定复现竞态。

bash
npm create vue@latest watcher-lab
cd watcher-lab
npm install
npm run dev
vue
<!-- 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>

怎样验证它真的工作了 ​

  1. 输入 vue,立即改成 vite:最终只应显示 vite 的结果;慢吞吞的 vue 不能反杀回来。
  2. 输入 error:出现错误信息,loading 最终恢复为 false。
  3. 清空输入:结果立即清空,不发网络任务。
  4. 用忽略 signal 的受控 Promise 替换 searchBooks:请求尚未完成时停止组件作用域,再 resolve 或 reject。books、errorMessage 和 loading 必须保持停止瞬间的值;成功、catch 与 finally 都不能再写入。
  5. 打开 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 则负责不让退休请求回来抢麦克风。

翻车现场:慢请求覆盖快请求 ​

最常见的写法表面没错,网络一慢就露馅:

ts
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 很快
T3B 返回,页面显示 vite暂时正确
T4A 返回,直接写入 books页面倒退成 vue

修复不能只靠“希望 A 比 B 快”。优先把同一个 AbortSignal 传给支持取消的 API;若遗留 SDK 根本不接受 signal,再用递增序号忽略过期结果。两层都放上不是多疑,是给不确定的网络留一把雨伞。

API 选择:精确来源与自动收集 ​

watch:我知道谁是开关 ​

watch 的第一个参数可以是 ref、reactive 对象、getter,或它们组成的数组。getter 是监听对象字段或组合条件时最清楚的写法。

ts
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:我只想声明一段会读状态的副作用 ​

ts
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:

ts
import { ref, watchEffect } from 'vue'

const selectedId = ref('first')

watchEffect(async () => {
  await Promise.resolve()
  console.log(selectedId.value) // 读得太晚,依赖收集已经下班
})

遇到这种场景,显式写成 watch(selectedId, ...)。可读性通常也更好:后来接手的人一眼就知道这个请求由什么驱动。

清理、停止与组件生命周期 ​

在 <script setup> 中同步创建的 watcher 会绑定到当前组件,组件卸载时自动停止;异步回调里才创建的 watcher 则没有这个保护罩。

ts
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 实例上,不受这条同步限制。

允许登记的时机与资源是否仍属于当前运行,是两个问题。若先等待连接,再登记清理,组件可能在等待期间已经卸载;已经发生的停止不会补执行后来登记的回调。使用同步登记的失效标志覆盖这个窗口:

ts
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 必须遍历嵌套属性;这位热心保安会很累。

ts
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():

vue
<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 会像听到一粒米掉地也要马上开会的同事。

调试路线:从“没反应”回到责任边界 ​

  1. 检查 source。 回调监听的是 ref、getter,还是一份已经脱离响应式的普通值?
  2. 检查触发。 同一个 tick 内的多次赋值可能被批处理;先记录次数,再怀疑 Vue。
  3. 检查时机。 需要的是旧 DOM、更新后 DOM,还是与外部库同步的即时副作用?
  4. 检查清理。 路由切换、关键词变化、失败和卸载时,计时器、请求、订阅分别由谁关门?
  5. 检查范围。 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,字段包含标题与正文。要求:

  1. 用一个明确的 watch source 在内容变化后延迟 500ms 保存;派生字数必须用 computed,不能用 watcher 回写。
  2. 用户连续输入、切换文章或组件卸载时,旧保存任务不得覆盖新文章;为网络请求与定时器设计清理。
  3. UI 至少有“未保存 / 保存中 / 已保存 / 保存失败”四种状态,并让屏幕阅读器能获知状态变化。
  4. 用一次慢请求和一次失败请求证明:最终保存的是最新文章的最新内容。

完成标准: 你能画出 source → computed → watcher → 外部保存服务的箭头;代码中每一个计时器、请求或订阅都找得到清理责任人;没有把最终实现贴在本页。

查看第 3 章第 05 题参考答案(请先独立完成,再让答案当代码审稿人)。

官方延伸阅读 ​

  • Vue Watchers 指南:source 类型、清理、深度监听与 flush 的官方语义。
  • Vue 响应式深入:依赖追踪、调试事件和外部状态集成。

用心记录每一步成长