首页 / 资讯中心 / 文章详情

Vue watch监听数组第一项新旧值相同的真相与排查指南

Vue watch监听数组第一项新旧值相同的真相与排查指南 ★ FEATURED ARTICLE
组里有个前端同事跑过来问我 watch 监听数组的第一项结果新值和旧值打印出来居然一模一样是不是 Vue 出了 bug 他把代码发过来第一行就是watch(() list.value[0], ...)后面跟着一个修改第一项内部属性的操作。这个问题我在项目里见过不止一次而且不光是新人写了三五年 Vue 的同学也经常被绕进去。它背后牵扯到 watch 监听器对新旧值的语义约定、响应式系统的依赖收集机制以及浅监听和深监听的差别。这篇就把这个问题彻底拆开讲清楚顺便把另一个高频问题监听器未启动的完整排查链路一起整理了给正被 watch 折磨的同学一份可以直接照着排查的参考。1. 先厘清核心watch 回调里的 newVal 和 oldVal 到底是什么1.1 现场还原第一项的新旧值为什么一样先看同事这段代码我简化一下import { ref, watch } from vue const list ref([ { id: 1, name: a }, { id: 2, name: b } ]) watch( () list.value[0], (newVal, oldVal) { console.log(newVal oldVal ?, newVal oldVal) console.log(newVal, oldVal) }, { deep: true } ) list.value[0].name c控制台打印出来的结果很诡异newVal oldVal ? true { id: 1, name: c } { id: 1, name: c }新旧值都是一个对象而且name都变成了c旧值好像丢了。很多人第一反应是Vue 有问题但严格说这是 watch 在设计上对旧值的约定它保存的是触发回调那一刻依赖对应的引用而不是这个引用对应内容的快照。当list.value[0].name被改成c时watch 内部并没有在修改前把list.value[0]深拷贝一份存起来。它只是在依赖变化后重新执行了一次 getter拿到新的list.value[0]再把上一次缓存的值当作旧值。关键在于新旧两次拿到的都是同一个对象引用——因为list.value[0]指向的数组元素对象从头到尾没有变过变的只是这个对象内部的name字段。1.2 为什么数组第一项最容易触发这个问题如果监听的是整个数组然后整体替换数组新旧值反而是不同的watch(list, (newVal, oldVal) { console.log(整体替换后引用是否变化:, newVal ! oldVal) }) list.value [{ id: 3, name: d }]这时list.value被赋了一个新的数组对象watch 新旧值自然不同。但很多人监听第一项时用的是() list.value[0]这个 getter 返回的是数组里的元素对象而不是数组本身。只要不执行list.value.splice(0, 1, { ... })来替换第一个元素list.value[0]的引用就不会变。再叠加一个因素为什么偏偏经常是第一项有一种特殊感因为在表格、列表、轮播这类业务里第一项经常被当作当前选中项单独取出来监听。它有时是一个基本类型有时是一个对象。如果第一项是对象newVal oldVal的迷惑性就特别强如果是基本类型字符串或数字反而不会有这个问题因为基本类型按值比较newVal和oldVal就是两个不同的原始值。Vue 2 里同样存在这个问题并不是 Vue 3 引入的。Vue 2 的watch底层通过Watcher的value字段保存上一次的求值结果这个value通常也是一个引用。所以只要你不主动做快照修改对象内部属性这种操作在几乎所有主流响应式框架里都会遇到新旧值指向同一对象的情况。1.3 那怎么拿到真的旧值如果你确实需要对比修改前后的内容差异可以选择下面几种方案按场景取舍。方案一在 getter 里返回基本类型而不是整个对象。watch( () list.value[0]?.name, (newName, oldName) { console.log(name 从 ${oldName} 变到 ${newName}) } )这是最省事的做法因为基本类型的旧值就是旧值不会出现引用相同的问题。缺点是如果第一项对象里有多个字段需要对比就得写多个 watch或者返回一个计算拼接值比较笨重。方案二手动保存快照。let oldFirst JSON.parse(JSON.stringify(list.value[0])) watch( () list.value[0], (newVal) { const old oldFirst oldFirst JSON.parse(JSON.stringify(list.value[0])) console.log(旧值:, old) console.log(新值:, newVal) }, { deep: true } )这里用JSON.parse(JSON.stringify(...))做一次深拷贝每次回调执行后把当前值作为下一次的旧值。问题在于性能如果第一项对象嵌套层数极深、字段极多深拷贝的开销不能忽略。我自己一般只在小对象、低频修改的场景用比如表单里的一个配置项。更大、更频繁的对象建议用structuredClone它能在支持的环境里显著提升性能let oldFirst structuredClone(list.value[0]) watch( () list.value[0], (newVal) { const old oldFirst oldFirst structuredClone(list.value[0]) // 使用 old 与 newVal 做对比 }, { deep: true } )方案三用事件源头去打补丁。如果修改动作是你自己代码发起的可以在修改前把需要的旧值存到一个局部变量里完全不需要让 watch 去猜。这个思路最朴素也最不容易出错。2. 监听器未启动从响应式源头到回调触发的完整排查链路相比新旧值一样监听器未启动是另一个高频问题症状表现为数据明明变了watch 回调就是不执行。很多人开始怀疑生命周期、怀疑组件缓存其实大部分情况都出在前面几环。2.1 第一关你监听的对象真的是响应式对象吗这是最基础也最容易忽略的点。普通变量本身没有依赖收集能力let count 0 watch(count, () { console.log(永远不会执行) }) count 1watch的第一个参数如果是一个普通数字Vue 内部会把它包装成 getter但这个普通数字根本不在任何响应式系统里之后count 1也不会触发任何依赖通知。这种写法在 Vue 2 和 Vue 3 里结果一样回调不执行。另一种情况更隐蔽。用reactive创建的响应式对象如果后面把它整个重新赋值给一个新对象会丢掉响应式连接let state reactive({ name: a, age: 1 }) watch(() state.name, (val) { console.log(触发, val) }) // 这样写是对的修改的是内部字段 state.name b // 但这样写是错的state 被替换成一个新对象watch 依赖的是旧对象的 name state { name: c, age: 2 }如果state是通过ref定义那么必须通过state.value ...来整体赋值。很多人从 Vue 2 转过来习惯this.state newObj在 Vue 3 的setup里就容易踩到。正确的做法是Object.assign(state, newObj)保留响应式对象的引用只更新字段。2.2 第二关source 写法和依赖收集范围是否匹配watch 的第一个参数支持 ref、reactive、getter 函数和数组。最容易出问题的写法是把整个响应式对象传进去但又在回调里去取某个子字段const state reactive({ user: { name: a, age: 10 } }) watch(() state.user, (newVal, oldVal) { console.log(user 变了) })如果只修改state.user.name上述 getter 返回的是state.user对象引用只要不把state.user整体替换回调不会触发。这里 watch 只收集了state.user这一层依赖并没有深入收集state.user.name。想监听对象内部字段的微妙变化要么写成() state.user.name要么给 watch 加{ deep: true }。还有一种漏收集情况getter 里用了可选链但取值路径中途返回了 undefined。watch( () state.user?.address?.city, (newVal) { console.log(city 变化, newVal) } )当state.user.address还不存在时getter 访问undefined.address其实已经中断Vue 只能收集到state.user和state.user.address两层的依赖。如果之后从后端拿到数据一次性给state.user配了一个完整的新address对象那么state.user.address这个引用变了回调会触发这没问题。但如果你只是state.user.address { city: 杭州 }getter 求值时访问到的是undefined依赖收集不完整后续修改state.user.address.city就可能触发不了。这种场景建议监听一个稳定的路径例如先确保address有默认值或者直接deep: true。2.3 第三关生命周期、组件缓存与异步创建很多人把 watch 写在事件回调里或者 setTimeout 里这会导致一个非常隐蔽的问题。看下面这个例子setup() { let stopWatch null onMounted(() { setTimeout(() { stopWatch watch(someRef, handler) }, 1000) }) onUnmounted(() { stopWatch stopWatch() }) }这段代码如果忘了在onUnmounted里调用stopWatch()组件销毁后这个监听器还会一直存活。它的依赖引用可能继续被修改回调继续执行造成幽灵更新——页面已经切走了还在打印日志甚至发请求。setup 内同步创建的 watch 会跟随组件实例自动销毁但异步创建的不会。所以我一直建议除非有特殊理由watch 应该放在 setup 顶层同步创建确需异步创建的必须手动保存 stop 函数并兜底清理。keep-alive 也是重灾区。被 keep-alive 缓存的组件不会走销毁流程watch 也不会自动停止。如果你在onActivated里根据激活状态修改了某个数据而这个数据恰好被 watch 监听回调会执行这个没问题。问题在于有的同学在onDeactivated里手动调了stopWatch()下次onActivated时又忘了重新创建于是监听器从第二次激活开始就彻底失效。这种问题非常难排查因为它没有任何报错。2.4 第四关immediate、flush 与只执行了一次的误解还有一种未启动其实是时机问题。Vue 3 的 watch 回调默认在组件更新前异步批量执行。下面这个操作const count ref(0) watch(count, () { console.log(count 变了) }) count.value count.value count.value你可能会以为回调会执行三次实际上它只执行一次。因为三次修改发生在同一个 tick 内Vue 的调度器把同一个 watcher 的任务合并了。这不是监听器没启动而是 Vue 刻意做的批处理优化。Vue 2 里也一样immediate 和 flush 都不会改变这个合并行为。如果回调里需要拿最新的 DOM要用flush: post或者nextTick包一层。如果要在同一次修改后立刻看到回调同步执行可以设置flush: sync但这是性能杀手不建议全局使用只适合极少数需要同步响应的场景。immediate: true则会让回调在创建时立即执行一次很多人不加 immediate就以为初始化时没触发等于没启动这其实是理解偏差——watch 本来就不负责初始化时的同步。下面用一个表格总结常见的几个未启动症状和对应方向方便你排查时快速定位症状常见位置优先排查数据变了回调完全没执行source 写法是否监听了非响应式对象、getter 是否收集到依赖对象内部属性变化不触发依赖收集范围是否缺少 deep: true或应改为监听具体字段组件恢复激活后失效生命周期onDeactivated 中是否意外 stop只在初始化时执行一次immediate 使用方式是否误解 immediate 语义连续修改只执行一次调度批处理属于正常行为调整逻辑或使用 sync3. watch 底层机制拆解依赖收集到调度触发之间发生了什么要真正理解前面两个问题不看源码是不可能的。但源码不用全读关键模型就三个effect、依赖收集、调度器。3.1 用 effect 的视角看 watchVue 3 里几乎所有的自动更新都建立在effect之上。watch 内部会创建一个 effect第一次执行时会运行你提供的 getter如果 monitor 的是 ref就读取.value同时把当前 effect 记为依赖。这个动作就完成了依赖收集。我习惯这么理解getter 里访问了哪些响应式属性watch 就被哪些属性的变化牵着走。一个属性变化后会触发依赖列表里的 effectwatch 发现这个 effect 是自己的就把回调任务交给调度器。所以 watch 是否触发从根本上取决于 getter 执行时到底访问了哪些属性。这也解释了为什么watch(() list.value[0], handler)加deep: true后能监听到list.value[0].name的变化因为 deep 实际上把 getter 的求值过程做了递归遍历traverse函数会从list.value[0]出发访问它内部所有可达的响应式属性于是这些属性全部被收集为依赖。Vue 2 的实现思路类似只是底层从 Proxy 换成了Object.defineProperty依赖管理由Dep和Watcher完成。理解了 Vue 3 的 effect 模型后回看 Vue 2 的$watch、computed、render watcher会发现它们都是同一套思想的变体。3.2 deep 的代价traverse 到底做了什么deep: true的底层是traverse函数它深度遍历一个值对每个访问过的响应式字段都触发一次读取从而收集依赖。这听起来很万能但代价是如果对象特别大traverse会访问其中每一个属性包括多层嵌套的数组、对象性能开销会明显上升。一个包含几千条记录的表格数据如果每次 change 都整表 deep 遍历高频场景下很容易卡顿。所以我的建议是deep 不是银弹。能用具体字段监听的地方优先写具体字段需要监听对象内部嵌套变化但范围明确的地方可以手动构造 getter 来缩小依赖范围。比如只需要知道用户信息里的昵称变了watch( () state.user.name, (newName, oldName) { // 只依赖 name 字段 } )这样比watch(() state.user, handler, { deep: true })更精准也不会因为 user 对象里其他无关字段变化而触发回调。不过要注意traverse默认会缓存已经访问过的对象避免循环引用导致死循环。它并不是简单一层层递归而是通过seen集合去重。这也是为什么深监听的性能不完全是对象字段数更多取决于唯一对象数量。3.3 调度器 job 与 flush 的关系watch 回调不是同步执行的它被包装成一个 job交给调度器。Vue 3 的调度器有一批队列watch 默认走queuePreFlushCb也就是在组件更新前执行回调。flush: post会让 job 进入queuePostFlushCb在组件更新、DOM 操作之后执行。flush: sync则跳过队列直接同步执行。从源码角度看watch 回调里在触发前还会做一次判断如果依赖没有变化或者新旧值相同它会直接跳过回调。比如前面提到的 deep 场景如果 deep 遍历后没有发现任何变化即使有人手动触发了依赖通知回调也不会执行。这个设计本身是在帮我们省性能。但也正因如此有些怪异行为有了解释你在 watch 回调里打印newVal oldVal为 true 时不要惊讶因为 watch 只保证依赖变化可能发生不保证旧值是修改前的快照。它比较新旧值是否相等的逻辑很粗糙一般只做引用比较或基础值比较不会对对象做深比较。4. 实战选型不同监听需求下 watch 该怎么配前面把原理和坑讲了一堆这一节把它们落到实际配置上。代码怎么写参数怎么选我会按场景给出一套可复用的结论。4.1 watch vs watchEffect别在选型上犯错很多人分不清 watch 和 watchEffect。watch 需要你指定监听什么watchEffect 则是自动追踪回调里用到的所有响应式状态。// watchEffect 写法 watchEffect(() { console.log(当前 count 是, count.value) }) // 等价的手写 watch 版本 watch(count, () { console.log(当前 count 是, count.value) })watchEffect 的优势是简洁适合副作用本身依赖多个响应式状态的场景。但它不会提供旧值而且创建时一定会执行一次。watch 的优势是可精确指定依赖、能拿到新旧值、可配置 immediate/deep/flush。我的原则是如果你需要旧值或者只想监听某个具体字段用 watch如果你只是想在几个状态变化时触发某个副作用对旧值没要求用 watchEffect 更省心。4.2 监听 getter 的三种写法与边界第一种监听基本类型字段。watch(() state.count, (newVal, oldVal) {})第二种监听对象里的某个属性。watch(() state.user.name, (newVal, oldVal) {})第三种监听整个对象并开启 deep。watch(() state.user, (newVal, oldVal) {}, { deep: true })边界在哪第三种会遍历 user 所有属性任何一层字段变化都会触发。如果业务上只是需要user 被替换了才重新拉数据就不该用 deepwatch( () state.user, (newVal, oldVal) { // 只有 state.user 引用被整体替换时触发 } )这个踩坑率也很高。很多人以为监听对象就能看到对象内部字段变化其实不 deep 的话默认只在引用变化时触发。还有一个更细的边界如果你 getter 里返回的是新对象比如() ({ ...state.user })这个 getter 每次执行都会生成新引用watch 内部的新旧值相同判断会因为引用不同而失效导致看起来每次依赖变化都触发而且新旧值永远不相同——这在某些场景是预期但如果你拿它做旧值对比会不准确。所以 getter 里尽量不要做一次性对象复制。4.3 多源监听、停止监听与竞态处理多源监听在表单校验、组合筛选场景很常见watch( [firstName, lastName], ([newFirst, newLast], [oldFirst, oldLast]) { console.log(newFirst, newLast, oldFirst, oldLast) } )注意数组形式下新旧值是各自对应的数组。如果某个源本身是 getter也可以混着写比如[() state.a, state.b]但 getter 的返回值会被作为新值。停止监听的方式前面提到过。在 setup 同步里创建的 watch 会自动跟组件生命周期绑定但如果你想手动停止可以用watch的返回值const stop watch(source, handler) // 某个时刻后不需要再监听 stop()如果 watch 创建在异步回调中一定要手动绑定onUnmounted清理否则可能产生内存泄漏和重复触发。竞态是另一个隐蔽问题。因为 watch 回调是异步批处理的用户操作很快时回调可能基于过期的数据请求接口导致先发出的请求后返回覆盖了后发出的请求结果。解决方式是使用 watch 回调的第三个参数onCleanupVue 3.4 的 watch 也支持watch(source, async (newVal, oldVal, onCleanup) { let cancelled false onCleanup(() { cancelled true }) const result await fetchData(newVal) if (!cancelled) { // 只有当前这次监听仍然有效时才更新 } })注意 Vue 3.4 之前watch回调的第三个参数并不一定稳定有些版本需要通过watchEffect的onInvalidate来做清理。如果版本较老可以用一个递增序号来控制let requestId 0 watch(source, async (newVal) { const currentId requestId const result await fetchData(newVal) if (currentId requestId) { // 处理结果 } })这个方案的思路简单可靠不依赖版本差异。5. 从热搜问题复盘我踩过的最隐蔽的三个坑5.1 用第一项索引做依赖导致的漏触发很多轮播、Tabs、表格高亮需要监听当前选中项常见写法是watch( () list.value[0]?.id, (newId, oldId) { console.log(当前第一项的 id:, newId) } )我踩过的一个坑是当用户删除了第一项原第二项顶上变成新的第一项如果两条数据的 id 相同这个 watch 压根不会触发。因为 getter 返回的是id值相同意味着新旧值相等watch 会直接跳过回调。类似情况发生在列表排序变化时第一项的 id 没变但内容变了watch 也纹丝不动。这类问题的本质是依赖错了。你想监听的是第一项这个位置上的内容而不是第一项的 id 字段。如果内容本身是对象直接监听整个对象加 deep 反而更符合语义如果只需要 id就要接受id 没变就不触发这一前提。业务上真要感知位置变化不如监听触发位置变化的操作源比如deleteItem、sortList这些方法在方法里明确做后续处理。5.2 对象属性新增和删除时 watch 失效的边界Vue 3 的 Proxy 能天然感知对象属性的新增和删除这一点比 Vue 2 舒服很多。但 watch 依然有边界。如果你监听这样一个字段const state reactive({}) watch( () state.user, (newVal) { console.log(user 出现了, newVal) } ) // 异步从接口返回后 state.user { name: xiaoming }这个写法在 Vue 3 里可以触发因为state.user的 get 和 set 都发生在响应式代理上。但换个写法watch( () state.user?.name, (newVal) { console.log(用户名, newVal) } ) state.user { name: xiaoming }由于 getter 第一次执行时state.user还是 undefinedstate.user?.name直接返回 undefined依赖收集只访问了state.user这个 Proxy 的 get 操作但没有访问到name字段。之后state.user { name: xiaoming }触发时实际上state.user的引用变化仍然会触发 effect因为访问state.user确实建立了依赖所以回调会执行。但如果中间隔了一层嵌套比如state.user.address?.city且address一开始也不存在变数就会更多。我的建议是对于可能动态增加的字段要么监听一个稳定存在的中间对象要么直接 deep 监听父对象。不要指望一连串可选链能覆盖所有未来出现的嵌套属性。5.3 组件销毁了监听器却还在跑这个场景我之前在一张后台报表页踩过。页面在onMounted里发请求拿到数据后用 watch 监听一个筛选条件的 ref用户切换条件就重新拉接口。看上去一切正常但因为 watch 是在接口回调里创建的组件卸载时没有手动 stop导致用户切到别的页面后之前泄漏的监听器仍然在响应旧页面的筛选条件变化甚至继续发请求。排查时最直观的信号是换页面之后控制台还在打印旧页面的日志。我当时加了一个onUnmounted(() stop())就解决了。后来我把代码规范定为凡是异步流程里创建的 watch、watchEffect一律在创建后立刻记录 stop 函数并在组件卸载时统一调用。同步创建的 watch 可以放心交给 Vue但异步创建的一定要自己兜底。多说一句Vue DevTools 其实也能帮你看到当前组件实例上注册的 effect 数量如果组件卸载后某个 effect 还挂在全局调度器里排查方向就非常明确。只是大多数团队的排查链路不会走到这一步先按前面四个关卡走完基本能解决 90% 的未启动问题。最后再分享一个我自己的习惯写 watch 时一定在 getter 里想清楚这一次求值到底访问了哪些响应式属性然后在回调里先打印一次新旧值确认触发条件和预期一致后再写业务逻辑。这个习惯帮我少踩了很多无形的坑也希望能帮到你。
阅读完成 · 觉得有帮助?
咨询建站