今天是我第五阶段 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 内部大致做了这几件事:
- 创建一个 effect,effect 的 run 函数里先执行你传的 getter(
() => state.count),getter 读取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 什么场景用哪个:一张对照表
我在实际项目里不断踩坑之后,总结了一套简单的判断逻辑,直接看表:
| 判断维度 | watch | watchEffect |
|---|---|---|
| 回调是否需要旧值 | 需要,拿(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 scope,setup 里所有通过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 中了,组件卸载时它根本不会被回收。结果就是组件销毁了,依赖还在,响应式链路也不断,内存占用和无效请求就一直持续下去。
对于这种需要在组件外部创建监听器的场景,正确做法是用effectScope:
import { 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 还是算清理逻辑,你都会有一个明确的判断依据。我这几天的侦听器进阶笔记到这里就结束了,里面的代码都是真实项目里跑过的写法,复制即可改造,但也建议你带着自己的业务场景重新推演一遍——进阶的路,从来都是踩出来的。