Vue3 watch 完全指南:从基础用法到源码原理与避坑实践

📅 发布时间:2026/9/11 1:25:34
Vue3 watch 完全指南:从基础用法到源码原理与避坑实践
在 Vue3 里watch 可能是大家接触最多、但离“真正吃透”最远的一个 API。官方文档写得足够标准但落到真实项目里——监听路由、监听 props、深度监听大对象、防抖搜索、父子组件联动——每个场景都有文档里没写透的坑。这篇文章我打算把 Vue3 的 watch 从基础用法到源码设计、从常见配置项到实战踩坑一次性梳理清楚按“用法 → 场景 → 配置 → 原理 → 排查”这条线来讲希望能帮你建立一套属于自己的一线判断标准。1. 从“能用”到“用好”先搞清楚 watch 的基本形态1.1 四种传参方式先对号入座Vue3 和 Vue2 最大的差别是 watch 从 Options API 里的一个选项变成了 Composition API 里的一个函数。你可以在setup里直接调用它也可以在script setup里直接使用。先用代码把最基础的四种传参方式列出来import { ref, reactive, watch } from vue // 1. 监听一个 ref const count ref(0) watch(count, (newVal, oldVal) { console.log(count changed:, newVal, oldVal) }) // 2. 监听一个 reactive 对象的某个属性 const state reactive({ name: 张三, age: 18 }) watch(() state.name, (newVal, oldVal) { console.log(name changed:, newVal, oldVal) }) // 3. 监听多个来源回调拿到的也是数组 const a ref(1) const b ref(2) watch([a, b], ([newA, newB], [oldA, oldB]) { console.log(a or b changed:, newA, newB, oldA, oldB) }) // 4. 监听一个 getter 函数可以做一些组合计算 watch( () ${state.name}-${state.age}, (newVal, oldVal) { console.log(组合值 changed:, newVal, oldVal) } )这四种写法基本覆盖了日常 90% 的场景。需要注意watch的第一个参数可以是一个 ref、一个 reactive 对象、一个 getter 函数也可以是一个由这些值组成的数组。这个设计本质上是为了适配不同的数据来源形态。有个细节很多人会忽略如果你直接监听一个reactive对象本身而不是 getter那么这个监听会自动变成深度监听并且回调拿不到旧值。这个我下面会专门展开讲。1.2 回调里的新旧值为什么有时候拿不到旧值很多人第一次从 Vue2 切到 Vue3都会遇到一个很奇怪的现象用 watch 监听一个reactive对象按理说回调应该能拿到新值和旧值但打印出来的旧值却和新值一样。比如const state reactive({ list: [] }) watch(state, (newVal, oldVal) { console.log(newVal oldVal) // true两个是同一个对象引用 })原因不复杂watch监听的 source 是响应式对象本身时Vue 会把整个对象作为依赖但旧的快照没有被额外保存所以oldVal和newVal指向的是同一个对象引用。你修改的是对象内部属性对象引用本身没有变因此拿不到真正变化前的“值”。想拿到旧值正确的做法是让 source 变成一个 getter并且在 getter 里返回一个新的拷贝const state reactive({ list: [1, 2, 3] }) watch( () ({ ...state }), (newVal, oldVal) { console.log(old list:, oldVal.list) console.log(new list:, newVal.list) } )通过 getter 返回一个浅拷贝对象每次依赖变化时Vue 都会先执行 getter 生成一个新的快照这样新旧值就是两个不同的对象自然就能拿到差异了。需要注意这里我写的是浅拷贝如果你的数据是多层嵌套还得考虑structuredClone这类深拷贝方案或者精确返回你关心的那一段数据。这个细节直接决定了你在写数据对比、历史记录这类功能时的方案选型。2. 使用场景拆解什么时候该用 watch什么时候该用 watchEffect2.1 监听 props 变化触发异步请求在实际业务里watch 最常见的用途之一就是监听父组件传来的 props然后发请求。典型场景是子组件接收一个idid一变子组件就要重新拉取详情数据。// 子组件 const props defineProps{ id: string }() const detail ref(null) const loading ref(false) watch( () props.id, async (newId, oldId) { loading.value true const res await fetchDetail(newId) detail.value res.data loading.value false }, { immediate: true } )这段代码看起来没问题实际跑起来却会埋雷。假如id连续快速变化两次第一次请求还没返回第二次请求又发出去了那么第一次返回的数据可能会覆盖第二次的结果最终页面展示的数据跟当前的id对不上。这就是前端异步任务最常见的“竞态问题”。解决办法其实不复杂常见做法是维护一个自增请求序号或者用AbortController取消上一次请求let requestSeq 0 watch( () props.id, async (newId) { const currentSeq requestSeq loading.value true const res await fetchDetail(newId) // 如果当前回调已经过期丢弃这次结果 if (currentSeq ! requestSeq) return detail.value res.data loading.value false }, { immediate: true } )这个思路在列表切换、Tab 切换、搜索联想等场景里都通用。很多初学者不知道 watch 回调是支持异步的也不考虑竞态等线上出现“数据闪变”才开始排查那时候成本就高了。2.2 监听路由参数变化别在组件里重复初始化另一个高频场景是路由参数变化。很多人在列表页里写// 错误示范只在 onMounted 里拉一次数据 onMounted(() { fetchList(route.query.page) })然后列表页点击下一页跳到了/list?page2页面数据却没刷新。原因很简单Vue Router 在同一个路由记录之间切换时组件默认会被复用onMounted不会再次触发。这时候就得靠 watch 监听路由参数import { useRoute } from vue-router const route useRoute() watch( () route.query.page, (newPage, oldPage) { // 拉取对应页码的列表数据 fetchList(newPage) }, { immediate: true } )这里有两个细节值得提醒。第一route本身是reactive对象如果你直接watch(route, ...)其实也可以但触发粒度太粗用() route.query.page这种 getter 写法可以做到只有page参数变化时才触发回调。第二如果你在同一个组件里既用了watch又用了onBeforeRouteUpdate要注意两种方式的触发时机差异日常简单场景用 watch 就够了需要做路由级取消逻辑时再看onBeforeRouteUpdate。2.3 watch 和 watchEffect 到底怎么选刚接触 Composition API 的人经常会纠结 watch 和 watchEffect 有什么区别。说直白一点watch你要明确告诉它“监听谁”它只在指定的数据变化后触发。watchEffect它自己不指定监听目标而是“回调里用到哪些响应式数据就自动收集哪些”。第一次会立即执行一次之后任何所用依赖变化回调都会重新执行。// watchEffect 写法用到 state.count 就自动追踪 watchEffect(() { console.log(count is now:, state.count) })两者的底层其实都是 ReactiveEffect区别在于依赖收集的方式 watch 是显式的watchEffect 是隐式的。使用建议我给两条第一如果业务逻辑里需要拿新旧值做对比、防抖、或者只在特定值变化时执行用 watch。第二如果你的副作用逻辑不关心“谁变了”只关心“现在所有关联数据算出来是什么样”比如埋点上报、状态同步、日志输出用 watchEffect 更省事。watchEffect 还有一个好用的点是支持在副作用里执行异步清理。Vue 3.5 之后官方提供了onWatcherCleanup配合watchEffect可以专门处理竞态import { watchEffect, onWatcherCleanup } from vue watchEffect((onCleanup) { // 伪代码发起请求 const controller new AbortController() fetch(/api/data, { signal: controller.signal }) onWatcherCleanup(() { controller.abort() }) })每次数据变化导致 effect 重新执行时上一次的清理函数都会被调用这就把竞态问题的处理责任交给了框架而不是自己维护序号。3. 三个配置项和一个停止方法把 watch 玩明白3.1 immediate、deep、flush 到底控制什么watch 的第三个参数是配置对象最常用的是immediate、deep和flush。immediate: true很好理解在创建监听时立即触发一次回调回调里的旧值是undefined。典型场景就是前面说的 props 初始化加载不需要等值变化才拉第一次数据。deep: true则是把监听变成深度监听。当你直接监听一个reactive对象时deep默认就是true当你监听的是ref且ref.value是一个对象时默认不会深度监听需要显式开启const obj ref({ a: { b: 1 } }) watch(obj, (newVal, oldVal) { // 修改 obj.value.a.b 2 时不会触发 }) watch(obj, (newVal, oldVal) { // 修改 obj.value.a.b 2 时触发 }, { deep: true })flush这个配置项很多人第一眼不知道它是干嘛的。它管的是“回调到底什么时候执行”。Vue3 里有三档pre默认值。组件更新前执行此时 DOM 还没更新。post组件更新后执行此时 DOM 已经完成更新。sync数据变化时同步执行性能开销最大除非你对执行时机有绝对要求否则不建议用。watch(data, () { // 想读取更新后的 DOM必须用 post console.log(el.value.offsetHeight) }, { flush: post })这里有一个 Vue2 到 Vue3 的行为变化需要特别留意Vue2 的watch回调默认在组件更新后执行而 Vue3 默认改成了组件更新前。如果你是从 Vue2 升上来的项目这个变化很容易导致原有的 DOM 操作逻辑出问题。遇到“回调里访问 DOM 拿到的是旧值”这种诡异现象第一反应应该检查flush配置。3.2 组件卸载之后回调还在跑说说怎么停掉 watchVue 有一个很贴心的设计在组件的setup里创建的watch会随着组件实例的卸载自动停止你不需要手动清理。但如果你的 watch 是在组件外部、或者在异步逻辑里创建的就必须手动停止。// 在 setTimeout 或事件回调里创建 watch let stopWatch function startWatch() { stopWatch watch(source, callback) } function stop() { stopWatch stopWatch() }更隐蔽的场景是在onMounted或者某个事件处理函数里创建了一个 watch组件卸载时这个 watch 不会自动销毁。这种情况下比较稳妥的做法是把stop函数存下来在onUnmounted里统一清理let stopWatch: (() void) | null null onMounted(() { stopWatch watch(source, callback) }) onUnmounted(() { stopWatch?.() })这个坑在项目里经常出现在“弹窗组件”“长列表组件”“第三方 SDK 封装”里。原因很好理解只要 watch 的实例不是组件作用域下创建的框架就不会帮你托管生命周期。3.3 deep 监听大对象的性能陷阱deep: true用起来方便但代价不小。深监听底层要靠traverse递归遍历对象的每一个属性。如果你监听的是一个几千条数据的列表或者一个层级很深的后端返回对象每次数据变化都会重复遍历所有属性页面一旦有高频更新性能问题会非常明显。更好的做法是精确监听你关心的字段而不是整对象深监听// 不建议直接深度监听整个大对象 watch(formState, handler, { deep: true }) // 建议只 getter 出你关心的字段 watch( () formState.contact.phone, (newVal) { // 只处理 phone 的变化 } )如果确实需要监听整个对象的变化且对性能敏感可以考虑“节流 深比较”的策略回调里不要把整个对象重新渲染到界面上而是先做一次差异计算只更新变化过的部分。4. 看看源码watch 是怎么被设计出来的4.1 基于 ReactiveEffect scheduler 的机制很多人在面试或日常阅读源码时一旦接触到 watch 的底层就会觉得绕。其实核心就一句话watch 内部创建了一个 ReactiveEffect把这个 effect 的 scheduler 定义成你要执行的回调逻辑当依赖被触发时响应式系统会执行 scheduler从而触发回调。先看一个简化版本的思路// 极简伪代码帮助理解 watch 的设计 function watch(source, callback, options) { // 1. 把 source 归一化成 getter 函数 const getter () source() // 2. 创建一个 ReactiveEffect const effect new ReactiveEffect(getter, () { // scheduler依赖变化时把回调扔进调度队列 queueJob(() { const newVal effect.run() callback(newVal, oldVal) oldVal newVal }) }) // 3. 第一次 run收集依赖拿到旧值 let oldVal effect.run() }真实源码里还有deep、flush、immediate、onTrack等一堆参数要处理但整体骨架就是这么设计的。明白了这个机制很多行为就都能解释通了为什么 getter 拿旧值要用闭包存一下因为旧值本质是上一次 effect.run 的返回值。为什么flush: post能让回调在 DOM 更新后执行因为 scheduler 把任务丢进了post队列。为什么sync性能差因为同步执行意味着每次依赖变化都立即跑一次回调不走批量调度。用一句话概括watch 是响应式副作用机制在业务层的一个封装。理解这一层再用它就会非常有底气。4.2 watch 和 computed 底层差异以及为什么 watch 不能取代 computed很多人会把 computed 和 watch 混着用其实两者的定位完全不同。它们底层都依赖 ReactiveEffect但设计目标是反过来的computed 是“惰性求值 缓存”只有当读取.value时才会求值并且依赖不变时不会重新计算返回缓存结果。watch 是“副作用触发”它不关心返回值不缓存结果数据一变就执行你给的回调。举个例子购物车总价// computed依赖变化自动重算但只有被读取时才重算 const totalPrice computed(() cartList.value.reduce((sum, item) sum item.price * item.count, 0) ) // watch每次数据变化都执行副作用 watch(totalPrice, (newPrice) { console.log(价格变化准备上报埋点, newPrice) })computed 适合“根据状态派生新状态”watch 适合“状态变化后执行动作”。你把一个需要展示的数据逻辑用 watch 去维护会非常痛苦因为你要自己管理临时变量和赋值时机反之如果你在 computed 里写console.log做埋点依赖不变化时计算逻辑可能根本不执行埋点就丢了。4.3 Vue 3.5 的新能力onWatcherCleanupVue 3.5 之后官方补了一个迟到但很实用的能力onWatcherCleanup。它解决的是 watch 和 watchEffect 回调里“副作用清理”的问题。过去要实现副作用的清理常规做法是在回调里返回一个清理函数watch(source, (newVal, oldVal, onCleanup) { const timer setTimeout(() { // do something }, 500) onCleanup(() clearTimeout(timer)) })Vue 3.5 以后官方推荐用onWatcherCleanup显式注册清理函数代码更直观而且和组合式 API 的风格更统一import { watch, onWatcherCleanup } from vue watch(source, () { const timer setTimeout(() { // do something }, 500) onWatcherCleanup(() clearTimeout(timer)) })这个 API 在需要频繁取消异步任务、清理定时器、关闭 WebSocket 连接的场景里非常有用。5. 实战把 watch 放进真实业务里这些问题没人提醒你5.1 常见问题速查表我把这几年线上项目里遇到的 watch 相关问题整理成了一张速查表排查时可以先对照一遍。问题现象根本原因解决方案回调里拿不到旧值source 是 reactive 对象或 getter 返回的是同一个引用getter 里返回拷贝或精确返回原始类型字段深度修改对象内部属性不触发source 是 ref且没开deep给 watch 加{ deep: true }或改用 getter 精确监听数据变了但页面没更新回调里读 DOM 是旧值默认 flush 是pre回调在 DOM 更新前执行设置{ flush: post }监听 props 或路由参数时数据闪变异步请求存在竞态用自增序号或 AbortController 清理过期任务组件卸载后日志还在打印watch 在异步/事件回调里创建不属于组件作用域手动保存 stop 函数并在onUnmounted中停止大对象 deep 监听导致卡顿deep 递归遍历开销过高改为 getter 精确监听必要时用节流immediate 回调里旧值不是预期的immediate 首次执行时旧值必然是 undefined代码里对 oldVal 做判空处理watch 在script setup之外没法用它是 Composition API不是全局方法确认 import 路径或者使用getCurrentInstance做侵入式封装不推荐5.2 我踩过的坑防抖、多依赖、父子组件传值先说防抖搜索。很多人会想着在 watch 里直接防抖watch(keyword, (val) { if (timer) clearTimeout(timer) timer setTimeout(() { requestSearch(val) }, 300) })思路没错但有一个细节容易忽视如果需要拿新旧值做判断比如“用户删除了关键词和输入了新关键词走不同逻辑”因为已经过了 setTimeoutoldVal和newVal的关系就不那么直观了。我更建议把防抖的场景拆成两层第一层 watch 负责拿到最新值第二层用函数节流或防抖去控制请求发送。这样逻辑更干净也方便测试。再说多依赖。watch 的数组传参确实好用但要注意回调参数结构watch([keyword, page], ([newKeyword, newPage], [oldKeyword, oldPage]) { // 数组解构的顺序要和 source 数组保持一致 if (newKeyword ! oldKeyword) { page.value 1 } })这里有个实际问题当keyword和page同时变化时回调只会触发一次但两个新值和两个旧值都会拿到。如果你需要判断“到底是谁触发的变化”需要在回调里做差异比较没有偷懒的办法。最后说父子组件传值。父组件把一个对象传给子组件子组件想监听这个对象的变化。我最开始写的是watch(props.formData, (newVal) { // 用 newVal 去初始化内部状态 })结果发现父组件只是改了formData.name子组件却拿不到预期的新对象。原因和 1.2 节说的一样props.formData是 reactive 对象直接监听时新旧值指向同一引用再加上引用类型在父组件里的更新方式不同坑就更深了。后来我的经验是在子组件里不要直接监听整个 props 对象而是明确监听自己关心的字段或者用 copy 的方式初始化内部数据再通过事件向父组件同步。像这样// 子组件监听明确字段 watch( () props.formData.name, (newName) { // 更新内部状态 } ) // 或者内部 state 初始化从 props 拷贝 const internalForm reactive({ ...props.formData }) watch( () props.formData, (newData) { // 当父组件整体替换对象时才同步一次 Object.assign(internalForm, newData) } )关于父组件整体替换对象和修改属性的差别我个人的体会是很多 Vue 初学者在这块被卡住的时候第一反应是怀疑 watch 写错了但其实问题出在“引用类型在父组件里被原地修改还是被整体重新赋值”上。前者能触发子组件 watch 的深度监听后者才是普通监听能感知到的变化。所以排查时先看一下父组件里对数据的操作方式会省下很多时间。再补充一个关联实践在 Pinia 里也是同样的逻辑。你可以在 store 外部 watch 某个 state比如监听用户登录态变化做页面联动import { useUserStore } from /stores/user const userStore useUserStore() watch( () userStore.isLoggedIn, (newVal) { if (newVal) { // 拉取用户信息 } else { // 清理本地状态 } } )Pinia 的 state 本质上也是 reactive 对象所以前面讲的所有引用类型注意事项在这里全部适用。做前端这几年我越来越发现 watch 其实是一个“越用越深”的 API。表面上看它只是“监听数据变化然后执行回调”但如果你能把新旧值机制、flush 的执行时机、依赖收集的原理这些底层逻辑都吃透那么在业务里遇到再诡异的响应式问题你也能很快定位到是哪一环出了偏差。希望这篇文章能帮你把 Vue3 的 watch 用得更顺手一些。