watch 是 Vue 中用于主动观察响应式数据变化并执行副作用的 API。与计算属性不同,watch 不返回新的值,而是执行一些命令式的操作(如请求数据、操作 DOM、记录日志)。理解 watch 的底层原理,能帮助我们在复杂场景下精准控制副作用的触发时机,避免常见的“无限循环”或“不触发”问题。
基本用法与依赖追踪
watch 接受一个监听源和一个回调函数。监听源可以是:
- 一个
ref或reactive对象 - 一个 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 成为你最顺手的“响应式扳机”。