Vue3中watch监听ref包裹的普通对象会遇到哪些常见坑?该怎么正确处理?
刚上手Vue3的朋友,肯定对响应式API里的watch和ref既熟悉又头疼——ref明明能存任意类型的数据,怎么一换成普通对象,watch要么没反应,要么一下子触发太频繁?其实这些都是Vue3响应式系统底层机制导致的,只要摸透它的原理,避开那些小陷阱,用起来就顺手多了,今天咱们就把这个事儿掰扯明白,连新手都能一眼看懂。
首先得搞懂:ref包裹普通对象和包裹基本数据类型,响应式逻辑有啥不一样?
很多朋友以为ref和reactive只是存数据的容器不同——ref存单个,reactive存多个,但本质上都是让数据变响应式,这话只对了一半,ref在处理基本数据类型(比如数字、字符串、布尔值、null、undefined)的时候,是通过Object.defineProperty的getter/setter或者Proxy的特性?不对,官方文档里说过,Vue3的响应式核心是Proxy,ref其实是把值包装成了一个带value属性的“假对象”,这个假对象本身是用Proxy代理的,当你修改ref.value的基本值时,Proxy能直接捕获到属性的更新,所以watch默认就能监听到。
但如果ref里存的是普通对象(不是用reactive包过的),那情况就变了:ref的Proxy只会监听这个假对象的value属性有没有被整体替换——比如你原来的ref是const user = ref({name: '张三'}),后来你直接写user.value = {name: '李四'},这时候是把整个value指向了新的内存地址,Proxy当然能抓到;但如果你只是改了name属性,user.value.name = '李四',那ref的Proxy看不到,因为它只盯着value的“身份”变没变,不管value里面的内容,这时候很多人就懵了:我明明改了数据,页面咋没反应?哦不对不对,页面有时候其实会有反应?因为Vue3的模板编译的时候,会自动把ref.value给解包,解包之后访问的是普通对象的属性,Vue3内部对这种“模板里解包后的原始对象”做了优化吗?不,不是的——这时候如果普通对象本身没被代理,页面其实不会更新才对?哦等下,不对不对,我刚才是不是搞混了ref和reactive的优先级?或者说,有没有一种自动深层代理的情况?
哦对了!这里必须澄清一个小细节:当你直接给ref赋值一个普通对象的时候,Vue3内部其实会偷偷把这个普通对象用reactive再包一层,赋值给value的其实是这个reactive代理后的对象,那这么说的话,模板解包后访问的是reactive的属性,页面肯定会更新啊?那为啥watch还是经常监听不到ref.value的属性变化?
因为watch的默认监听逻辑,是浅监听——也就是只监听你传给它的第一个参数(那个“监听源”)的直接变化,不管监听源里面嵌套的属性,哦!这才是第一个核心大坑:ref包裹的普通对象,虽然内部是reactive深层代理的,但你传给watch的如果是user(不带.value),那watch监听的是ref这个假对象的value属性是否被替换;如果你传给watch的是user.value(带.value),那watch默认只会监听这个reactive对象的引用有没有变,不会监听里面的属性修改,除非你加特殊配置。
第一个常见坑:直接传ref对象给watch,只改对象属性不触发回调
刚才已经说了,这个坑的原因是默认浅监听ref的value引用,那怎么解决?有两种最常用的方法,还有一种稍微特殊的情况。
第一种方法,用一个函数当监听源,函数里面返回你要监听的具体属性,比如watch(() => user.value.name, (newVal, oldVal) => { console.log(newVal, oldVal) }),这种方法的好处是非常精准——只会在name属性变化的时候触发,不会因为其他属性(比如age、address)的修改浪费性能;而且不管你的对象嵌套多深,只要函数里写对路径就行,比如() => user.value.address.city,这也是官方推荐的第一种方式,尤其适合你只需要监听某个特定属性的场景。
这里要注意一个点:如果你的函数返回的是一个引用类型的数据(比如() => user.value.address,而不是city),那这个时候watch默认还是浅监听这个引用,只有整个address被替换的时候才会触发,你还得加deep配置吗?等下,后面再说deep的事儿。
第二种方法,直接传ref对象给watch,但加一个配置项{ deep: true },这时候watch就会递归监听ref内部reactive对象的所有嵌套属性,只要有任何一个属性变了,都会触发回调,这种方法的好处是省事,不用写一大堆路径;但坏处就是可能会浪费性能——如果你只关心name,但age或者其他几百个嵌套属性变了,都会触发这个回调,尤其是在你的数据量比较大、嵌套层级比较深的项目里,这个性能损耗可能会很明显,所以如果能用第一种精准监听的方法,尽量别用deep,除非你真的需要监听整个对象的所有变化。
还有一种稍微特殊的情况,就是当你用watchEffect的时候,watchEffect和watch不一样,它不需要你明确指定监听源,只要你在回调函数里面用到了响应式数据,不管是ref的解包值,还是reactive的属性,甚至是computed的返回值,它都会自动追踪,一旦这些数据变化就会重新执行,比如你写watchEffect(() => { console.log(user.value.name) }),那不管你是整体替换user.value,还是只改name,都会触发,不过watchEffect的缺点也很明显:它没有oldVal,只有newVal;而且它会在组件初始化的时候自动执行一次,不像watch可以通过配置{ immediate: false }(默认就是false)控制初始不执行,所以watch和watchEffect各有各的适用场景,别混着用。
第二个常见坑:修改对象属性后,watch回调里的oldVal和newVal一模一样
这个坑出现的概率比第一个还高,尤其是在新手第一次用deep配置或者用函数返回引用类型当监听源的时候,为啥会这样?
咱们得从JavaScript的数据类型说起,基本数据类型是按值传递的,引用类型是按引用传递的,也就是说,当你修改一个reactive对象的嵌套属性时,这个对象本身的引用地址并没有变——原来的oldVal和现在的newVal指向的是同一个内存地址里的东西,那watch在对比新旧值的时候,会先检查引用地址是否相同:如果相同,不管里面的内容变没变,它就会直接用同一个对象作为oldVal和newVal传给回调,这时候你在回调里打印oldVal.name和newVal.name,肯定是一样的,都是修改后的值。
那怎么解决这个问题,拿到真正的旧值?官方文档里有提到一种方法,就是用函数当监听源,并且对返回的引用类型数据做一个深拷贝,比如你可以用JSON.parse(JSON.stringify())这个简单粗暴的方法,不过这个方法有局限性——不能处理Date对象、正则表达式、函数、循环引用这些特殊数据;如果你的数据里没有这些特殊类型,那可以用,或者你可以用Vue3自带的工具函数cloneDeep?哦不对,Vue3核心包里没有这个,你得安装lodash-es的cloneDeep,或者自己写一个浅一点的深拷贝函数。
举个例子,用lodash-es的cloneDeep解决这个问题:
import { watch } from 'vue'
import cloneDeep from 'lodash-es/cloneDeep'
const user = ref({ name: '张三', age: 20 })
watch(
() => cloneDeep(user.value),
(newVal, oldVal) => {
console.log('新值:', newVal)
console.log('旧值:', oldVal)
},
{ deep: true } // 这里虽然加了deep,但因为我们已经深拷贝了,其实也可以不加?不对,我们监听的是cloneDeep后的新对象,每次user.value变化的时候,函数返回的都是一个新的深拷贝对象,引用地址肯定不一样,所以其实不需要加deep,watch就能自动对比引用地址触发回调,这时候oldVal和newVal就是两个不同的对象了。
)
对哦,刚才那个例子里,如果我们用cloneDeep的话,每次函数执行都会返回一个新的对象,引用地址变了,所以不管有没有deep配置,watch都会触发,而且oldVal和newVal是真正的新旧两个状态,不过这里要注意,cloneDeep的性能开销是比较大的,如果你的对象嵌套很深、数据量很大,每次变化都深拷贝一次,可能会影响页面的流畅度,所以这个方法也要谨慎使用,除非你真的非常需要拿到旧值。
有没有其他不需要深拷贝的方法?其实有,但要看你的具体需求,比如你只需要对比某个具体属性的新旧值,那用第一种精准监听的方法,直接监听那个属性就行,这时候oldVal和newVal肯定是按值传递的,不会一样,或者你可以在修改对象属性之前,手动保存一个旧值,
const user = ref({ name: '张三', age: 20 })
let oldUser = { ...user.value } // 这里用展开运算符做一个浅拷贝,如果是嵌套对象的话,还要继续展开或者浅拷贝
const updateName = (newName) => {
oldUser = { ...user.value } // 先保存旧值
user.value.name = newName // 再修改新值
}
watch(
() => user.value.name,
(newVal) => {
console.log('新名字:', newVal)
console.log('旧名字:', oldUser.name)
}
)
这种方法的好处是性能好,不需要深拷贝;坏处是需要你手动管理旧值,稍微麻烦一点,而且如果你的对象是在多个地方修改的,每个地方都得记得保存旧值,容易出错,所以还是得根据你的具体场景选择合适的方法。
第三个常见坑:ref包裹的普通对象,被整体替换成了非响应式的,watch反而不生效了?
不对不对,刚才第一个坑说的是整体替换会生效,但为啥有时候替换了反而不生效?哦,是因为你替换的是整个ref吗?不,ref本身是不能被重新赋值的,除非你用let声明ref变量,但那样的话ref的响应式特性就丢了——比如你写let user = ref({name: '张三'}),然后后来又写user = {name: '李四'},那这时候user已经不是一个ref对象了,变成了一个普通对象,不管是模板还是watch,都不会再监听它的变化,哦对!这也是一个非常容易犯的低级错误,新手一定要记住:ref和reactive声明的响应式变量,尽量用const声明,不要用let,更不要用var——因为用const声明的话,你只能修改它的属性(或者ref的value),不能替换整个变量本身,这样就能避免不小心把响应式变量改成普通变量的情况。
那有没有可能用const声明的ref,整体替换value后,watch还是不生效?比如你替换的value是原来的那个对象本身?比如const user = ref({name: '张三'}),然后const temp = user.value,再user.value = temp——这时候value的引用地址没变,所以watch当然不会触发,不管你有没有加deep配置,这种情况虽然少见,但有时候也会在处理数据缓存的时候遇到,比如你从缓存里取出了原来的对象,又赋值回去了,这时候就不会触发更新,这时候你要么手动把缓存里的对象深拷贝一份再赋值,要么用watchEffect加上一些其他的触发条件,或者直接调用forceUpdate(不过forceUpdate是最后没办法的办法,尽量别用)。
第四个常见坑:在watch回调里修改监听源本身,导致无限循环
这个坑不管是用watch还是watchEffect,不管是监听ref还是reactive,都有可能遇到,比如你写watch(user, (newVal) => { newVal.age++ }, { deep: true })——这时候只要user的任何属性变化,watch都会触发,然后回调里又修改了age,导致watch再次触发,然后又修改age,又触发……无限循环下去,浏览器会直接卡死。
那怎么避免无限循环?你要明确你的需求——为什么要在watch回调里修改监听源?如果是因为要根据某个属性的变化,自动计算另一个属性的值,那你应该用computed,而不是watch,比如你有一个price和count的属性,要计算total,那直接写const total = computed(() => price.value * count.value)就行,不用在watch里修改total或者其他属性。
如果必须要在watch回调里修改监听源,那你得加一个限制条件,比如只有当新值满足某个条件的时候才修改,或者用一个标志位来控制是否执行修改逻辑。
const user = ref({ name: '张三', age: 20, isAdult: false })
let isUpdatingAdult = false // 标志位
watch(
() => user.value.age,
(newAge) => {
if (isUpdatingAdult) return // 如果是正在修改isAdult导致的触发,直接返回
isUpdatingAdult = true // 开始修改isAdult,先把标志位设为true
user.value.isAdult = newAge >= 18
isUpdatingAdult = false // 修改完成,把标志位设为false
}
)
这样的话,修改isAdult的时候,虽然会触发watch监听age的回调吗?不对,刚才的监听源是age,不是isAdult,所以修改isAdult不会触发——哦,我刚才举的例子不太对,重新举一个:比如你有一个inputValue的ref,你要监听它的变化,然后把它转换成大写,再赋值回去:
const inputValue = ref('')
let isConverting = false
watch(
inputValue,
(newVal) => {
if (isConverting) return
isConverting = true
inputValue.value = newVal.toUpperCase()
isConverting = false
}
)
对,这样就不会无限循环了——第一次输入的时候,inputValue变成小写的'abc',触发watch,isConverting是false,所以把它转换成'ABC'赋值回去;赋值回去的时候,又触发watch,但这时候isConverting是true,所以直接返回,不会再修改,等修改完成,isConverting又变成false,等待下一次输入。
Vue3中watch监听ref包裹的普通对象的正确姿势
好了,说了这么多坑,咱们来总结一下正确的使用方法,让你以后再也不会踩雷:
- 尽量用const声明ref和reactive变量:避免不小心把响应式变量替换成普通变量,丢失响应式特性。
- 只需要监听特定属性时,用函数返回具体路径:精准、高效,不会有oldVal和newVal一样的问题(除非监听的是引用类型的属性)。
- 需要监听整个对象的所有变化时,用ref对象当监听源加deep配置:省事,但要注意性能损耗,尽量别在数据量大、嵌套深的场景用。
- 需要拿到引用类型监听源的真正旧值时,用深拷贝函数包一下监听源:可以用lodash-es的cloneDeep,或者自己写一个,但要注意性能开销;如果只需要对比某个属性,还是用精准监听更靠谱。
- 尽量别在watch回调里修改监听源本身:如果必须要改,用computed代替,或者加标志位控制,避免无限循环。
- 如果需要自动追踪所有用到的响应式数据,用watchEffect:但要注意它没有oldVal,而且会在初始化的时候自动执行。
其实Vue3的响应式API设计得已经非常人性化了,只要你摸透了它的底层逻辑(Proxy、浅监听、引用传递 vs 值传递),这些坑其实都是很容易避开的,多动手写几个小例子,练练手,你很快就能掌握了。
官方文档里还有很多关于watch和ref的细节,比如监听多个源、flush配置(控制watch回调是在DOM更新前还是更新后执行)、immediate配置(控制是否在初始化的时候执行一次)等等,如果你有兴趣的话,可以去官方文档里看看,这些细节在实际开发中也经常会用到。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



