Vue3 watch Map怎么监听才能生效?注意事项有哪些?
做前端开发的小伙伴最近肯定都有感觉,Vue3的响应式系统虽然比Vue2强,但碰到复杂数据类型比如Map还是容易踩雷——明明代码改了Map里的值,watch却纹丝不动,页面也不会更新;要么就是watch了但监听到的是同一对象,没法做值对比;还有想只监听某个key的变化,又不知道怎么精准触发,这些问题我之前写后台管理系统时全踩过,折腾了好几天,才摸出一套既实用又靠谱的方案。
Vue3 watch 直接监听Map实例为啥经常没用?
刚开始遇到问题时,我第一反应是Vue3的Proxy是不是出bug了,后来翻了响应式原理的资料才明白,根本不是这么回事:Vue3的watch默认只会监听引用数据类型的引用变化,Map作为典型的引用类型,只有整个实例被重新赋值时(比如map.value = new Map([[1,2]])),才会触发默认的浅监听;而我们平时用Map的set、delete、clear这些原生方法改内容,引用地址一点没变,watch自然就抓不到信号。
那Proxy为啥没拦截这些方法呢?其实拦截了!Vue3给响应式Map(也就是ref或者reactive包裹过的Map,建议用ref包裹更方便赋值判断)加的Proxy处理器里,专门重写了get、set、deleteProperty这些核心方法,修改内容时会触发内部的响应式更新,页面其实是会跟着变的——只不过默认的watch没有开启深层监听,看不到这些“内部的小变动”而已。
比如这段新手常写的代码,控制台只会打印一次“初始化监听”,不管怎么点按钮修改age,都不会再触发回调:
<script setup>
import { ref, watch } from 'vue'
const userMap = ref(new Map([
['name', '小明'],
['age', 18]
]))
// 直接监听userMap.value或者userMap,都不会抓内部方法的修改
watch(userMap, (newVal, oldVal) => {
console.log('userMap变了!', newVal, oldVal)
}, {
immediate: true
})
const updateAge = () => {
userMap.value.set('age', userMap.value.get('age') + 1)
}
</script>
<template>
<div>姓名:{{ userMap.get('name') }}</div>
<div>年龄:{{ userMap.get('age') }}</div>
<button @click="updateAge">加一岁</button>
</template>
不信你可以复制这段代码跑一下,页面上的年龄肯定会跳,但控制台就只有初始化那一行。
Vue3 watch Map生效的3种常用方法
既然知道了问题出在“深层监听”或者“精准追踪依赖”上,解决办法就有针对性了,我整理了3种常用且实用的,覆盖了日常开发的绝大部分场景,每种都有优缺点和适用场景,大家可以按需选。
开启deep: true深层监听
这是最直接的办法,给watch加个{ deep: true }配置项就行,不管是用ref还是reactive包裹的Map,内部的set、delete、clear、键值修改全都会触发回调,不过要注意,开启deep之后,Vue3会递归遍历整个响应式对象/Map/Set,性能会有一定损耗——如果你的Map里有成千上万个键值对,或者键对应的值又是多层嵌套的对象数组,频繁触发深层监听会影响页面流畅度,这时候就得考虑后面两种方法了。
刚才那段代码加个deep就能跑通:
watch(userMap, (newVal, oldVal) => {
console.log('userMap变了!', newVal, oldVal)
}, {
immediate: true,
deep: true // 开启深层监听
})
不过这里还有个小坑:开启deep之后,newVal和oldVal会是同一个引用!因为Proxy拦截的是内部操作,没有重新生成新的Map实例,如果你需要对比修改前后的完整内容,得自己手动深拷贝一份oldVal或者newVal,比如用JSON序列化,但要注意JSON序列化会丢失Date、RegExp、Function这些特殊类型,最好用Vue3内置的structuredClone(Vue3.3+才完全兼容,旧版本可以用lodash的cloneDeep)。
返回一个函数,精准追踪需要监听的键
如果你的Map只有少数几个键需要监听,或者键值对很多但不想开启深层监听,这个方法绝对是首选!给watch的第一个参数传一个箭头函数,函数里专门调用你要监听的键对应的get方法,这样Vue3的响应式系统只会追踪这些get操作产生的依赖,不会遍历整个Map,性能损耗几乎为零,而且newVal和oldVal也能正确区分,不用手动深拷贝。
比如只想监听userMap里的age键,代码可以写成这样:
watch(
() => userMap.value.get('age'), // 只追踪age的get依赖
(newAge, oldAge) => {
console.log('年龄变了!', newAge, oldAge)
},
{ immediate: true }
)
这段代码不仅性能好,而且控制台打印的oldAge和newAge是正确的数值,不会出现引用相同的问题,如果要监听多个键,可以在箭头函数里返回一个数组,把所有需要追踪的键值放进去:
watch(
() => [userMap.value.get('name'), userMap.value.get('age')],
([newName, newAge], [oldName, oldAge]) => {
console.log('姓名或年龄变了!', { newName, newAge, oldName, oldAge })
},
{ immediate: true }
)
这里还有个进阶用法:如果Map的键是动态生成的,可以把键也放在数组里一起返回吗?其实不用,只要动态键存在的时候你调用过对应的get方法,响应式系统就能追踪到——比如有个input框输入动态键,按钮点击后给这个键赋值,只要你在watch的箭头函数里遍历一遍Map的所有键调用get?不对,遍历调用get反而又有点像深层监听了,性能会下降,更好的办法是用computed缓存动态键的状态,或者直接把动态键的生成逻辑和监听get逻辑绑定在一起。
监听Map的size属性
如果你只需要知道Map里有没有新增或者删除键(不管具体是哪个键,也不管键对应的值有没有变),监听size属性是最快的!因为set会让size加1(如果之前没有这个键),delete减1,clear减到0,修改已有键的value不会改变size,刚好适合做“数量变化”的场景,比如后台管理系统里的购物车商品数量提示、已选项目数量变化触发批量操作按钮的启用禁用等等。
监听size属性和精准监听键的方法一样,用箭头函数返回size就行:
watch(
() => userMap.value.size,
(newSize, oldSize) => {
console.log('Map的数量变了!', newSize, oldSize)
},
{ immediate: true }
)
这段代码的性能几乎可以忽略不计,因为size是Map的一个普通属性,Proxy拦截起来非常快。
Vue3 watch Map还有哪些容易忽略的细节?
刚才说的3种方法已经能解决大部分问题,但还有几个细节容易踩雷,我整理了4个最常见的,大家一定要注意:
watch和watchEffect的区别
很多新手会分不清watch和watchEffect,其实用在Map上区别挺大的:
- watch需要明确指定依赖(比如ref实例、带deep的ref实例、返回键值的箭头函数、返回size的箭头函数),只有依赖变化时才会触发回调,有newVal和oldVal;
- watchEffect会自动收集依赖(只要在回调函数里调用了响应式数据的任何属性或方法,都会被收集),初始化时会自动执行一次,没有oldVal,只有当前的newVal。
比如用watchEffect监听Map的age:
watchEffect(() => {
console.log('年龄现在是:', userMap.value.get('age'))
})
这段代码初始化会打印一次,每次修改age也会打印,但不会打印oldAge,如果你的需求不需要oldVal,而且依赖很多不想一个个写在watch的第一个参数里,watchEffect会更方便,但要注意不要在watchEffect里写太多无关的响应式数据操作,否则会频繁触发回调。
ref包裹Map和reactive包裹Map的区别
虽然两者都能让Map变成响应式,但用法上有一些小差异:
- ref包裹Map时,访问和修改都需要加
.value,比如userMap.value.set('a',1); - reactive包裹Map时,不需要加
.value,直接访问和修改就行,比如userMap.set('a',1); - 用watch监听时,不管是ref还是reactive,带deep的直接传实例;精准监听键或者size时,ref要加
.value,reactive不用。
那到底用ref还是reactive呢?其实官方推荐用ref包裹引用数据类型,因为ref可以直接判断是否为响应式(用isRef),而且以后如果需要把整个Map实例替换掉,ref更方便——reactive替换掉整个实例的话,原来的响应式绑定就会失效,必须用Object.assign之类的方法把新内容合并进去。
Map里的嵌套对象/数组修改时的处理
如果Map里的键对应的值是嵌套的对象或数组,
const userMap = ref(new Map([ ['name', '小明'], ['hobbies', ['打篮球', '踢足球']] ]))
这时候修改hobbies数组的内容(比如push、pop),用deep:true可以监听到,用精准监听数组的方法(比如() => [...userMap.value.get('hobbies')])也可以监听到,但要注意用精准监听数组的话,最好是返回数组的浅拷贝或者JSON序列化后的结果,否则如果只是修改数组的某个索引值(比如userMap.value.get('hobbies')[0] = '游泳'),返回原数组的话引用地址没变,watch不会触发。
Vue2转Vue3时要注意的旧写法
有些从Vue2转过来的小伙伴会习惯用$watch,但Vue3的组合式API里没有$watch,要用watch或者watchEffect;还有Vue2里用Vue.set给对象加新属性才能响应式,但Vue3里不管是ref还是reactive包裹的Map,用原生的set、delete、clear都是响应式的,不需要任何额外的方法。
Vue3 watch Map生效的核心逻辑其实就是利用Proxy的特性,要么开启deep:true让watch递归追踪内部变化(适合数据量小的场景),要么用返回键值/返回size的箭头函数精准追踪依赖(适合数据量大的场景),还要注意newVal和oldVal的引用问题、ref和reactive的用法区别、嵌套数据的处理方式。
我之前踩坑的时候还写了个小demo测试了这3种方法的性能,在Map里放了10000个键值对,每个键对应一个有3层嵌套的对象,开启deep:true修改其中一个键值,大概需要0.2-0.5毫秒;用返回单个键值的箭头函数修改同一个键值,大概只需要0.005-0.01毫秒,差距其实挺明显的,所以大家在开发的时候一定要根据场景选对方法,不要图省事直接开deep。
希望这篇文章能帮到正在踩坑的小伙伴,如果还有其他关于Vue3响应式系统的问题,欢迎在评论区留言讨论!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


