人人都会AI编程

4.6 watch 侦听器:深度监听、立即执行、刷新时机原理

更新时间:2026-07-10

watch 是 Vue 中用于主动观察响应式数据变化并执行副作用的 API。与计算属性不同,watch 不返回新的值,而是执行一些命令式的操作(如请求数据、操作 DOM、记录日志)。理解 watch 的底层原理,能帮助我们在复杂场景下精准控制副作用的触发时机,避免常见的“无限循环”或“不触发”问题。

基本用法与依赖追踪

watch 接受一个监听源和一个回调函数。监听源可以是:

  • 一个 refreactive 对象
  • 一个 getter 函数(返回需要监听的值)
  • 由上述组成的数组
import { ref, watch } from 'vue'

const keyword = ref('')

watch(keyword, (newVal, oldVal) => {
  // 当 keyword.value 变化时调用
  console.log(`搜索词从 ${oldVal} 变为 ${newVal}`)
})

底层原理上,watch手动执行一次监听源的取值操作,在这次取值过程中收集依赖(通过 effect + track)。一旦监听源内部的响应式数据发生变化,就会触发 trigger,调度器便会执行你提供的回调。这一机制与 watchEffect 的“自动追踪”不同:watch 需要你明确指定依赖,因此回调中可以使用不参与依赖收集的变量而不会导致重复触发。

深度监听(deep)

默认情况下,watch 只会跟踪监听源最外层的引用变化:对于 ref 包裹的对象,只有当整个对象被替换时才会触发;对于 reactive 对象,虽然能监听到第一层属性的变化,但嵌套对象内部的变化不会被追踪。如果希望监听对象所有层级的变化,需要开启 deep: true

import { reactive, watch } from 'vue'

const form = reactive({
  user: {
    name: 'Alice',
    address: {
      city: 'Shanghai'
    }
  }
})

watch(
  () => form,
  (newVal) => {
    console.log('表单任何属性变动都会被捕捉')
  },
  { deep: true }
)

原理:当你设置 deep: true 时,Vue 会在内部递归遍历监听源返回值的每一个属性(在取值阶段),手动触发它们的 getter,从而让所有层级的属性都被当作依赖收集起来。这种方式在处理大型对象时会带来一定的性能开销,因此应避免对深度嵌套且体积庞大的数据使用 deep: true,或使用精准的 getter 替代。

真实场景:一个复杂的配置页面,表单结构是多层嵌套的,当用户修改任意字段时,需要检测表单是否已发生变更(比如激活“保存”按钮)。deep: true 可以胜任,但如果表单非常大,更优的做法是只监听表单的“序列化快照”:

watch(
  () => JSON.stringify(form),
  (newVal) => {
    // 序列化后,任何字段改变都会导致字符串变化
  }
)

立即执行(immediate)

默认行为下,watch 只在监听源的值发生变化时才会执行回调。如果你希望在组件初始化时立刻执行一次回调(例如根据当前值加载数据),可以设置 immediate: true

const userId = ref(1)

watch(
  userId,
  (newId) => {
    fetchUserData(newId)
  },
  { immediate: true }
)

原理immediate 的实现很简单——在 watch 初始化完毕、收集依赖之后,Vue 会立即以当前值作为新值、undefined 作为旧值调用一次回调。因此回调函数中可以安全地使用新值,旧值为 undefined 是预期行为。

注意事项

  • 如果回调中再次修改了监听源的依赖,可能导致递归触发,应小心避免。
  • immediate 非常适合于“监听路由参数并拉取数据”的场景。

刷新时机(flush)

当响应式数据发生变化时,Vue 并不会立即执行所有 watch 回调,而是将它们加入一个调度队列。通过 flush 选项,你可以控制回调在何时被取出执行:

  • 'pre'(默认):在组件更新之前调用回调。此时 DOM 还是旧的,回调中可以安全地读取更新前的 DOM 状态,非常适合那些需要在 DOM 变化前执行预处理操作的场景。
  • 'post':在组件更新之后调用回调。此时 DOM 已经更新,可以在回调中安全地访问最新 DOM(如获取更新后的元素尺寸)。它等价于 watchEffect 配合 { flush: 'post' } 或直接在 setup 里使用 watchPostEffect
  • 'sync':同步执行回调,即数据一变立刻触发,不会加入异步队列。这种方式会牺牲批量更新的性能优化,只有在你确实需要在数据变化后立即执行某些无法延迟的操作(如需要同步获取值的第三方库集成)时才建议使用。

原理:Vue 内部使用一个调度器(scheduler)来控制 effect 的执行时机。默认情况下,组件的渲染 effect 和 watch 回调都具有 pre 优先级,它们会在同一个微任务队列中 flushing,但渲染 effect 比 watch 回调更“晚”执行(渲染在回调之后?实际渲染 effect 的调度是 queueJob,watch 回调也是 queueJob,但 watch 回调的 job 优先级可能相同,但调度顺序是组件更新 before DOM。这里不细究,但结果就是 pre 回调在 DOM 更新前,post 在 DOM 更新后)。设置 flush: 'post' 则会将回调放入 queuePostRenderEffect 中,确保它在 DOM 更新后的微任务里执行。flush: 'sync' 则完全绕过队列,直接同步调用。

代码示例

watch(
  source,
  (val) => {
    // flush: 'pre' 时,这里可以读取旧的滚动位置
    console.log('pre flush')
  },
  { flush: 'pre' }
)

watch(
  source,
  (val) => {
    // flush: 'post' 时,更新后的 DOM 尺寸可以正确获取
    nextTick(() => {
      console.log('post flush (DOM updated)')
    })
  },
  { flush: 'post' }
)

停止监听与副作用清除

watch 函数在组件 setup<script setup> 中调用时,会自动绑定到组件实例,组件卸载时自动停止。你也可以手动停止:

const stop = watch(source, callback)
// 需要时手动停止
stop()

回调函数还可以接收一个 onCleanup 参数(第三个参数),用于注册清理函数。当 watcher 即将重新执行或停止时,这个清理函数会被调用。这让异步副作用(如防抖请求)变得安全:

watch(keyword, (newVal, oldVal, onCleanup) => {
  const timer = setTimeout(() => {
    fetchResults(newVal)
  }, 300)
  onCleanup(() => clearTimeout(timer))
})

实用总结

  • 默认浅层监听:对于基本类型 ref 或具体的属性 getter,直接使用即可;监听整个 reactive 对象时,注意只有第一层属性变化会触发,深层变化请使用 deep: true 或精准 getter。
  • 立即执行immediate: true 让初始化时的数据状态也能被处理,完美对接“根据当前值加载数据”的需求。
  • 控制刷新时机:大部分情况使用默认的 'pre';当需要在 DOM 更新后操作时用 'post''sync' 慎用,它会让状态更新的可预测性变差。
  • 善用清理机制:避免在 watch 回调中留下未清理的定时器和未取消的请求,利用 onCleanup 保持代码健壮。

掌握了这些,你就能够根据实际场景精细地编排副作用,让 watch 成为你最顺手的“响应式扳机”。