Vue3 Composition API watch开启deep后踩过哪些坑?如何高效用它处理深层数据监听?
最近几年做前端开发,肯定绕不开Vue3,而Composition API又成了大家写逻辑的首选,在处理动态数据的时候,watch绝对是高频用到的API——但只要一碰深层的嵌套对象、数组,不加deep可能监听不到变化,加了deep又容易出各种奇怪的问题,上周我还在改项目里因为deep watch写得太随意导致的页面卡顿bug,今天就把自己踩过的坑、查资料验证过的原理,还有实际开发里高效用deep的方法都整理出来,希望能帮到大家。
首先搞懂:watch默认为什么监听不到深层数据变化?
很多刚开始用Vue3 Composition API的朋友,最常遇到的第一个场景就是:定义了一个嵌套多层的对象或者数组,比如const state = reactive({ user: { name: '张三', tags: ['前端', 'Vue'] } }),然后写watch(state.user, () => console.log('user变了')),结果去改state.user.name或者push一个新标签,控制台根本没反应。
这时候你可能会加个{ deep: true },哎,就能触发了,但你有没有想过背后的原因?其实得从Vue3的响应式原理说起——Vue3用的是Proxy,而不是Vue2的Object.defineProperty,默认情况下,watch监听的是源对象的引用地址,如果只是改了源对象内部的属性或者数组的元素,源对象本身的引用地址没变,Proxy的set陷阱虽然会触发(所以视图会更新),但watch的默认监听逻辑是“引用地址有没有被替换”,所以不会执行回调。
开启deep之后,watch会递归遍历源对象里的所有嵌套属性、数组元素,给每一个都建立响应式依赖追踪,这时候不管你改的是哪一层、哪一个元素,只要有变化,依赖就会更新,回调就会触发。
小心踩这些坑:不是所有场景都适合直接开deep
很多人觉得deep watch好用,一遇到深层数据就无脑加,但实际上它有几个很明显的副作用,用错了会给项目埋雷:
第一个坑:性能开销大到离谱
递归遍历深层数据的成本有多高?举个极端点的例子:如果你的state里有一个包含10000条用户数据的数组,每条用户数据又有5层嵌套的属性(比如标签、权限、收货地址列表这种),开了deep watch之后,每次你修改任何一条数据的任何一个属性,Vue3都要先检查一遍整个数组的所有嵌套项的依赖,再触发回调——要是你的回调里还有耗时操作(比如重新计算大列表、请求接口),那页面卡顿是分分钟的事,移动端甚至可能出现掉帧、闪退。
之前我在做电商后台的商品规格编辑器时就踩过这个坑:商品的规格树有三级(品类→颜色→尺码),每一级都有好多项,我为了监听整个规格树的变化,直接给整个树开了deep watch,还在回调里重新计算所有SKU的库存价格组合,结果编辑三级品类的一个小尺码名称,都要等个2-3秒才能看到SKU的变化,测试给我提了好几个性能bug。
第二个坑:容易触发不必要的回调
比如你还是监听state.user,加了deep,你本来只想监听user.tags数组的变化,结果不小心修改了user.name(这个修改本来不需要做操作),deep watch还是会触发回调,执行了你不需要的逻辑,既浪费性能,又可能导致其他意外的问题(比如接口被重复请求、数据被覆盖)。
第三个坑:deep watch监听reactive顶层对象时,新值旧值会一样
这个坑是很多人容易忽略的细节,如果你写watch(state, (newVal, oldVal) => { console.log(newVal, oldVal) }, { deep: true }),然后去改state.user.name,你会发现控制台打印的newVal和oldVal完全一模一样!
为什么会这样?因为reactive返回的是Proxy包装后的对象,不管什么时候访问,拿到的都是同一个引用的代理实例——而watch的deep模式下,它只会对比引用,不会给你做快照保存旧值(除非你监听的是ref包裹的对象,或者用toRaw转了原始对象,但转原始对象又会失去响应式),这时候你要是想对比新旧值的差异,就只能自己写逻辑,或者用第三方库比如lodash的isEqual、fast-deep-equal。
避开坑的高效用法:根据场景选择合适的监听方式
知道了deep watch的坑,那什么时候该用,什么时候不该用?该用的时候又怎么优化?接下来给大家分享几个实际开发里常用的方案:
如果只需要监听某几个具体的深层属性,别开deep,直接监听对应的路径
这是最推荐的方案,既能精准触发回调,又不会有性能开销,比如刚才说的只想监听user.tags的变化,直接写watch(() => state.user.tags, () => { console.log('tags变了') })就行——注意这里要把源数据写成一个函数返回值,不能直接传state.user.tags,因为直接传的话,函数参数在初始化的时候就执行了,拿到的是初始的tags数组引用,之后就算tags数组内部变化了,引用地址没变(除非你直接替换整个tags数组),还是监听不到。
那要监听多个具体的深层属性怎么办?可以把它们放在一个数组里当源:watch([() => state.user.name, () => state.user.tags], ([newName, newTags], [oldName, oldTags]) => { /* 你的逻辑 */ }),这样只有当name或者tags变化的时候才会触发回调,还能拿到各自的新旧值。
如果确实需要监听整个嵌套结构的变化,先优化源数据结构,再用deep watch
有些场景确实没办法只监听几个具体的属性,比如刚才说的商品规格树,只要任何一级的规格项变化(增删改),都要重新计算SKU——这时候可以先优化一下源数据的结构,把规格树拆成更小的模块?不行的话,就尽量把深层数据里不需要监听的部分剔除掉,只监听核心的变化部分,比如只监听品类、颜色、尺码的id、名称、库存系数这几个会影响SKU的字段,而不是整个规格树的所有字段(比如创建时间、更新时间、备注这些没用的)。
开了deep watch之后,回调里的耗时操作一定要做节流或者防抖!比如刚才的SKU计算,编辑三级尺码名称的时候,可能用户输入会很快,不需要每次敲一个字符就计算一次SKU,加个300ms的防抖,性能会好很多,节流防抖可以用lodash的throttle、debounce,也可以自己写。
如果需要对比新旧值的差异,试试用computed缓存+watchEffect?或者用watch监听原始值的快照?
刚才说过,deep watch监听reactive顶层对象的时候,新旧值是一样的,那怎么解决这个问题? 第一个方法是用computed先把你需要的核心数据转成JSON字符串(或者用toRaw转成原始对象,再用structuredClone深拷贝一份旧值),然后监听这个computed的结果。
const stateSnapshot = computed(() => JSON.stringify({
user: state.user,
cart: state.cart.slice(0, 10) // 只缓存购物车前10个?根据需求来
}))
watch(stateSnapshot, (newStr, oldStr) => {
const newVal = JSON.parse(newStr)
const oldVal = JSON.parse(oldStr)
// 这里可以对比newVal和oldVal的差异了
})
不过JSON.stringify有个缺点:它会忽略undefined、function、Symbol这些类型的属性,也不能处理循环引用,如果有这些需求,可以用structuredClone(现代浏览器和Node.js 17+都支持)或者lodash的cloneDeep深拷贝原始对象,然后用一个ref保存旧值,每次watch回调执行完之后更新这个ref:
import { reactive, watch, toRaw, ref } from 'vue'
const state = reactive({ user: { name: '张三', tags: ['前端'] } })
const oldStateRef = ref(structuredClone(toRaw(state)))
watch(
() => state,
(newVal) => {
const oldVal = oldStateRef.value
const rawNewVal = toRaw(newVal)
// 这里对比rawNewVal和oldVal
console.log('对比差异:', rawNewVal, oldVal)
// 对比完之后更新旧值
oldStateRef.value = structuredClone(rawNewVal)
},
{ deep: true }
)
第二个方法是用watchEffect?不对,watchEffect默认也是不做新旧值对比的,而且它会立即执行一次,不过watchEffect+computed缓存+旧值ref的组合,其实和上面的方法差不多,大家可以根据自己的习惯选。
监听数组的时候,尽量不要直接修改索引或者长度,用push、pop、splice这些Vue3封装好的方法
虽然Vue3的Proxy可以监听到数组的索引修改和长度修改(这比Vue2的Object.defineProperty强多了),但如果你用deep watch监听数组,直接修改索引或者长度,和用封装好的方法触发的回调是一样的,但尽量用封装好的方法,代码更清晰,也更符合Vue的设计理念,不过如果只监听数组的引用,直接修改索引或者长度是不会触发回调的,这一点要注意。
再补充一个:watchEffect要不要加deep?
哦对了,很多人会把watch和watchEffect搞混,这里顺便说一下:watchEffect默认就是“深度”的?不对,应该说watchEffect是自动追踪响应式依赖的,不管你在回调里用到了哪一层的响应式数据,只要那个数据变化了,watchEffect就会重新执行——它不需要像watch那样显式指定源,也不需要显式加deep。
那什么时候用watch,什么时候用watchEffect?如果你需要知道新旧值,或者需要显式控制什么时候开始监听(比如可以用immediate控制是否立即执行,但watchEffect是立即执行的),或者需要监听的是具体的几个值,就用watch;如果你只需要在用到的响应式数据变化时执行逻辑,不需要新旧值,不需要控制监听时机,就用watchEffect,比如刚才的商品规格SKU计算,如果不需要对比新旧值,用watchEffect会不会更方便?其实不一定,因为watchEffect会立即执行一次,而watch可以用immediate: false控制初始化的时候不执行,这个要看具体需求。
最后总结一下
Vue3 Composition API的watch开启deep之后,确实能解决深层数据监听的问题,但它不是万能的,不能无脑用,实际开发里,我们应该优先考虑直接监听具体的深层路径,这样既精准又高效;如果确实需要监听整个嵌套结构,要先优化源数据结构,再给回调加节流或者防抖;如果需要对比新旧值,可以用JSON.stringify转成字符串,或者用structuredClone深拷贝原始对象保存旧值;还要注意watch和watchEffect的区别,根据场景选择合适的API。 的时候,也查了很多资料,还翻了翻之前写项目时的笔记,希望能给大家带来帮助,如果大家还有其他关于Vue3 Composition API的问题,欢迎在评论区留言讨论,我会尽量回复的。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


