Vue3 composition api里的watch deep模式怎么用?踩过的坑和优化方案有哪些?
先搞懂watch和watchEffect的区别——别上来就用watch deep
很多刚接触Vue3组合式API的同学,一碰到要监听复杂数据(比如对象、数组)的深层变化,第一反应就是开watch的deep:true,但其实deep模式真的不是“万能药”,甚至有时候watchEffect都比它更合适,所以先把这俩核心的区别理清楚,后面用deep的时候才不会盲目。
先回忆下watch的特点:它是懒执行的,第一次页面加载不会触发回调,只有监听的源数据真正发生变化的时候才会执行;它可以同时监听多个源,还能拿到变化前和变化后的值;它支持配置immediate、deep、flush这些选项。
而watchEffect呢?是立即执行的,第一次页面加载会自动跑一次回调,后续只要回调里用到的响应式数据(不管是浅层还是深层,不管是对象的属性还是数组的元素)变了,都会重新触发;它不能同时监听多个独立的、没用到一起的源?不对,不对,准确说,只要在同一个watchEffect的回调里引用了多个响应式数据,不管是浅层深层,都是一起被监听的,但它拿不到变化前的值;它的选项比watch少,目前常用的只有flush。
举个简单的小例子对比下:比如有个商品对象,包含id、name、库存数stock三个属性,还有个total数组存用户选的多个商品。 如果用watch来监听stock的变化,只有当stock这个属性的具体值变的时候才会触发:
import { ref, watch } from 'vue'
const product = ref({ id: 1, name: 'Vue3实战书', stock: 10 })
watch(() => product.value.stock, (newVal, oldVal) => {
console.log(`库存从${oldVal}变成了${newVal}`)
})
但如果product整个对象被重新赋值了(比如product.value = { id:2, name:'React进阶', stock:20 }),上面的watch也会触发,因为源是product.value.stock,整个对象变了,引用的属性自然也变了;但如果只是把product.value.name改成‘Vue3源码解析’,那上面的监听完全没反应,因为只监听了stock。
如果换成watch deep模式,监听整个product对象的话:
watch(product, (newVal, oldVal) => {
console.log('商品对象发生了变化', newVal, oldVal)
}, { deep: true })
这时候不管是stock变、name变、id变,还是给product加个新属性(比如price:99)、删个属性,都会触发回调,但这里有个坑:watch deep模式下,如果监听的是ref包裹的对象,newVal和oldVal是同一个引用! 因为Vue的响应式是基于Proxy的,ref包裹对象的话,内部其实也是Proxy,修改对象的深层属性,整个对象的引用并没有变,所以newVal和oldVal指向的内存地址一样,打印出来虽然看起来属性不同,但你对比它们的引用是完全相等的,根本拿不到真正的“旧值”,这个是用deep第一个要记住的点。
那什么时候用watch,什么时候用watchEffect,什么时候又必须用watch deep呢?
- 只需要监听数据的某个特定变化、需要拿变化前的值、不需要第一次加载就执行:选普通watch(或者带路径的,或者函数返回值的)
- 需要第一次加载就执行、只关心用到的响应式数据有没有变、不需要拿旧值:选watchEffect
- 需要监听复杂数据的所有深层变化,包括新增、删除属性/元素(注意数组的push/pop这些API,其实普通监听数组引用的话会不会触发?普通watch监听数组的话,默认是浅层监听,也就是只有数组本身的引用变了才会触发,比如arr.value = [1,2,3]改成[4,5,6];但如果是用push/pop/splice这些API修改数组的元素,数组的引用没变,普通监听就不会触发,这时候要么用函数返回值返回数组的每一个元素?不对,除非你写死,但数组长度可能变,所以这时候要么用watchEffect引用数组的长度或者某个具体属性的集合,要么用watch deep监听数组)
watch deep模式的基础用法详解
基础用法其实不复杂,主要分监听ref包裹的对象/数组,和监听reactive包裹的对象/数组这两种情况,还有同时监听多个源的情况。
监听ref包裹的对象/数组
刚才举的商品例子就是监听ref包裹的对象,再补充监听数组的例子:
import { ref, watch } from 'vue'
const cartList = ref([
{ id:1, name:'Vue3书', count:1 },
{ id:2, name:'键盘', count:1 }
])
// 监听整个购物车的所有变化:商品数量变、新增商品、删除商品、修改商品名
watch(cartList, (newVal, oldVal) => {
// 这里newVal和oldVal引用相同,对比count的变化得自己处理
const totalCount = newVal.reduce((sum, item) => sum + item.count, 0)
console.log(`购物车总商品数: ${totalCount}`)
}, { deep: true })
这时候不管你是cartList.value[0].count++,还是cartList.value.push({id:3,name:'鼠标',count:1}),还是cartList.value[1].name='机械键盘',还是cartList.value.splice(0,1),都会触发回调,打印总商品数。
监听reactive包裹的对象/数组
这里有个小细节:reactive包裹的对象/数组,默认就是深层响应式的,而且如果直接把reactive对象传给watch作为源的话,默认就是深层监听!不需要加deep:true! 但很多同学可能不知道这点,会多此一举加deep,虽然不会报错,但最好还是注意下代码的简洁性。
import { reactive, watch } from 'vue'
const user = reactive({
name: '张三',
age: 25,
address: {
province: '广东',
city: '深圳',
district: '南山区'
}
})
// 直接传reactive对象,默认deep:true,修改address.city也会触发
watch(user, (newVal, oldVal) => {
console.log('用户信息变了', newVal, oldVal)
// 同样,newVal和oldVal引用相同
})
// 如果你只想监听user的浅层变化(比如整个user被重新赋值?不可能,因为reactive不能直接重新赋值整个对象,不然会丢失响应式,要改得用Object.assign或者直接替换属性)
// 比如只想监听name和age的变化,不管address,就用函数返回值:
watch(() => [user.name, user.age], ([newName, newAge], [oldName, oldAge]) => {
console.log(`姓名从${oldName}变成${newName},年龄从${oldAge}变成${newAge}`)
})
这里要特别强调reactive不能直接重新赋值的问题:比如你不能写user = reactive({name:'李四',...}),这样原来的user就变成了普通对象的引用,丢失了响应式;如果要替换整个对象的内容,要用Object.assign(user, {name:'李四',...}),这时候如果是直接监听reactive对象的话,还是会触发回调,因为是深层修改。
同时监听多个源并使用deep模式
watch可以同时监听多个源,用数组包裹起来,这时候如果其中某个源是复杂数据,想监听它的深层变化,那deep:true会对整个数组里的所有源生效吗?不对,不对,数组里的每个源如果是函数返回值或者ref的话,deep是统一生效的;但如果你只想对数组里的某个源开deep,那得把那个源单独拎出来?不,不行,目前watch的选项是全局的,对同时监听的所有源都生效。
import { ref, reactive, watch } from 'vue'
const productCount = ref(100)
const cartList = ref([{id:1,name:'书',count:1}])
const user = reactive({name:'张三'})
// 同时监听productCount、cartList、user,统一开deep:true
watch([productCount, cartList, user], ([newCount, newCart, newUser], [oldCount, oldCart, oldUser]) => {
console.log('有数据变了')
// productCount是普通ref,newCount和oldCount是不同的;cartList和user的new和old引用相同
}, { deep: true, immediate: true })
这里immediate:true是第一次加载就执行回调,刚才提到过,这个选项可以配合deep一起用。
用watch deep模式踩过的那些坑——避坑指南来了!
刚才已经提到了两个小坑:newVal和oldVal引用相同,reactive默认深层监听,下面再讲几个更常见、更容易影响性能或者功能的大坑。
坑1:滥用deep导致性能严重下降
这是最大的坑!没有之一!很多同学不管监听什么复杂数据,直接上来就开deep:true,完全不管有没有必要,这会导致Vue的性能消耗急剧上升。 为什么呢?因为deep模式下,Vue会递归遍历监听的整个对象/数组的所有属性、子属性、孙属性……直到最底层的原始值,给每一层都加上Proxy的监听钩子,只要有任何一层的任何一个值(包括新增、删除)发生变化,都会触发回调,如果你的数据结构非常复杂,比如有多层嵌套的对象、有很长的数组(比如几百上千个元素,每个元素又是嵌套对象),那递归遍历的过程本身就很耗性能,而且后续每次修改任何一个小地方,Vue都要重新检查整个结构,触发不必要的回调。 举个极端点的例子:比如你有一个用户列表,里面有1000个用户,每个用户又有个人信息、购物记录、订单记录等嵌套对象,你只想监听第一个用户的购物记录里的最后一条的状态变化,但你直接给整个用户列表开了deep:true,那每次有任何一个用户的任何一个属性变,都会触发你的回调,这完全是浪费资源。
坑2:监听函数返回值时误用对象/数组的浅拷贝
刚才我们说过,如果不想监听整个对象的所有变化,可以用函数返回值,比如返回对象的某个属性、或者数组的某个元素、或者几个属性的数组,但如果函数返回的是一个新的对象/数组的浅拷贝,那即使原来的对象/数组的深层属性没变化,每次Vue执行依赖收集的时候,都会认为返回值变了,从而触发回调,这时候你开不开deep都会有问题。 比如这个错误的例子:
import { reactive, watch } from 'vue'
const user = reactive({
name: '张三',
address: {
city: '深圳'
}
})
// 错误:函数返回了新的对象,每次依赖收集都会生成新引用,watch会认为变化了,即使没开deep
watch(() => ({ name: user.name, city: user.address.city }), (newVal, oldVal) => {
console.log('触发了')
})
// 这时候你修改user.address.district,虽然返回的对象里没district,但函数返回的是新对象,引用变了,普通watch也会触发;如果开了deep,更不用说了
那正确的做法是什么呢?如果只需要监听几个特定的属性,就用数组包裹这几个属性的原始值,不要返回新对象:
// 正确:返回属性的原始值组成的数组,引用不会变,只有属性值变的时候才会触发
watch(() => [user.name, user.address.city], ([newName, newCity], [oldName, oldCity]) => {
console.log(`姓名:${oldName}→${newName},城市:${oldCity}→${newCity}`)
})
如果确实需要返回一个对象(比如后续要在回调里用这个对象做什么),那可以用computed来包装这个返回值,computed会自动做依赖收集和缓存,只有依赖的属性变了,computed的值才会变,然后再监听computed:
import { reactive, computed, watch } from 'vue'
const user = reactive({...})
const userSimpleInfo = computed(() => ({ name: user.name, city: user.address.city }))
watch(userSimpleInfo, (newVal, oldVal) => {
console.log('简化用户信息变了', newVal, oldVal)
// 这里如果computed返回的是新对象,newVal和oldVal引用还是不同的,因为computed每次依赖变化都会重新计算,生成新对象
})
如果还想拿到computed返回对象的真正“旧值”(比如对比city有没有变),那要么还是用数组返回原始值,要么在computed里做对比,缓存上一次的值。
坑3:监听数组时,以为所有修改都会触发(普通监听vs deep监听vs监听路径)
刚才提到过一点,再详细说下数组的监听情况,很多同学在这里容易混淆:
- 普通watch监听数组的ref引用(比如直接传arr):只有数组本身的引用变了(比如arr.value = [1,2,3])才会触发,push/pop/splice/shift/unshift这些API修改数组的元素,引用没变,不会触发。
- 普通watch监听数组的函数返回值() => arr.value):和上面一样,只有引用变了才会触发。
- 普通watch监听数组的某个具体索引() => arr.value[0]):只有索引0的元素变了才会触发,不管是直接赋值arr.value[0] = 5,还是arr.value[0].count++(如果索引0是对象的话,但这里普通监听的是索引0的引用,索引0的引用不变的话,不会触发!哦对,这个也是坑!比如arr.value[0]是{count:1},你修改count,索引0的引用没变,普通监听() => arr.value[0]不会触发,必须监听() => arr.value[0].count或者开deep。
- 普通watch监听数组的length() => arr.value.length):只有数组的长度变了(push/pop/splice等修改长度的API)才会触发,修改数组中间元素的值但长度不变的话,不会触发。
- watch deep监听数组:不管是引用变、长度变、中间元素值变、元素的深层属性变,都会触发。
坑4:监听新增/删除属性时,没考虑Vue2的遗留问题?不对,Vue3已经解决了大部分,但要注意用正确的方法修改
Vue2中,给对象新增属性(比如obj.newProp = 1)或者删除属性(delete obj.oldProp),因为Object.defineProperty的限制,无法触发响应式更新,必须用Vue.set/Vue.delete或者this.$set/this.$delete;但Vue3用的是Proxy,直接给reactive对象新增/删除属性,或者修改数组的索引,都是可以触发响应式更新的! 前提是你用的是reactive或者ref包裹的对象/数组。 不过这里要注意,如果是ref包裹的对象,你修改属性的时候要加.value:比如product.value.price = 99是对的,product.price = 99是错的,因为product是ref,不是Proxy本身,Proxy在product.value里。 还有,如果是给一个已经解构的reactive对象新增属性,会不会触发?
import { reactive, toRefs } from 'vue'
const user = reactive({name:'张三'})
const { name } = toRefs(user)
// 给user新增age属性,name会不会受影响?不会,因为name是单独的ref,但user的响应式还在,监听user的话会触发
user.age = 25
// 如果直接解构user而不是toRefs,那name就是普通字符串,完全丢失响应式
const { name: normalName } = user
normalName = '李四' // 不会触发任何响应式
所以解构reactive对象的时候,一定要用toRefs或者toRef,不然会丢失响应式,这也是和watch deep配合使用时要注意的,不然你以为新增了属性会触发watch deep,结果因为解构错了,根本没修改到响应式对象。
watch deep模式的优化方案——减少性能消耗,提高代码质量
既然滥用deep会导致性能问题,那有什么优化方案呢?下面给大家分享几个实用的方法,从“减少使用deep的场景”到“即使要用deep也尽量优化”。
优化方案1:优先使用普通watch(函数返回值)监听特定路径
这是最有效的优化方案!能用普通watch监听特定路径就绝对不用deep,比如刚才的购物车例子,如果你只想监听购物车的总商品数变化,那完全不需要deep监听整个cartList,只需要监听cartList.reduce的返回值?不对,watch的源必须是响应式的,或者函数返回的响应式数据的引用/属性/原始值,哦对,reduce返回的是原始值,不是响应式的,所以要把它放在computed里,然后监听computed:
import { ref, computed, watch } from 'vue'
const cartList = ref([...])
// 用computed计算总商品数,自动缓存
const totalCount = computed(() => cartList.value.reduce((sum, item) => sum + item.count, 0))
// 监听computed,只有totalCount变的时候才会触发,性能比deep好太多
watch(totalCount, (newVal, oldVal) => {
console.log(`总商品数从${oldVal}变成了${newVal}`)
})
再比如,如果你只想监听购物车第一个商品的数量变化,直接监听() => cartList.value[0].count就行,不需要deep:
watch(() => cartList.value[0]?.count, (newVal, oldVal) => {
// 加个可选链,防止cartList为空报错
if (newVal !== undefined) {
console.log(`第一个商品数量从${oldVal}变成了${newVal}`)
}
})
这种特定路径的监听,Vue只会给路径上的每一层加必要的钩子,不会递归整个对象/数组,性能消耗非常小。
优化方案2:如果必须监听多个属性的变化,用数组包裹这些属性的原始值
刚才在坑2里已经提到过了,不要返回新对象,用数组包裹原始值,这样引用不会变,只有属性值变的时候才会触发,而且不需要开deep(除非属性本身是复杂数据,那这时候可以单独对那个属性开deep?不对,数组的源如果有复杂数据,统一开deep的话还是会影响所有,但如果只有一个复杂数据的话,也可以单独写一个watch监听那个复杂数据的特定路径,或者整个数组的源只针对那个复杂数据开deep,但目前watch的选项是全局的,所以还是分开写更好)。
优化方案3:使用watchPostEffect/watchSyncEffect代替watch deep(适合不需要旧值、需要立即执行的场景)
刚才提到过watchEffect是立即执行的,而且自动收集回调里用到的所有响应式数据的依赖,不管是浅层还是深层,那其实有时候用watchEffect代替watch deep会更方便,而且性能也差不多?不对,watchEffect的依赖收集是按需的,比如你在watchEffect的回调里只用到了cartList.value[0].count和cartList.value.length,那它只会监听这两个路径,不会监听整个cartList,性能比watch deep好太多! 比如刚才的总商品数例子,用watchEffect写的话:
import { ref, watchEffect } from 'vue'
const cartList = ref([...])
watchEffect(() => {
const totalCount = cartList.value.reduce((sum, item) => sum + item.count, 0)
console.log(`总商品数: ${totalCount}`)
})
这里watchEffect会自动收集cartList.value的依赖吗?不,因为reduce会遍历cartList的每一个元素,访问每一个item.count,所以会自动给每一个item.count加上依赖钩子,只要有任何一个item.count变,或者cartList的长度变(因为遍历的时候用到了length的隐式依赖?不对,直接访问cartList.value.length才会显式依赖,但reduce其实也会用到length,不过更准确的是,只要cartList的元素数量或者元素的count变,watchEffect都会触发,而且它只监听这些必要的路径,不会监听item.name、item.id这些没用的属性,性能比watch deep好太多! 那watchPostEffect和watchSyncEffect又是什么呢?它们其实是watchEffect的变种,只是flush选项不同:
- watchEffect默认的flush是'pre',也就是在组件更新之前执行;
- watchPostEffect的flush是'post',在组件更新之后执行,这时候可以访问到更新后的DOM;
- watchSyncEffect的flush是'sync',在数据变化同步执行,不等待组件更新,这个要谨慎使用,因为可能会导致多次同步执行,影响性能。
优化方案4:如果必须用watch deep,尽量缩小监听的范围
比如你有一个很大的嵌套对象,只想监听其中某一个子对象的所有变化,那就不要监听整个大对象,只监听那个子对象的特定路径:
import { reactive, watch } from 'vue'
const bigData = reactive({
user: {...},
product: {...},
order: {
list: [...], // 很长的订单列表
totalPrice: 0,
status: 'pending'
}
})
// 只监听order子对象的所有变化,不要监听整个bigData
watch(() => bigData.order, (newVal, oldVal) => {
console.log('订单信息变了')
}, { deep: true })
这样Vue只会递归遍历order子对象,不会遍历user和product,性能消耗会小很多。
优化方案5:使用shallowRef/shallowReactive配合watch deep(适合只需要浅层响应式,但偶尔需要监听深层变化的场景)
shallowRef和shallowReactive是Vue3提供的浅层响应式API:
- shallowRef包裹的对象,只有.value的引用变了才会触发响应式,.value里面的属性变化不会触发;
- shallowReactive包裹的对象,只有第一层的属性变化才会触发响应式,深层的属性变化不会触发。
那什么时候用它们配合watch deep呢?比如你有一个很大的列表,大部分时候只需要修改列表的引用(比如替换整个列表),不需要修改列表元素的深层属性,但偶尔需要监听一次深层变化,这时候用shallowRef/shallowReactive可以减少平时的响应式消耗,只有在需要的时候开watch deep:
import { shallowRef, watch } from 'vue' // 用shallowRef包裹大列表,平时修改元素深层属性不会触发响应式,性能好 const bigList = shallowRef([...]) // 只有在点击某个按钮后,才开启一次深层监听,处理完就取消 const handleCheckDeep = () => { const stopWatch = watch(bigList, (newVal, oldVal) => { console.log('列表深层变化了') // 处理完逻辑后,取消监听,避免后续不必要的性能消耗 stopWatch() }, { deep: true, immediate: true }) }这里要注意watch的返回值是一个停止监听的函数,调用它就可以取消watch,这个功能很实用,不管是普通watch还是watch deep都可以用,配合shallowRef/shallowReactive效果更好。
优化方案6:使用lodash-es的isEqual配合computed和watch(适合需要对比对象深层变化,但不需要实时监听每一次小变化的场景)
有时候你不需要每次对象的任何一个小属性变都触发回调,只需要对象的整体内容(深层)和上次不一样的时候才触发,这时候可以用lodash-es的isEqual函数(注意要用esm版本的lodash-es,不然Vue3的tree-shaking会失效),配合computed和watch:
import { ref, computed, watch } from 'vue'
import { isEqual } from 'lodash-es'
const user = ref({
name: '张三',
address: { city: '深圳' }
})
// 用computed缓存上一次的user值,对比是否相等
const userChanged = computed(() => {
// 这里必须返回一个能触发依赖变化的值,比如用JSON.stringify?但JSON.stringify有局限性,比如不能处理函数、Symbol、循环引用等,所以用isEqual更好
// 但computed的依赖怎么触发?哦,我们可以在computed里引用user.value,这样只要user的引用或者浅层属性变?不对,user是ref,引用user.value就会触发依赖收集,但user.value的深层属性变的话,user.value的引用没变,computed不会重新计算,所以这时候还得配合watch deep监听user.value,然后在computed里用isEqual对比上一次的值,或者直接在watch deep的回调里用isEqual对比:
})
// 直接在watch deep的回调里用isEqual对比,只有真正深层内容不同的时候才执行逻辑
watch(user, (newVal) => {
// 这里需要自己保存上一次的旧值,因为newVal和oldVal引用相同
if (!isEqual(newVal, lastUserVal.value)) {
console.log('用户真正的深层内容变了')
// 更新lastUserVal
lastUserVal.value = JSON.parse(JSON.stringify(newVal)) // 深拷贝,避免引用相同
}
}, { deep: true, immediate: true })
// 初始化lastUserVal
const lastUserVal = ref(JSON.parse(JSON.stringify(user.value)))
这里用JSON.parse(JSON.stringify())做深拷贝有局限性,比如不能处理函数、Symbol、循环引用、Date对象(会变成字符串)、RegExp对象(会变成空对象)等,如果有这些情况,可以用lodash-es的cloneDeep函数,或者自己写一个深拷贝函数。
watch deep模式的最佳实践
最后给大家总结一下watch deep模式的最佳实践,帮助大家在开发中合理使用:
- 优先不用deep:能用普通watch监听特定路径、能用computed+watch、能用watchEffect按需收集依赖,就绝对不用watch deep。
- 缩小监听范围:如果必须用deep,尽量只监听需要的子对象/子数组,不要监听整个大对象。
- 配合停止监听函数:如果只需要临时监听深层变化,记得在处理完逻辑后调用watch返回的stop函数取消监听。
- 注意newVal和oldVal的引用问题:监听ref/reactive包裹的对象/数组时,newVal和oldVal引用相同,需要自己保存旧值并做深拷贝对比。
- 避免解构错误:解构reactive对象时要用toRefs/toRef,不然会丢失响应式。
- 合理使用浅层响应式:配合shallowRef/shallowReactive可以减少平时的响应式消耗。
Vue3的组合式API给我们提供了非常灵活的数据监听方式,watch deep只是其中的一种,不是万能的,我们要根据具体的业务场景选择最合适的监听方式,这样才能写出性能好、代码质量高的Vue3应用。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



