Vue3项目用Map总踩坑?和Object、Reactive、Ref结合的全场景避坑指南来了!
Map是ES6之后新增的键值对数据结构,之前很多前端写Vue2项目习惯用普通Object存字典,但到了Vue3响应式系统升级,直接把Ref/Reactive套在Map上会不会有问题?什么时候用Map比Object更合适?还有数组转Map、遍历删除这种高频操作,有哪些不常见但好用的技巧?今天就把这几个月踩过的Vue3 Map相关坑,还有查文档、翻实战经验总结的全场景用法全说清楚。
Vue3 Map和普通Object到底选哪个?别凭感觉选
很多人一开始会纠结:Object用了这么多年熟得很,干嘛非要换看起来更复杂的Map?其实不是必须换,但得看具体场景,Vue3下两者的响应式特性也不一样,先把选择逻辑理明白。
从ES6原生特性的角度看:Map有这3个“不可替代”的优势
第一个是键的类型完全不受限,Object只能用字符串、Symbol作为键,如果你存的是用户ID、坐标数组、甚至DOM元素这种非字符串类型的键,直接用Object的话,浏览器会自动把这些键转成字符串——比如把用户ID是数字123和字符串'123'存到Object里,会被当成同一个键覆盖掉,存坐标数组[116.397,39.908]的话,会转成'116.397,39.908',要是键里有中文或者特殊字符,转出来的字符串可读性也很差,还容易出bug,Map就不一样了,数字、字符串、Symbol、数组、对象、函数、null、undefined,所有能想到的JavaScript数据类型都能当键,严格按引用或值(原始类型)比较,不会有自动转义的问题。
第二个是直接支持迭代器,不需要额外用Object.keys()、Object.values()、Object.entries()这些方法,比如要遍历Map的所有键值对,直接用for...of循环就行,顺序还和插入的顺序完全一致——注意哦,ES6之后Object的遍历虽然也有一定顺序(先按数字键升序,再按字符串/Symbol键插入顺序),但处理数字键和非数字键混合的情况特别容易乱,比如插入顺序是'user3'、1、'user1'、2,Object遍历出来的键会是1、2、'user3'、'user1',要是你做的是列表展示、消息队列这种严格依赖插入顺序的功能,Object肯定不行,Map就能完美解决。
第三个是性能更稳定,尤其是频繁增删键值对的场景,查过一些性能测试的结果:当键值对数量在100个以内时,Object和Map的性能差不多;超过100个之后,Map的增删速度要比Object快30%到50%,遍历速度也有优势;要是数量达到10000个以上,Object的性能会急剧下降,Map还是能保持稳定,这是因为Object内部存储是哈希表加链表的结构,还有原型链的干扰,增删大数量键值对的时候需要重新分配内存或者调整哈希表的桶,而Map内部专门优化了键值对的存储和遍历,没有原型链的问题。
从Vue3响应式特性的角度看:两者的绑定方式有小区别
Vue3的响应式系统是基于Proxy实现的,Reactive本来就是用来包裹引用类型(数组、对象、Map、Set等)的,所以理论上直接用Reactive包裹Map就能实现响应式,但实际用的时候,要注意一个细节:不能直接替换整个Reactive包裹的Map对象,否则会丢失响应式——比如你写了const userMap = reactive(new Map()),然后在代码里写userMap = new Map([['1', {name: '张三'}]]),这时候原来的Proxy对象被替换成了普通的Map,界面肯定不会更新,如果真的需要替换整个Map,应该用Ref包裹,因为Ref的value是可变的,替换value不会丢失响应式。
普通Object用Reactive包裹也没问题,但要注意两个Vue3特有的坑:第一个是删除键的时候,要用Vue3提供的deleteProperty函数,不能直接用delete操作符——比如const userObj = reactive({1: {name: '张三'}}),直接写delete userObj[1]的话,界面不会更新,必须写import { deleteProperty } from 'vue',然后deleteProperty(userObj, 1);第二个是给对象添加新键的时候,要是对象一开始没有这个键,直接赋值userObj['2'] = {name: '李四'}是可以响应式的吗?哦对了,Vue3的Proxy已经解决了Vue2的这个缺陷,不管是添加、修改还是删除键,只要是通过Reactive包裹的对象操作的,都能触发响应式,但如果是把整个对象里的某个嵌套对象替换成普通对象,那嵌套对象的后续操作就不会响应式了——这个坑不管是Object还是Map包裹Reactive都会有,处理的时候要小心。
Vue3 Map和Reactive、Ref的正确绑定姿势
刚才说了不能直接替换Reactive包裹的Map,那具体怎么绑定才不会出问题?分两种常用场景说。
不需要替换整个Map对象,用Reactive
这种场景最常见,比如存用户信息列表、商品分类字典,只需要增删改查其中的某个键值对,不需要把整个字典清空重写,直接用Reactive包裹就行,写法也很简单: 先定义响应式的Map, const categoryMap = reactive(new Map()) 然后就可以用Map原生的方法操作了,比如set添加键值对: categoryMap.set('1', { id: '1', name: '电子产品', parentId: '0' }) get获取键值对: const productCategory = categoryMap.get('1') has检查键是否存在: if (categoryMap.has('2')) { console.log('有图书分类') } delete删除键值对: categoryMap.delete('1') clear清空所有键值对: categoryMap.clear() 这些操作都是原生的,但因为是通过Reactive的Proxy对象操作的,所以都能触发Vue3的响应式更新,界面会自动刷新。
可能需要替换整个Map对象,用Ref包裹Reactive,或者直接用Ref包裹Map
很多人会问:直接用Ref包裹Map行不行?会不会丢失响应式?答案是:如果用Vue3.3之前的版本,直接用Ref包裹普通Map,只有value整体替换的时候才会触发响应式,用Map原生方法操作内部键值对不会触发响应式,因为Ref只会监听value的引用变化,不会深度监听内部的属性或元素(Vue3的Ref默认是浅监听,除非加了deep: true,但加了deep: true之后性能会下降);如果用Vue3.3及以后的版本,直接用Ref包裹普通引用类型(包括Map、Set、数组、对象)会自动变成深监听的Reactive包装的Ref,这时候用原生方法操作内部键值对也能触发响应式。
不过为了兼容性更好,不管是哪个版本的Vue3,我都建议用两种写法: 第一种是Ref包裹Reactive的Map: const categoryMap = ref(reactive(new Map())) 这种写法的好处是,不管是替换整个Map的value,还是用原生方法操作内部键值对,都能触发响应式——替换的时候写categoryMap.value = reactive(new Map([['1', {name: '电子产品'}]])),操作内部的时候写categoryMap.value.set('2', {name: '图书'}),都没问题; 第二种是用Vue3提供的shallowReactive包裹Map,再用Ref?不对,shallowReactive只会监听第一层的引用变化,内部键值对的操作不会触发响应式,所以没必要,要是你怕deep: true影响性能,又不需要监听Map内部嵌套对象的变化(比如Map的value是字符串、数字这种原始类型),可以直接用Vue3.3之前版本的浅Ref,只在替换整个Map的时候触发响应式,操作内部键值对的话手动触发更新——不过这种场景很少见,还是用深监听或者Ref包裹Reactive更稳妥。
Vue3 Map的高频操作技巧,很多人都不知道
除了基本的增删改查,Vue3项目里还有很多Map的高频操作,比如数组转Map、批量删除键值对、按值查找键、深拷贝响应式Map,这些操作有一些不常见但好用的技巧,能帮你少写很多代码,还能提高性能。
数组转Map的三种方法,按需选择
数组转Map是Vue3项目里最常用的操作之一,比如从后端接口拿到了用户信息数组,要快速通过用户ID获取用户信息,这时候转成Map比遍历数组高效多了。
第一种方法是直接用Map构造函数,这是最简单的,适合数组的元素是数组的情况([key1, value1], [key2, value2]]),或者数组的元素是对象,需要自己指定键和值:
比如从后端拿到的用户数组是:
const userList = [
{ id: '1', name: '张三', age: 25 },
{ id: '2', name: '李四', age: 30 }
]
要转成以id为键,整个对象为值的Map,直接写:
const userMap = reactive(new Map(userList.map(item => [item.id, item])))
这种写法一行代码就能搞定,比用forEach循环push快多了,而且可读性很强;
第二种方法是用forEach循环批量set,适合需要对值或者键做一些处理的情况:
比如要转成以id为键,以name为值的Map,还要给name加个前缀“用户:”,可以写:
const userNameMap = reactive(new Map())
userList.forEach(item => {
userNameMap.set(item.id, 用户:${item.name})
})
第三种方法是用reduce方法,这种方法看起来比较高级,适合喜欢函数式编程的人:
const userAgeMap = userList.reduce((map, item) => {
map.set(item.id, item.age)
return map
}, reactive(new Map()))
这里要注意,reduce的初始值必须是响应式的Map,否则生成的Map是普通的,不会触发响应式。
批量删除键值对的两种方法,别一个个delete
如果要删除Map里的多个键值对,别一个个写delete,不仅麻烦,还可能因为操作次数太多影响性能,有两种更高效的方法: 第一种方法是先收集要删除的键,再用forEach循环批量delete,适合要删除的键有规律的情况: 比如要删除Map里所有parentId不为'0'的分类(也就是子分类),可以先收集所有符合条件的键: const keysToDelete = [] for (const [key, value] of categoryMap) { if (value.parentId !== '0') { keysToDelete.push(key) } } 然后批量删除: keysToDelete.forEach(key => categoryMap.delete(key)) 为什么要先收集键再删除?因为如果直接在for...of循环里删除键值对,会改变Map的遍历顺序,可能会漏删一些键值对——这个坑很多人都踩过,必须注意; 第二种方法是重新生成一个只保留需要的键值对的Map,适合要删除的键比较多,保留的键比较少的情况,性能会比批量delete更好: 比如只保留parentId为'0'的分类,可以直接写: categoryMap.value = reactive(new Map([...categoryMap.value].filter(([key, value]) => value.parentId === '0'))) 这里用了展开运算符把Map转成键值对数组,然后用filter过滤,最后再转成响应式的Map,这种写法一行代码就能搞定,而且性能很高,因为不需要循环删除,只需要重新生成一个新的Map。
按值查找键的三种方法,避免写双重循环
按值查找键是Map的一个痛点,因为Map原生只有get(按键找值)、has(按键查存在)的方法,没有按值找键的方法,这时候很多人会写双重循环遍历数组再遍历Map,性能很差,其实有三种更高效的方法: 第一种方法是用for...of循环直接遍历Map的键值对,找到第一个符合条件的键就返回,找不到就返回undefined,这种方法适合只需要找一个符合条件的键的情况,性能最高: function getKeyByValue(map, targetValue) { for (const [key, value] of map) { if (value === targetValue) { // 如果value是对象,要用深比较,比如lodash的isEqual return key } } return undefined } 第二种方法是用Array.from()把Map转成键值对数组,再用find方法,这种方法适合喜欢函数式编程的人,和第一种方法性能差不多,但可读性更强: function getKeyByValue(map, targetValue) { const entry = Array.from(map).find(([key, value]) => value === targetValue) return entry ? entry[0] : undefined } 第三种方法是维护一个反向的Map,也就是以原Map的value为键,原Map的键为值,这种方法适合频繁按值查找键的情况,性能最高,但要注意同步更新——比如原Map添加了一个键值对,反向Map也要添加;原Map删除了一个键值对,反向Map也要删除;原Map修改了一个键的值,反向Map也要删除旧的键值对,添加新的: const userMap = reactive(new Map()) const reverseUserMap = reactive(new Map()) // 添加的时候同步更新 function addUser(id, user) { userMap.set(id, user) reverseUserMap.set(user.name, id) // 假设value的name是唯一的 } // 删除的时候同步更新 function deleteUser(id) { const user = userMap.get(id) if (user) { reverseUserMap.delete(user.name) userMap.delete(id) } } // 修改的时候同步更新 function updateUser(id, newUser) { const oldUser = userMap.get(id) if (oldUser) { reverseUserMap.delete(oldUser.name) } userMap.set(id, newUser) reverseUserMap.set(newUser.name, id) } // 按name查找id就很快了 function getUserIdByName(name) { return reverseUserMap.get(name) } 这里要注意,反向Map的value必须是唯一的,否则会被覆盖掉,如果value不是唯一的,反向Map的value可以存一个数组,比如reverseUserMap.set(user.age, [...(reverseUserMap.get(user.age) || []), id])。
深拷贝响应式Map的正确方法,别用JSON.parse(JSON.stringify())
很多人深拷贝对象或者数组的时候习惯用JSON.parse(JSON.stringify()),但这个方法不能用来深拷贝响应式Map,因为:
- JSON.stringify()不能识别Map、Set、Symbol、函数、正则表达式、Date对象等特殊类型,会把Map转成{},Date转成字符串,函数和Symbol直接忽略;
- 即使能识别,生成的也是普通的Map,不是响应式的;
- 如果Map的value有循环引用,JSON.parse(JSON.stringify())会报错。
那正确的深拷贝方法是什么?分两种情况: 第一种情况是Map的value是原始类型或者简单的对象(没有循环引用、没有特殊类型),可以用Vue3提供的toRaw()函数先把响应式Map转成普通Map,然后用structuredClone()深拷贝普通Map,最后再用Reactive包裹: import { reactive, toRaw } from 'vue' const originalMap = reactive(new Map([['1', { name: '张三', date: new Date() }]])) // 先转成普通Map const rawMap = toRaw(originalMap) // 用structuredClone深拷贝(支持Date、正则表达式等特殊类型,但不支持函数和Symbol) const clonedRawMap = structuredClone(rawMap) // 再转成响应式Map const clonedMap = reactive(clonedRawMap) 这里要注意,structuredClone()是ES2022新增的方法,兼容性还可以,除了IE浏览器都支持,如果你的项目需要兼容IE,就不能用这个方法; 第二种情况是Map的value有函数、Symbol或者循环引用,或者需要兼容IE浏览器,就用lodash的cloneDeep()函数,然后再用Reactive包裹: import { reactive, toRaw } from 'vue' import cloneDeep from 'lodash/cloneDeep' const originalMap = reactive(new Map([['1', { name: '张三', fn: () => console.log('hello'), sym: Symbol('test'), date: new Date() }]])) const rawMap = toRaw(originalMap) const clonedRawMap = cloneDeep(rawMap) const clonedMap = reactive(clonedRawMap) lodash的cloneDeep()支持所有JavaScript数据类型,包括循环引用、函数、Symbol等,兼容性也很好,但会增加项目的体积,如果你的项目不需要兼容IE,而且没有特殊类型,还是用structuredClone()更轻量。
Vue3 Map的常见踩坑记录,提前避坑
刚才说了很多技巧,现在再总结一下这几个月踩过的Vue3 Map相关的坑,提前避坑能帮你节省很多调试时间。
坑一:直接替换Reactive包裹的Map对象,丢失响应式
这个坑刚才已经提过了,再强调一遍:如果用Reactive包裹的Map,绝对不能直接替换整个对象,比如const categoryMap = reactive(new Map()),然后写categoryMap = new Map(...),这时候原来的Proxy对象被替换成了普通的Map,界面肯定不会更新,如果真的需要替换整个Map,就用Ref包裹Reactive的Map,或者直接用Vue3.3及以后版本的Ref包裹普通Map。
坑二:直接用Ref包裹Vue3.3之前版本的普通Map,操作内部键值对不触发响应式
刚才也提过了,Vue3.3之前的版本,Ref默认是浅监听,只会监听value的引用变化,不会深度监听内部的属性或元素,所以直接用Ref包裹普通Map,只有value整体替换的时候才会触发响应式,用Map原生方法操作内部键值对不会触发响应式,解决方法要么是升级到Vue3.3及以后的版本,要么是用Ref包裹Reactive的Map,要么是加deep: true(但加了deep: true之后性能会下降)。
坑三:在for...of循环里直接删除Map的键值对,漏删元素
刚才在批量删除键值对的技巧里也提过了,直接在for...of循环里删除键值对,会改变Map的遍历顺序,可能会漏删一些键值对,解决方法是先收集要删除的键,再用forEach循环批量删除,或者重新生成一个只保留需要的键值对的Map。
坑四:用Object.keys()、Object.values()、Object.entries()遍历响应式Map,得不到正确的结果
很多人习惯了遍历Object的方法,就直接用来遍历Map,这是不对的,因为响应式Map虽然是Proxy对象,但它的本质还是Map,不是Object,Object.keys()等方法只能遍历Object的可枚举属性,不能遍历Map的键值对,正确的遍历方法是用for...of循环、forEach方法、或者展开运算符转成键值对数组再遍历。
坑五:比较Map的value是对象的时候用===,导致判断错误
因为JavaScript的对象是按引用比较的,不是按值比较的,所以如果Map的value是对象,用===比较的话,只有两个对象的引用完全一致的时候才会返回true,否则都会返回false,解决方法是用深比较,比如lodash的isEqual()函数,或者自己写一个深比较的函数。
Vue3 Map的使用建议
最后再给大家几个Vue3 Map的使用建议,帮助大家更好地选择和使用Map:
- 什么时候用Map?什么时候用Object?
- 用Map的情况:键的类型不是字符串或Symbol;严格依赖插入顺序;频繁增删大数量键值对;需要直接支持迭代器。
- 用Object的情况:键的类型是字符串或Symbol;不需要严格依赖插入顺序;键值对数量很少(100个以内);需要用JSON序列化。
- 绑定响应式的时候优先用Ref包裹Reactive的Map,兼容性更好,不管是替换整个Map还是操作内部键值对都能触发响应式。
- 数组转Map优先用Map构造函数,一行代码就能搞定,可读性强,性能高。
- 按值查找键频繁的话,维护一个反向的Map,性能最高,但要注意同步更新。
- 深拷贝响应式Map别用JSON.parse(JSON.stringify()),用structuredClone()或者lodash的cloneDeep()。
- 提前避坑,记住刚才总结的5个常见坑,避免浪费调试时间。
Vue3的响应式系统升级之后,Map的使用变得更加方便和高效,只要掌握了正确的绑定姿势和高频操作技巧,就能很好地发挥Map的优势,提高代码的可读性和性能,希望这篇文章能帮到大家,要是有其他Vue3 Map相关的问题,欢迎在评论区留言讨论。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网



