Map和Set实战指南:从踩坑到选型,彻底搞懂JS集合类型

发布时间:2026/10/20 12:28:20
Map和Set实战指南:从踩坑到选型,彻底搞懂JS集合类型 1. 项目概述做前端的人尤其是写了好几年业务代码的人对Map和Set一定不陌生。但我发现一个很有意思的现象很多同学面试时会背概念说起ES6新增了Map和SetMap是键值对集合Set是去重集合头头是道可真正落到项目里最常用的数据结构依然是{}和[]Map和Set常年吃灰。说实话这有点浪费。这两个数据结构不是锦上添花的语法糖在某些场景下它们能明显提升代码的可读性和性能。我自己是从一次排查线上Bug的经历里真正建立起对它们的好感的——当时有个同事用对象去缓存接口数据key是字符串拼接的请求参数结果在某个参数里混进来了__proto__这个字符串整个缓存对象瞬间被污染页面白屏了半小时。后来换成Map同样的逻辑干干净净问题再没出现过。这篇文章不打算做枯燥的API罗列。我会直接跳到什么时候该用Map、什么时候该用Set这个最实际的问题上结合项目里的真实场景把我的踩坑经验和选型方法一次性讲清楚。想系统梳理ES6集合类型的读者或者工作中常和数据映射、去重逻辑打交道的开发者应该都能从里面拿到一些能直接用的东西。1.1 核心需求解析先把这两个东西的基本定位说清楚。Map解决的是键值对映射的需求它的本质是一个真正的哈希表实现。和普通对象{}相比它的键不再被强制转换成字符串可以是任意类型——对象、数组、函数都能当键。这一个特性就打开了很大的想象空间。Set解决的是不重复集合的需求。它天然保证集合内的元素唯一同时提供了近乎O(1)的查找和删除能力这在大量数据场景下比数组的indexOf要快好几个数量级。一句话概括我的使用原则需要动态增删键值对、键的类型不固定优先用Map需要维护一个唯一集合、频繁判断某个值是否存在优先用Set。1.2 为什么对象和数组不够用聊到Map和Set很多人的第一反应是对象和数组不也能干这些事吗这个疑问我理解因为从表面上看功能确实有重叠。但在真实的工程场景里它们各自有很难绕过去的短板。对象的第一个问题我已经在上面提到了——键名只能是字符串和Symbol。你如果写obj[true] 1引擎会把true转成字符串true存起来写obj[{}] 2这个对象会被转成[object Object]。这里还有个隐藏的巨大隐患当键名恰好是__proto__、constructor这类名字时对象的行为会变得非常不可预期甚至直接改变原型的指向造成原型链污染。数组的问题在于查询效率。判断一个值是否在数组里最常用的includes或indexOf都是线性遍历数组越大耗时越长。而Set的底层是哈希表查找的时间复杂度在O(1)差距在数据量上来之后会非常明显。这不是我凭空说数据吓人——曾经在一个权限系统里需要频繁判断用户是否在某个角色组里角色组有几万个UID用数组includes一次要几毫秒换成Set之后单次判断直接降到微秒级别接口从超时变成秒回。对象和数组不是不能用但在这些特定场景下Map和Set确实是不做第二选的更优解。2. Map使用场景拆解2.1 任意类型作为键真正的哈希映射Map最区别于普通对象的一点就是键可以是任意类型而且严格按引用相等来判断。这一点初看只是语法层面的放宽实际带来的能力是全方位的。举例来说我需要统计页面上不同DOM节点的点击次数如果用对象来做必须先给每个节点设一个唯一ID字符串然后obj[uid]操作很笨重。用Map的话不绕弯子直接拿DOM节点本身当键const clickCountMap new Map(); document.querySelectorAll(.btn).forEach(btn { btn.addEventListener(click, () { clickCountMap.set(btn, (clickCountMap.get(btn) || 0) 1); }); });再比如缓存场景你有一个由配置对象计算出的结果后续多次调用时想复用。如果每次都用对象的JSON字符串做key一旦对象的字段顺序不同就会导致缓存不命中。用Map的话直接把这个配置对象当作键存进去只要传入的是同一个引用就能命中缓存。最常见的还是配合函数式编程里的memoization。下面是一个用Map实现的高阶缓存函数function memoize(fn) { const cache new Map(); return function(...args) { const key args[0]; if (cache.has(key)) { return cache.get(key); } const result fn(...args); cache.set(key, result); return result; }; }这里args[0]可以是一个对象换成普通对象缓存就没办法实现。注意cache.has这一行Map的has方法等价于一个O(1)的哈希查找在做存在性判断时非常顺手这也是对象做不到的干净接口。注意用引用类型做键用的是引用地址来判断同一个键不是内容深比较。你传两个结构相同但不是同一个引用地址的对象进去在Map里会被当作两个不同的键这一点务必记住否则缓存和统计都会出现难以排查的错误。2.2 遍历顺序插入序保证普通对象即便在现代JavaScript里已经做到字符串键按插入顺序遍历但整数键和字符串键的遍历顺序和分组规则并不完全统一很容易让人摸不着头脑。数字键在前字符串键在后Symbol键躲在最后这个顺序在一些业务逻辑里根本不敢依赖。Map的遍历严格按照插入顺序先插入的先被遍历到。这个特性在需要记录操作历史、按时间序列处理数据时天然契合。Map本身就搭载了三个遍历方法keys()、values()和entries()而且它们都返回迭代器可以被for...of直接消费配合解构赋值可以写出很简洁的循环体const userScores new Map([ [张三, 85], [李四, 92], [王五, 77] ]); for (const [name, score] of userScores) { console.log(${name}: ${score}); }新手比较容易忽略的是迭代器是支持解构的。将userScores直接展开成数组得到的就是二维数组数组再配合fromEntries又能轻松把Map转回普通对象const arr [...userScores]; // [[张三, 85], [李四, 92], [王五, 77]] const obj Object.fromEntries(userScores); // { 张三: 85, 李四: 92, 王五: 77 }这个Map转对象的操作在实际开发里很常用——接收到的数据是Map但后端接口需要JSON对象一行代码搞定。反过来Object.entries(obj)配合new Map()也能干净地把对象转成Mapconst map new Map(Object.entries({ a: 1, b: 2 }));2.3 性能差异要点很多文章喜欢提Map性能比对象好这是一个不严谨的说法。严谨的说法是在动态增删键值对的场景下Map的性能表现稳定在静态读写已知属性的场景下对象反而更快。为什么因为普通对象在创建的时候引擎比如V8会对对象进行所谓的隐藏类优化如果属性结构固定且不变更访问属性可以直接通过偏移量计算速度飞快。而一旦你频繁动态添加或删除属性隐藏类就会失效引擎不得不退化为字典模式性能就会下降。Map本身就是一个专门为频繁增删优化的哈希表不存在这种分发逻辑所以在高频率的动态操作下Map能提供更稳定的表现。有个典型的性能误区反复在对象上做delete操作。当你在对象上删除属性引擎没法继续使用隐藏类优化后面的一切读取都会变慢。而Map专门提供了delete方法底层设计就是为了应对这类高频增删的场景。提示如果你有一个结构固定的对象只是反复读取它的属性别换成Map对象就很好。Map的性能优势体现在动态操作上不要为了用而用。如果你的代码有严重的性能瓶颈建议先用开发者工具做性能分析找到热点再优化不要盲目替换数据结构。3. Set使用场景拆解3.1 数组去重的正确写法Set最广为人知的用法就是去重用法特别简短const arr [1, 1, 2, 3, 3, 4, 5, 5]; const unique [...new Set(arr)]; // [1, 2, 3, 4, 5]一行代码搞定清晰明了。但这背后有两个细节容易踩坑。第一个坑是关于对象去重的。new Set(arr)对对象的去重效果是基于引用相等判断的两个内容完全相同但引用不同的对象会被当作不同的元素保留。如果你有这个需求比如按对象的id字段去重必须手动写逻辑例如用Map记录已经出现过的id再决定是否保留当前项。第二个坑涉及到NaN。在大多数人的认知里NaN ! NaN但Set内部用于判断两个值是否相等的算法比较特殊它认为NaN等于自身因此多个NaN会被合并为一个。这个行为其实是符合业务直觉的毕竟在去重场景里你几乎不会希望出现两个NaN。3.2 快速成员判断去重只是Set的表层用法。更核心的价值在于它的has方法能提供近乎O(1)的成员判断能力。这是一个我认为被严重低估的场景。举一个更贴近业务的具体例子。在表格组件里经常需要支持选中行和全选/全不选的交互。如果你用一个数组来维护所有选中行的ID每次判断某行是否被选中都要调用includes数组越长越慢而且切换选中状态的时候增加用push删除用splice代码里还要处理索引查找非常啰嗦。用Set来维护选中状态就舒服得多const selectedIds new Set(); function toggleSelect(id) { if (selectedIds.has(id)) { selectedIds.delete(id); } else { selectedIds.add(id); } } function isSelected(id) { return selectedIds.has(id); }has判断是否存在add增加元素delete删除元素三个操作全部是O(1)复杂度性能在大量行数据下也能稳定扛住。代码意图读起来也清晰不需要额外的注释。另一个我常遇到的场景是标签体系的白名单校验。比如用户提交一组标签我们需要过滤掉不在预设白名单里的标签。用Set装白名单判断速度和非白名单剔除的位置都是一行代码const allowedTags new Set([tech, life, work]); const userTags [tech, random, work, unknown]; const filtered userTags.filter(tag allowedTags.has(tag)); // [tech, work]反过来如果要找用户上传标签中新增了哪些白名单外的标签用Set的差集操作思路来做也非常直观。先把用户标签转换为Set然后遍历过滤出不在allowedTags中的项即可。3.3 集合运算模拟Set提供的另一个实用价值是可以很方便地模拟集合论中的交集、并集、差集运算。JavaScript原生没有这些API但用Set和展开语法组合代码比想象中的还要简洁。const setA new Set([1, 2, 3, 4]); const setB new Set([3, 4, 5, 6]); // 并集 const union new Set([...setA, ...setB]); // {1, 2, 3, 4, 5, 6} // 交集 const intersection new Set([...setA].filter(x setB.has(x))); // {3, 4} // 差集A - B const difference new Set([...setA].filter(x !setB.has(x))); // {1, 2}这里依赖的是展开运算符会把Set的迭代器结果铺成数组filter里再做一次哈希判断。交集和差集的复杂度都是O(n)很高效。这三个运算组合起来能覆盖不少真实需求。比如权限管理里一个用户拥有的权限集合和某个角色要求的权限集合之间不仅可以直接对比是否相等还能快速算出缺少哪些权限差集用来提示用户在两个数据源的ID列表之间求公共部分交集也是Data同步场景里的高频操作。4. WeakMap与WeakSet实战4.1 内存管理的变化讲到Map和Set就绕不开它们各自的弱引用兄弟WeakMap和WeakSet。它们和Map/Set最大的区别是key上的引用是弱引用不会被垃圾回收机制计入引用计数。这句话怎么理解用Map存对象只要这个Map还活着被存进去的对象就一直被强引用着永远不会被回收。这在某些场景会造成内存泄漏。而WeakMap持有的是对象的弱引用如果程序里除了WeakMap之外没有任何地方再引用这个对象垃圾回收器会在合适的时机把它收掉。但这带来一个限制WeakMap和WeakSet的key必须是对象不能是基本类型且它们没有size属性、不支持遍历。弱引用的对象随时可能被回收所以遍历没有意义这是设计取舍。如果需要用Map存对象且希望它能被垃圾回收释放应该用WeakMap。反过来说如果你需要反复遍历查看当前存了哪些键——比如调试时想看看缓存里有什么——那就老老实实用MapWeakMap做不到。4.2 私有数据缓存WeakMap最典型的工程场景就是给对象绑定私有数据同时又不影响该对象的生命周期。我用一个具体的例子来说明。假设你需要维护一个用户在线状态的模块每个用户是一个对象你要记录他的登录时间戳。如果用Map用户登出后即使把用户对象置为nullMap里的引用还会把对象强留着造成无法回收。用WeakMap登录记录和用户对象的生命周期绑定在一起用户对象被清除登录记录也会随之消失const loginTimeMap new WeakMap(); function login(user) { loginTimeMap.set(user, Date.now()); } function logout(user) { loginTimeMap.delete(user); } function getLoginTime(user) { return loginTimeMap.get(user); }代码层面没有区别但内存上WeakMap不会成为内存泄漏的源头。类似的场景还有DOM元素和组件状态信息的绑定——组件卸载后DOM节点消失相关的状态信息也会被回收不会因为Map里的残留引用来造成泄漏。另外很多优秀的库也把WeakMap用于实现私有字段数据存储。公共API暴露出来的对象身上不希望调用者能改到内部的敏感字段就把它存在WeakMap里以对象实例为key外部自然无法访问。这也算一种替代Class私有属性的思路。5. 高频业务场景组合玩法5.1 数据统计与日志聚合在实际项目中Map和Set常常组合在一起用能解决不少复杂业务问题。我说一个自己处理过的日志分析的case。有一次需要统计某个接口在一天内被不同用户调用的次数以及究竟是哪些用户调的。当时原始日志是一行行的记录每条包含userId和time。我的第一版实现用了两个普通对象分别存计数和用户去重列表结果因为整体逻辑里加了太多变量读起来维护的人都头疼。后来统一改成MapSet组合代码立刻变得清爽const stats new Map(); // key: userId, value: { count, times: Set } for (const log of logs) { if (!stats.has(log.userId)) { stats.set(log.userId, { count: 0, times: new Set() }); } const record stats.get(log.userId); record.count; record.times.add(log.time); }最后只需要遍历stats就能得到每个用户的调用次数和去重后的调用时间点集合。这个组合的核心思路在于外层用Map做用户维度的映射内层用Set做时间点的去重集合两层各司其职组成了清晰的存储结构。类似的结构在很多看板、监控类的业务里都适用例如统计商品维度的订单量Map同时去重统计有哪些用户买过Set。5.2 两级缓存模型另一个我自己在项目里用过多次的模型是Map对象存储的两级缓存。当数据量较大、每次获取需要做一次较重的计算时可以使用Map做内存级缓存同时用一个普通对象做元数据描述。Map保证了按key查找的高效性和遍历的顺序性而普通对象存一些描述信息比如创建时间、过期时间、访问计数等代码的组织比大杂烩清晰不少。const cache new Map(); function getProductInfo(productId) { if (cache.has(productId)) { return cache.get(productId); } const info fetchProductFromRemote(productId); // 模拟远程请求 cache.set(productId, info); return info; }当我们需要给这个缓存加基建比如记录每个产品信息的过期时间直观做法是把过期时间存在一个平行的Map里同一个productId同时出现在两个Map中。这种做法功能上没有大问题但要保证两个Map同步增删责任分明稍不留神就会漏一处。我在实际项目里更喜欢用Map 对象的方式来包装把映射关系和元信息收敛在一个结构里虽然这只是编码习惯上的取舍但对代码维护来说体验明显更好。你可以在Map的值里直接放一个对象把业务数据和元信息都装进去整体易读性上升一个台阶。6. 常见问题排查与避坑经验6.1 对象和Map混用导致的Bug我见过最频繁的坑是把改造成Map之后的部分代码遗留成了对象不同模块之间各用各的最后在某个公共函数里一种数据结构被两种方式同时操作直接乱套。建议的做法是在一个模块内只用一种集合类型公共工具函数优先声明清楚入参出参的集合类型。哪怕只是内部小项目也需要统一规范。特别是在Map和对象转换时要注意Object.fromEntries(map)从Map生成的对象键会被强制转成字符串如果原来是数字键这一步会丢失数字的类型信息后续如果又拿它去Map.get(1)会查不到因为此刻存在的是字符串1。6.2 Set去重时对象引用陷阱我在前面的去重小节提到过Set对对象的去重是基于引用相等。再次强调因为这是容易在业务里直接导致Bug的点。举个例子const objArr [{ id: 1 }, { id: 1 }, { id: 2 }]; const uniqueArr [...new Set(objArr)]; // length 仍是 3如果你期望的结果是两个id去重用Set直接去重显然达不到目的。这时候需要用Map按id聚合再取出值列表const map new Map(objArr.map(obj [obj.id, obj])); const uniqueArr [...map.values()]; // [{ id: 1 }, { id: 2 }]这里用map的构造参数一次完成映射key是idvalue是该对象。由于后续相同id的对象会覆盖前面的天然实现了按id去重最后用values()取回对象列表。这个写法简洁且不容易踩坑建议直接收藏。注意values()返回的是迭代器要拿到数组必须配合展开运算符或Array.from。很多人会忘记这一步直接返回迭代器被JSON序列化结果得到一个空对象或者报错这种问题排查起来非常费时间。6.3 Map与JSON序列化的坑Map默认不能被JSON.stringify正确序列化。直接对整个Map执行这个操作会得到{}。原因是JSON的序列化器不认识Map内部的数据结构只把它当作一个空对象处理。这在把Map数据发送到后端或者存localStorage时会造成bug静默丢失数据很难被发现。解决方案不麻烦先转成数组再序列化JSON.stringify([...map]); // 序列化成二维数组后端收到之后再用new Map(data)还原。这比转成普通对象更安全因为普通对象会丢失键的类型信息。同样的道理从服务端拿到的JSON也不能直接new Map(json)要先走Object.entries(json)变成二维数组。否则构造器拿到的不是可迭代的键值对列表行为会跟你预期完全不一样。6.4 扩展小技巧Map和Set的迭代器善用最后分享一个我自己平时用得比较多的技巧。因为Map和Set都实现了Symbol.iterator所以它们能直接用于for...of也能被展开成数组还能和解构赋值配合。比如在需要批量删除Set里某些元素时完全可以先过滤再重建const set new Set([1, 2, 3, 4, 5]); const filtered new Set([...set].filter(n n % 2 0)); // {2, 4}Map的批量更新也讲究一次性构造const updatedMap new Map([...map].map(([key, value]) { return [key, value * 2]; }));不过真正直接用的时候我不建议图省事而频繁倒腾数组和集合结构。这个转换有一定开销改动本身也很直观。在性能无所谓的场景里清晰好看完全没问题如果数据量大到需要敏感对待性能还是尽量用原生迭代器方法做增量更新避免反复内存拷贝。7. 写在最后的实践建议从Map和Set这两个结构本身来看它们不是多么高端的东西。但在工程上选对数据结构往往比写对算法还要重要——因为数据结构决定了后续代码的写法、可读性、性能边界和内存表现。我自己在带项目或者Code Review的时候通常会特别留意同事对集合类型的使用发现该用Set的地方用数组反复includes或者用对象当Map用还去拼接字符串键都会顺手改掉。个人体会比较深的一条是不要为了用Map而用Map也不要为了显得高级而去替代已经能正常工作且清晰易懂的对象或数组写法。数据结构的选择只有一个标准——合适。键的动态性、类型的任意性要求下Map合适唯一性、快速判断存在性要求下Set合适结构固定的共享对象普通对象完全合适。这篇文章里所有的例子都是我在真实项目里用过或者帮同事改过的问题你可以直接拿来当参考。如果有不同的经验或者踩到我没写过的坑欢迎交流。互相补充比一个人闷头踩坑高效得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询