Vue3侦听器进阶:watch/watchEffect、flush时机、深度监听与竞态处理
今天是我第五阶段 Vue3 核心深挖的第 75 天这一阶段的目标是把手底下的响应式机制挨个拆干净再装回去。前面几十天把 ref、reactive、computed、effect 调度这些大件都过了一遍轮到侦听器进阶的时候我原本以为两三天就能收工——毕竟watch的基础用法谁不会呢可真把watch和watchEffect丢进复杂业务里折腾一圈才发现文档没细讲的地方全是坑同样的代码在列表页没问题换到详情页就疯狂触发回调里明明想拿 DOM 最新高度拿到的一瞬间页面还没渲染完组件卸载之后旧的请求结果还在往仓库里塞。这篇文章就把我这几天整理的侦听器进阶笔记完整摊开包括 API 分工逻辑、flush 三种时机、深度监听的旧值陷阱、onCleanup 竞态处理以及监听器的生命周期治理适合已经会写基础watch、准备上真实业务的读者。1. 第75天先理顺侦听器到底在听什么1.1 基础会了复杂业务还是会写崩的原因很多人学watch就停在监听一个 ref回调里拿新值和旧值这一步然后就开始写业务。写简单 demo 确实够用但一旦字段变多、请求变复杂各种莫名其妙的问题就冒出来了。我复盘了一下根子在于大家把侦听器理解成了浏览器事件监听那种东西——以为它像addEventListener一样每次变化都立刻调用回调。实际上 Vue3 的侦听器是建立在响应式副作用机制上的回调本身是一个 effect它通过读取响应式数据完成依赖收集数据变化后再由调度器统一安排执行。这个区别带来的直接后果就是回调不一定是同步执行的、不同依赖的变化可能会被合并成一次触发、对象类型的旧值拿到的往往不是你以为的那个快照。这三条每一条都可能在真实业务里反咬你一口。我现在带小伙伴做项目会先让他们把watch 回调 副作用函数 调度队列这句话记住再去写业务。很多时候你不是不明白 API而是没搞懂它在整个响应式链路里的位置。1.2 从依赖收集到回调触发一次完整的监听链路要理解进阶内容最好先在心里过一遍完整的触发链路。当你写watch(() state.count, callback)时Vue 内部大致做了这几件事创建一个 effecteffect 的 run 函数里先执行你传的 getter() state.countgetter 读取state.count时触发依赖收集把当前 effect 记录到 count 的依赖集合里。当state.count被修改时触发响应式系统的 trigger 逻辑把依赖集合里的 effect 拿出来不是直接调用而是交给调度器。调度器把这次回调任务推进一个任务队列并在微任务里统一冲刷队列。所以同一轮事件循环里多次修改 count回调可能只执行一次并且是在一段微任务里批量执行。这里最关键的理解是侦听器回调的触发不是响应式的即时反应而是经过调度器排队后的批处理结果。明白了这一点后面讲 flush 时机、讲队列合并就都好懂了。提示你自己写复杂逻辑时不要假设每次数据变化回调一定执行一次。同一 tick 内多个修改、多次 mutations最终回调执行的时机和次数由调度规则决定而不是由你的赋值语句数量决定。2. watch 和 watchEffect 的分工逻辑选错API的代价比想象中大2.1 一个 effect 底子两种 API 形态watch和watchEffect在源码层面是同一套东西最后都会创建一个 effect 实例区别主要在于依赖的来源和回调的形态。watch的特点是需要你手动声明监听源ref、reactive、getter、数组都行并且默认是惰性的——数据不变它不执行只有变化了才跑。回调里能拿到(newVal, oldVal)这也是大多数人最先学会的用法。watchEffect则是把声明依赖这件事完全省略了它会立刻执行一次回调执行过程中访问到了哪些响应式数据哪些就自动成为它的依赖。回调本身没有新旧值参数也不需要你去手动指定源。从原理上说watch的底层实现其实就是在内部做了一个getter effect的组合先把你的监听源读一遍拿到旧值再在 effect 回调里读一遍拿到新值然后对比。这也是为什么watch比watchEffect多一点开销——它多维护了一份上一次取值结果用于比较。2.2 什么场景用哪个一张对照表我在实际项目里不断踩坑之后总结了一套简单的判断逻辑直接看表判断维度watchwatchEffect回调是否需要旧值需要拿(newVal, oldVal)不需要没有旧值概念是否希望立即执行一次默认不执行用immediate: true打开总是立即执行依赖来源手动指定精确控制回调内自动收集无法精确指定多个响应式源用数组手动组合天然支持依赖自动追踪典型场景监听路由参数变化、表单字段联动、做前后值对比请求联动、批量副作用、日志上报、需要依赖变了就跑的场景举个最典型的例子监听路由参数变化重新拉详情数据你用watch因为你要拿新旧参数判断是不是同一个详情页而监听一个搜索关键字去请求联想结果你用watchEffect更省事因为搜索框的 value 被读取的同时依赖就自动建立了还省掉了第一次手动调用的代码。watchEffect最常见的误用场景是有人拿它当watch用明明需要旧值却拿不到只能自己额外声明一个变量去缓存上次的值。这不叫灵活这叫把简单问题搞复杂了。反过来watch的误用场景是监听一大堆数据其实回调里只用到一个最终结果比如好几个参数拼成一个请求条件这种情况下用watchEffect自动追踪明显更轻。2.3 数组源的两个隐藏细节很多教程会提watch([a, b], callback)这种多源写法但有两个细节很少讲清楚。第一个细节是回调的newVal和oldVal都是数组需要自己按位置解构。写法看起来像这样watch([count, double], ([newCount, newDouble], [oldCount, oldDouble]) { console.log(newCount, newDouble, oldCount, oldDouble) })如果你不解构直接拿第一个参数它是个数组后面访问属性全是undefined这种 bug 很难一眼看出来。第二个细节更重要数组里的任意一个源发生变化整个回调就会执行而不是等所有源都变了才执行。所以你不要指望这个写法能实现等两个条件都满足再通知我的语义。如果你确实要等几个依赖都稳定下来再跑逻辑正确的做法是在回调里自己加判断条件watch([keyword, page], ([kw, pg]) { if (!kw || pg 0) return fetchList(kw, pg) })本质上就是把聚合逻辑放到业务层自己处理数组源只负责收集触发信号。3. flush 时机pre、post、sync 三种模式的真实差异3.1 默认的 pre 为什么DOM 还没更新watch和watchEffect的默认 flush 都是pre意思是回调会在组件更新之前执行。很多人第一次踩到这个坑时的场景几乎一模一样监听一个数据变化然后在回调里读取某个 ref 对应的 DOM 节点高度结果读到的还是旧值。原因就是pre的把回调排进了 Vue 内部调度队列而这个队列冲刷的时机早于组件 render 阶段。换句话说你改数据的那一刻DOM 还没有重新渲染回调里读到的自然是旧状态。被这个坑坑过一次之后你就会明白pre适合做的事是基于新数据做逻辑预处理——比如根据新值更新另一个响应式状态、重置分页、记录操作日志。凡是涉及 DOM 测量、读取更新后节点状态的默认时机都不合适。3.2 post拿更新后 DOM 的正确姿势当你需要读取组件更新后 DOM 的结果把 flush 改成post就行watch(list, async (newList) { // 此时组件已经完成本轮渲染 const height scrollArea.value?.offsetHeight adjustHeight() }, { flush: post })这个写法等价于在默认回调里套一层nextTick但flush: post是声明式的语义更清楚代码也更干净。我在真实项目里经常这么干监听表格数据变化后等表格渲染完再去调整滚动条位置或者根据渲染后的实际高度做分页加载判断。要注意的是post也不是万能药。如果你的回调里有更耗时的同步操作它同样会阻塞后续渲染流程只是执行顺序上保证在 DOM 更新之后而已。真正的性能问题还是要靠把耗时逻辑移到异步任务里去。3.3 sync立即执行的代价与唯一适用场景flush: sync是三种模式里最简单的数据一旦变化立刻同步执行回调完全不走调度队列。听起来最实时但它彻底破坏了 Vue 的批量更新机制。默认情况下一次事件循环里对同一个数据改十次回调可能只执行一次改成 sync 之后十次修改执行十次回调每次都要做一堆逻辑。如果你在回调里还触发了其他响应式数据变化那可能会引起连锁响应性能直接崩。我这里给一个忠告生产环境几乎用不到sync。它唯一的实用场景是测试环节——你希望每次赋值后立刻断言 callback 状态避免等微任务。业务代码里如果有必须同步拿到结果的需求通常说明设计上已经依赖了副作用顺序这时候更应该考虑用 computed 或者重构逻辑而不是让侦听器开同步模式。注意flush 的选择不是性能调优的第一手段。遇到回调时机不对的问题先想想是不是该用 computed 派生状态、是不是该调整业务逻辑层次最后才轮到调整 flush。4. 深度监听与旧值陷阱reactive 对象上最容易被忽略的两个问题4.1 隐式深度监听Vue 替你做了但你要付性能账当监听源直接传一个reactive对象时Vue 会隐式开启 deep 监听。也就是说const state reactive({ user: { name: 张三, detail: { age: 18 } } }) watch(state, (newState) { console.log(state 深层次变化也会触发) }) state.user.detail.age 20 // 会触发这很方便但代价是Vue 在收集依赖时要递归遍历整个对象的嵌套结构。如果这是一个巨大的树形数据或者一个很长的列表每次变化时的收集和比较成本都不低。我在项目里踩过的典型问题是有人习惯性地watch(state.list, ...)这个 list 里每一项都有几十个字段、上千条数据然后只看 list 的长度变化。结果每次列表里任意一项的任意字段变化整个回调都在跑页面明显掉帧。这种场景正确做法是监听精确的派生表达式watch(() state.list.length, (len) { // 只有 length 变化才触发 })一句话总结需要深层次监听时优先用 getter 精确到你要的字段而不是对整个对象开无差别扫描。4.2 为什么 newVal 和 oldVal 总是同一个引用这是进阶玩家最常讨论的坑直接watch(reactiveObj, cb)然后在回调里修改reactiveObj的某个属性打印出来的newVal和oldVal是同一个引用内容看起来一模一样。原因在于 Vue 判断值是否变化用的是Object.is比较而对象本身引用没有变变的只是它的内部属性。所以对对象类型来说oldVal并不是变化前的快照它指向的还是同一个对象。你在回调里读oldVal.name读到的其实是已经被修改之后的newVal.name。很多人在找改动前数据时的第一反应是开deep: true但这解决不了问题因为 deep 只是让监听更深并不会帮你做拷贝。想拿到真正的旧状态你必须自己制造一个变更引用的源。4.3 用 getter 造新引用方案与代价最直接的方案是让 watch 的源返回一个新对象watch( () ({ ...state.user.detail }), (newDetail, oldDetail) { // newDetail 是当前快照oldDetail 是上一次快照 } )getter 每次执行都展开成一个新对象引用必然变化Object.is比较自然永远不相等回调每次都触发并且因为每次产生的都是独立副本oldDetail才是真正意义上上一次的值。但这个方案有两个代价一是每次触发都要做一次浅拷贝对象很深很大时拷贝成本不低二是引用每次都变无论内部字段是否真的变化回调都会执行。如果你只需要监听某一个具体字段那不如直接监听字段本身watch(() state.user.detail.age, (newAge, oldAge) { console.log(newAge, oldAge) // 基础类型值新旧清晰 })基础类型作为 getter 返回值时新旧值就是普通值比较最干净也最省心。所以我的习惯是对象比较尽量拆成基础类型字段的监听拆不了再考虑用浅拷贝源最后的兜底才是对整个对象开深监听。另外如果你用的是 Vue 3.5 及以上版本deep选项除了true还可以传一个数字表示最大遍历深度。比如deep: 2就只遍历两层深层变化不触发能有效控制大对象的监听开销。升级版本后可以检查一下业务里那些其实是上面两层够用了的场景换成数字深度省不少性能。5. 竞态处理onCleanup 是进阶侦听器的核心武器5.1 一个典型的搜索联想竞态场景先来看一个真实到不能再真实的场景输入框每变化一次就请求一次联想结果。用户打字速度很快第一次请求还没回来第二次请求已经发出去了。如果网络的响应速度不一致第一次请求可能后回来直接把第二次请求的结果覆盖掉——用户看到的就是我打的是vue3联想的却是vue的旧结果。这本质上是异步竞态发出去的请求顺序和返回顺序不一致旧响应用了新状态。传统的解决思路是在外部声明一个全局的cancelled状态每次启动新请求前把上一个标记为作废。手工写几回你就知道这种标志位代码散得到处都是很容易漏。5.2 onCleanup把清理逻辑内嵌到监听生命周期watchEffect的回调函数有一个特殊参数onCleanup它接收一个清理函数。Vue 会在以下时机执行这个清理函数回调下一次执行之前先把上一轮的副作用清掉监听器被手动停止时组件卸载导致监听器自动停止时有了它上面那个标志位逻辑就可以完全收拢到回调内部watchEffect((onCleanup) { let cancelled false onCleanup(() { cancelled true }) fetch(/api/search?q${keyword.value}) .then(res res.json()) .then(data { if (!cancelled) { results.value data } }) })每次关键字变化触发新一轮执行时上一轮的cancelled会被置为 true旧的 Promise then 里发现已经作废就不再更新 results。组件卸载时同样会执行一遍清理避免卸载后还用旧结果污染状态。关键点在于onCleanup必须在回调的同步执行阶段调用注册不能在异步回调里注册。因为它登记的是一次当前这一轮执行的清理逻辑Vue 需要在下一轮开始前拿到它。如果你在 then 里面才调用Vue 根本不会知道存在这个清理函数。5.3 防抖与丢弃过期响应的完整组合onCleanup还能配合防抖一起使用。真实的搜索场景通常是既要防抖减少请求次数又要丢弃过期响应。下面这段是我在项目里实际用的模式let debounceTimer null watch(keyword, (newKw, oldKw, onCleanup) { clearTimeout(debounceTimer) let cancelled false onCleanup(() { cancelled true clearTimeout(debounceTimer) }) debounceTimer setTimeout(async () { const res await fetch(/api/search?q${newKw}) if (!cancelled) { results.value res } }, 300) })注意这里的onCleanup是watch回调的第三个参数同样支持。上一轮还没执行的 setTimeout 会在下一轮清理时被 clearTimeout 掉已经发出去的请求则通过cancelled标记丢弃结果。我曾经试过把这一切都用外部状态管理来做最后发现代码散到三个文件里还要考虑各种边界条件。而onCleanup的好处是把启动一轮副作用和撤销这一轮副作用绑定在同一个作用域里读代码的人一眼就能看出清理逻辑和业务逻辑的对应关系。这种局部性是手写全局标志位替代不了的。6. 监听器的生命周期治理自动停止之外的边界情况6.1 setup 里创建的监听为什么自动被清理很多人默认组件卸载时 watch 会自动停止这个说法不算错但前提是监听器在一个受组件作用域管制的上下文里创建也就是组件的 setup 内。Vue3 在组件实例上维护了一个 effect scopesetup 里所有通过watch、watchEffect、computed创建的响应式副作用都会自动注册到这个 scope 里。组件卸载时 scope 统一回收监听器随之停止。这是为啥你平时写业务不用手动stop也不会泄漏的原因。但注意这个托管只对 setup 同步执行阶段创建的监听有效。一旦监听器的创建脱离了组件实例的 scope自动回收就不存在了。6.2 脱离 setup 的监听器手动 stop 与 effectScope我排查过不少组件卸载后接口还在请求、控制台还有警告的案例最后定位到的原因几乎都是同一个有人在组件外部写了这样的代码。比如在一个纯工具函数里// 某个 utils.js export function setupAutoSync(store) { watch( () store.filters, () { syncRemote(store) } ) }组件里调用setupAutoSync(store)之后这个 watch 是在组件 setup 的执行上下文里创建的看起来会跟着组件卸载。但如果这个函数是在setTimeout、事件回调、或者路由切换后异步调用的创建 watch 的那一刻已经不在组件实例的 scope 中了组件卸载时它根本不会被回收。结果就是组件销毁了依赖还在响应式链路也不断内存占用和无效请求就一直持续下去。对于这种需要在组件外部创建监听器的场景正确做法是用effectScopeimport { effectScope } from vue export function setupAutoSync(store) { const scope effectScope() scope.run(() { watch( () store.filters, () { syncRemote(store) } ) }) return () scope.stop() }effectScope.run()会把其中创建的响应式副作用收集到一个独立 scope 里返回一个停止函数。组件卸载时调用它整个 scope 内所有监听器一起清理。这个模式在做通用工具、插件、跨页面共享逻辑时非常实用。6.3 手动 stop 的正确时机watch和watchEffect的返回值都是一个 stop 函数手动调用就能主动终止监听。最典型的应用场景是做一次性监听某个条件满足后就不再需要监听了。const stop watch(someRef, (val) { if (val ready) { // 业务处理 stop() } })在回调里调用自身的 stop 是安全的因为 stop 只会终止后续的监听当前这一轮回调不受影响。但要注意别在 stop 之后还继续依赖这个 watch 的清理函数——停止后注册的 onCleanup 不会被执行这一点偶尔会坑到人。6.4 排查泄漏的一个实用思路如果你怀疑项目里有监听器泄漏我的排查顺序是这样的先看组件卸载后属于它的请求还在不在发在的话打开 Vue DevTools 检查响应式依赖里还有没有该组件相关的 effect然后全局搜索所有在setTimeout、Promise、事件回调、异步函数里创建的watch/watchEffect逐一确认它们是在 scope 内还是 scope 外最后用onScopeDispose在工具函数里挂清理逻辑确保 scope 被停止时能做资源释放。这一套流程走下来绝大部分监听器泄漏都能定位。顺便说一句很多内存泄漏其实不是 Vue 的问题而是把响应式副作用的生命周期和组件生命周期做了错误的绑定预期。搞清楚 scope 的边界这类问题基本可以绝迹。最后再分享一个小技巧排查竞态问题时别急着上onCleanup。先确认这个副作用的生命周期有多长——它是跟着单个数据变化走的还是跟着组件生命周期走的还是跟着一个独立的业务 scope 走的。想清楚这一层之后无论是选 watch 还是 watchEffect、开 flush 还是算清理逻辑你都会有一个明确的判断依据。我这几天的侦听器进阶笔记到这里就结束了里面的代码都是真实项目里跑过的写法复制即可改造但也建议你带着自己的业务场景重新推演一遍——进阶的路从来都是踩出来的。