深入理解JavaScript Set与Map:原理、实战与性能优化

发布时间:2026/10/14 7:01:42
深入理解JavaScript Set与Map:原理、实战与性能优化 ## 1. 为什么必须搞懂Set和Map前端数据处理的两根硬骨头 先从一个最普通的场景说起。你接手了一个后台管理系统的订单列表接口返回的数据里有重复的订单号需要去重后再渲染。老手可能会直接写 Array.from(new Set(orderList))一键搞定。但如果你只停留在“会用”的层面不去想 Set 和 Map 背后到底干了什么、有什么边界条件、为什么某些场景性能更好那遇到复杂数据时照样会踩坑。 Set 和 Map 是 ES6 引入的两种集合型数据结构核心价值就一句话**Set 管“有没有”Map 管“是什么和什么对应”**。它们不是数组和对象的替代品而是互补品。Set 解决“唯一性判断与集合运算”的问题Map 解决“键值映射与有序遍历”的问题。这篇文章我会把这俩货的底层原理、API 细节、工程应用、踩坑点一次性说透偏向实际能直接落地的层面适合做前端一到三年、想把数据操作基本功打牢的人。 ## 2. 核心API拆解别光看文档要看操作语义 ### 2.1 Set 的增删查改表面简单细节不少 Set 的 API 很少add、delete、has、clear再加一个 size 属性。看起来人畜无害但有几个点值得抠一下。 第一add 的返回值是 Set 实例本身这意味着可以链式调用。比如 javascript const set new Set(); set.add(1).add(2).add(3);这个设计很实用但如果没有返回值意识写set.add(item); set.add(item2);就显得啰嗦。第二delete和has使用的是SameValueZero比较规则不是严格等于的完全一致。这导致一个反直觉的结果NaN和NaN在 Set 里被认为相等0和-0也被认为相等。所以const s new Set(); s.add(NaN); s.has(NaN); // true而不是 false s.add(-0); s.size; // 1因为 -0 和 0 视作同一个值这一点在写去重逻辑时尤其重要。如果你的数据里有-0和0用 Set 去重后只剩一个。第三遍历顺序。Set 的遍历顺序就是插入顺序。这一点在“需要保持数据录入顺序”的场景特别有用。比如埋点事件去重时你希望保留第一次出现的顺序Set 可以直接满足不用额外索引。再看遍历 API。Set 原生支持forEach、keys()、values()、entries()。由于 Set 没有键值之分keys()和values()返回的内容完全一样而entries()返回的数组项是[value, value]。新手可能疑惑为什么这么设计实际是为了和 Map 的遍历接口对齐便于在两种集合间互相转换。2.2 Map 的增删改查键的灵活性是最大优势Map 的 API 是set、get、has、delete、clear搭配size。这里最核心的差异在于键可以是任意类型包括对象、函数、NaN。这一点直接碾压普通对象。举一个场景你要统计一个页面里所有按钮被点击的次数给每个按钮 DOM 元素挂一个计数变量很蠢用对象做索引只能把 DOM 转成字符串容易碰撞。用 Map 就干净了const clickCountMap new Map(); function trackClick(btnElement) { clickCountMap.set(btnElement, (clickCountMap.get(btnElement) || 0) 1); }DOM 元素本身作为键不需要额外生成 id也不需要担心原生属性命名冲突代码可读性也好很多。另一个容易被忽略的点Map 的遍历顺序同样是插入顺序。普通对象的属性顺序虽然在某些情况下也按插入排序整数型键除外但语义不明确而且删除属性后顺序表现不一致。Map 是完全按插入顺序保证这在处理配置项、状态列表时非常有价值。Map 的forEach回调参数是(value, key, map)注意顺序是值在前、键在后跟数组的(item, index)语义一致都是先值后索引键。这个参数顺序很容易写反我见过不少写map.forEach((key, value) ...)导致 bug 的情况。2.3 迭代器与展开语法Set 和 Map 的隐藏武器Set 和 Map 都是可迭代对象因此可以被...展开、被for...of遍历、被解构。这一特性极大提升了它们在函数式编程场景里的适用性。先看 Setconst s new Set([1, 2, 3]); const arr [...s]; // [1, 2, 3] const max Math.max(...s); // 3三个点解决“集合转数组”的高频需求。再配合 filter 或 map你甚至可以直接对 Set 做链表式处理先转数组再回 Set。再看 Mapconst m new Map([[a, 1], [b, 2]]); const entries [...m]; // [[a, 1], [b, 2]] const keys [...m.keys()]; // [a, b] const values [...m.values()]; // [1, 2]这种展开能力在做数据变形时非常顺手。而且 Map 的构造函数可以直接接受一个“键值对数组”或者另一个可迭代对象所以new Map(existingMap)就能完成一次性浅拷贝。3. 设计逻辑与底层运行机制为什么它比对象和数组快3.1 从哈希表理解 Set 和 Map 的查找速度经常有人问判断一个元素是否在集合里用数组的includes不就行了吗为什么非要has这就得深入到实现层面。Set 和 Map 基于哈希表实现引擎不同实现细节有差异但整体思路一致。哈希表的核心思路是通过一个哈希函数把键映射到表中的一个位置存取时先算哈希再访问平均时间复杂度是 O(1)。数组的includes是线性查找一个一个比对时间复杂度 O(n)。如果数组长度是 10000最差情况要比较 10000 次Set 的has通常一次定位就够。实际引擎可能用多种优化比如空间换时间、开放地址或链表法处理冲突但宏观结论不变。拿实际场景验证一下十几万条去重日志记录用数组includes去重执行可能要几十秒甚至卡死页面用 Set 去重基本毫秒级完成。这不是玄学是数据结构差异的必然结果。需要补充说明的是如果数据规模很小比如几个元素数组和 Set 的差距不明显不必为了“快”而强行引入 Set。数据量级在千百级以上Set 的价值才真正体现。3.2 内存组织方式差异Set 和 Map 的空间代价时间和空间是一对矛盾体。哈希表用“预分配空间 哈希槽”换速度自然比数组更耗内存。所以在超大数组场景下如果用 Set 去重内存峰值可能会明显上升。但这里有一个工程判断大多数前端场景的数据规模在万级以内多出来的内存消耗完全可以接受。一个值得注意的细节Set 存储引用类型时保存的是引用不是深拷贝。因此如果 Set 里的对象被外部修改has匹配到的是修改后的状态。这一点如果你没意识到做缓存类功能时会出现“改了原对象但缓存里还是旧值”的错觉——其实缓存里没有旧值只有同一个对象的引用。3.3 键比较规则详解严格相等、SameValueZero、对 NaN 和 -0 的区别刚才提到 SameValueZero这里展开讲。这个规则是比较两个值是否相等标准基本等同于但有两个例外NaN等于自身-0等于0。这个规则很重要因为如果按照NaN ! NaN则set.has(NaN)永远为 falseset.delete(NaN)永远无法命中这会让数据的“唯一性判断”失效。ES6 设计者专门引入 SameValueZero 来修复这个坑。但要注意它和Object.is也不完全一样。Object.is(NaN, NaN)是 trueObject.is(-0, 0)是 false。换句话说SameValueZero 比Object.is更宽容把-0和0合并了。如果你在某些场景需要区分-0和0不能用 Set 或 Map 直接存因为会合并掉差异。3.4 链式反应数组、Set、Map 互相转换的顺序保持很多新手会担心数组转 Set 再转回数组顺序会不会乱不会。Set 的遍历顺序严格按插入顺序所以从数组构建 Set 再转回去顺序完全一致。利用这个特性可以做到“去重但不能乱序”。Map 同样你按什么顺序set进去按什么顺序遍历出来。这在处理带顺序的配置文件时是致命利器。一个经典例子从后端拿到的权限列表用 Map 存权限标识与描述遍历时 UI 展示顺序永远稳定不会像普通对象一样因为键是数字字符串而自动重排。关于普通对象的顺序再多说一句对象属性键如果是“类整数”比如1、2会被自动按数值升序排列其他键按插入顺序排列。这个表现不直观是很多隐性 bug 的来源。Map 不存在这个问题任何键都严格按插入顺序。4. 高阶应用与经典实操把 Set 和 Map 用到工程细节里4.1 数组去重与集合运算并集、交集、差集的优雅写法数组去重是 Set 最常见的应用。基础用法是const arr [1, 2, 2, 3, 3, 4]; const unique [...new Set(arr)]; // [1, 2, 3, 4]但实际开发里去重经常针对对象数组。此时 Set 的去重逻辑默认按引用比较两个内容相同的对象并不相等。如果需要按某个字段去重需要先把字段提取出来做索引再还原数据。比如const data [{id: 1, name: a}, {id: 2, name: b}, {id: 1, name: a}]; const seen new Set(); const result data.filter(item { if (seen.has(item.id)) return false; seen.add(item.id); return true; });这段代码的执行意图是首次遇到某个 id 时把它加入 Set并保留这次数据之后遇到重复 id 就丢弃。这种模式比uniqBy类库的实现更直接也更容易定制。再来看集合运算。交集、并集、差集在权限管理、菜单过滤、标签筛选里出现频繁。利用 Set 的 has 方法可以写出非常精简的集合运算函数// 并集合并去重用 Set 天然支持 const union (a, b) [...new Set([...a, ...b])]; // 交集遍历较小的集合用 has 快速判断 const intersection (a, b) { const setB new Set(b); return [...new Set(a)].filter(item setB.has(item)); }; // 差集在 A 中但不在 B 中 const difference (a, b) { const setB new Set(b); return [...new Set(a)].filter(item !setB.has(item)); };注意交集和差集实现里先把b转成 Set。如果直接用b.includes(item)碰到大数据量会慢。把“待查询集合”转换成 Set是这类算法题和实际业务里的核心优化思路。4.2 复杂映射场景从对象数组构建 Map、自定义键类型场景是这样的后端返回一个用户列表你需要在页面上根据 userId 频繁查找用户名。每次用find遍历列表显得很低效此时可以把列表转为 Mapconst userList [{id: u1, name: 张三}, {id: u2, name: 李四}]; const userMap new Map(userList.map(user [user.id, user]));这里用了map方法把每个用户对象转为[id, user]键值对数组然后一次性交给 Map 构造函数。之后查找用户只需userMap.get(u1)O(1) 命中。Map 不仅能存基本类型键还能存数组、对象甚至函数。比如缓存路由配置时用路由正则作为键、处理函数作为值这是普通对象做不到的。4.3 遍历与函数式组合结合 filter、reduce 的高阶玩法Set 和 Map 虽然没有map、filter方法但可以转成数组后无缝使用。比如给 Set 中的每个元素做映射const set new Set([1, 2, 3]); const doubled new Set([...set].map(x x * 2));让每个用户的状态值叠加计数const nums [1, 2, 3, 4, 5]; const countMap nums.reduce((acc, num) { acc.set(num, (acc.get(num) || 0) 1); return acc; }, new Map());reduce 每一次都在同一个 Map 上更新避免了用普通对象时频繁重建临时对象的开销。当你要“动态统计一组值出现次数”时这个模式非常稳定。使用体验上我喜欢把 Set 和 Map 当作一种“数据结构中间层”先从数组/对象提取数据构建成 Set 或 Map 做逻辑判断再转回展示层需要的格式。这样可以规避很多边界判断代码的意图也更明确。4.4 状态管理与数据索引Map 作为 “更规范” 的对象容器普通的业务状态很多人习惯用对象const state {}; state[key] value;问题在于键会被隐式转为字符串对象键或数组键就变成长字符串容易混淆继承自原型链的属性可能污染逻辑。比如key toString时state[key]命中的不是你以为的数据。不存在“键数量”这样的直接属性。Map 完美解决上述问题。键保持原类型、不存在原型链干扰、size直接给数量。所以在状态管理、表单状态缓存、字典映射类业务里我推荐优先考虑 Map。5. 核心实战用 Set 和 Map 解决三个典型业务问题5.1 基于 Set 的权限与标签管理系统构建做 B 端系统时权限判断是绕不开的。如果一个按钮是否显示取决于当前用户是否拥有某个权限码直接可以用 Set 存储权限列表const userPermissions new Set(); // 例如 [order:view, order:edit] function hasPermission(code) { return userPermissions.has(code); }权限码在 Set 里天然唯一不会因为接口重复推送而膨胀。配合“权限码更新”的需求还可以这样操作// 全量替换 function syncUserPermissions(codeList) { userPermissions.clear(); codeList.forEach(code userPermissions.add(code)); } // 增量添加 function addPermission(code) { userPermissions.add(code); }这种实现比数组配合includes复杂度低一截更重要的是语义清晰Set 这个词四个字母本身就是“集合”做权限集合完全对口。5.2 基于 Map 的配置表与状态机映射状态机的典型写法是switch-case或者对象映射但状态很多时对象映射很难维护。用 Map 可以把“状态值 - 处理函数”组织成一个配置表配合get直接执行const stateHandlers new Map([ [idle, () console.log(空闲)], [loading, () console.log(加载中)], [success, () console.log(成功)], [error, () console.log(出错)], ]); function transition(state) { const handler stateHandlers.get(state); if (handler) { handler(); } else { console.warn(未知状态: ${state}); } }如果直接写成对象{ idle: () {} }遇到键名是toString这种内置属性时stateHandlers[toString]会命中原型链而不是你的配置造成莫名其妙的执行结果。Map 没有这个问题还额外获得一个“未知状态”的兜底判断机会。5.3 高并发接口数据缓冲用 Map 避免重复请求防重复请求是高频需求。有时同一个请求在短时间内被触发多次可以用 Map 缓存正在进行的 Promiseconst pendingMap new Map(); function fetchWithBuffer(url) { if (pendingMap.has(url)) { return pendingMap.get(url); } const promise fetch(url).finally(() { pendingMap.delete(url); }); pendingMap.set(url, promise); return promise; }这段代码的技术点是不以“是否缓存成功结果”为标准而以“有没有正在请求”为标准。pendingMap的键是url值是 Promise 本身。后续调用如果发现同一个url正在请求直接复用同一个 Promise。请求结束后无论成功失败都从 Map 里移除保证下次重新发起。这个模式比全局变量标记 回调函数的方案要简洁得多代码量少且不容易出现状态残留。5.4 从数组到 Map 的批量转换与反向索引前端做列表筛选时经常需要“根据 ID 快速找到对应项”。一次性转换并重复使用是最高效的做法// 原始数据 const orders [ { id: 1001, product: A, price: 20 }, { id: 1002, product: B, price: 30 }, ]; // 构建索引id - 订单 const orderIndex new Map(orders.map(order [order.id, order])); // 输出订单产品名 console.log(orderIndex.get(1001)?.product); // A还可以反过来构建“产品名 - 所有订单”的索引const ordersByProduct new Map(); orders.forEach(order { const list ordersByProduct.get(order.product) || []; list.push(order); ordersByProduct.set(order.product, list); });这个操作的关键在于先用get取已有数组如果没有则用空数组初始化。这个“取或建”模式在 Map 里需要手动写因为它不像 Python 的defaultdict。假如这是一个高频片段建议抽成工具函数function groupToMap(list, keyFn) { const map new Map(); list.forEach(item { const key keyFn(item); if (!map.has(key)) map.set(key, []); map.get(key).push(item); }); return map; }6. 常见问题与排查技巧我踩过的经典坑6.1 引用类型去重失败为什么 Set 无法去重“相同”的对象很多人想当然地认为const items [{id: 1}, {id: 1}]; const uniq [...new Set(items)]; // 期望只有一个对象但结果是两个对象。因为 Set 比较的是引用不是内容。两个对象即使结构完全一样只要存储地址不同has判断就不命中。前面已经给了按字段去重的写法这里强调一下如果在实际业务里需要给“仍含引用类型”的数据去重必须明确自己的业务唯一键是什么不能依赖语言层面的相等判断。6.2 遍历时直接删除元素导致漏删或者行为异常这个问题在数组里大家很熟forEach配合splice会导致遍历错位。Set 和 Map 也有类似问题但表现更微妙。const set new Set([1, 2, 3]); set.forEach(item { if (item 2) set.delete(2); // 合法不会崩 });事实上 Set 在遍历时删除“当前项”是被允许的但要小心删除“后续项”可能导致行为不符合预期。更稳妥的做法是先把要删除的元素收集到数组里遍历结束后再统一删除const toDelete []; set.forEach(item { if (item % 2 0) toDelete.push(item); }); toDelete.forEach(item set.delete(item));Map 也有类似问题。如果用 Map 的forEach同时删除键建议采用同样的收集删除模式。最简单、最安全的方式是用“遍历副本”的思路[...map].forEach(([key, value]) {...})这样不会影响原始迭代。6.3 JSON.stringify 的坑Set 和 Map 序列化后变成空对象const set new Set([1, 2, 3]); JSON.stringify(set); // {} const map new Map([[a, 1]]); JSON.stringify(map); // {}这两个结果足够让人懵。原因是JSON.stringify不认识 Set 和 Map只会序列化它们的可枚举属性而 Set/Map 内部数据并不以可枚举属性的方式暴露。如果业务需要把 Set/Map 传给后端或存 localStorage必须先转换const setToJson JSON.stringify([...set]); // [1,2,3] const mapToJson JSON.stringify([...map]); // [[a,1]]从localStorage读取后再重建const set new Set(JSON.parse(store.setData)); const map new Map(JSON.parse(store.mapData));注意Map 转 JSON 后的数据格式是二维数组不是对象重建时直接用new Map(二维数组)即可。6.4 Map 的 key 相等性数字 1 和字符串 1 是两个不同的键const map new Map(); map.set(1, number); map.set(1, string); map.size; // 2 map.get(1); // number map.get(1); // string这条规则和普通对象完全不同。普通对象会把键转成字符串所以obj[1]和obj[1]是一样的Map 不做转换保持原样。这既是优点可以区分类型也是陷阱如果你没注意类型以为存过1实际查的是1就会拿不到值。从接口拿到的数据字段一般是字符串如果之前用数字存过需要格外小心类型。6.5 WeakMap 与内存管理什么时候用“弱”版本WeakMap 的键必须是对象WeakSet 的元素必须是对象。它们对键的引用是弱引用不阻止垃圾回收。这意味着如果某个对象除了作为 WeakMap 的键没有任何其他地方引用它那么它可能被回收对应条目也会消失。这个特性非常适合做“对象级私有数据”和“临时缓存”。比如给 DOM 元素挂数据const elementData new WeakMap(); const el document.getElementById(app); elementData.set(el, { clickCount: 0, timer: null }); // el 被移除后elementData 里对应数据也会被 GC 回收不手动清理也不泄漏如果用 Map 存储同样的数据DOM 元素被移除后Map 还强引用着这个失效节点导致内存泄漏。不过 WeakMap 不可迭代也没有size所以它不适合“需要遍历所有键值”的场景。7. 工具选型、数据对比与实战建议实际工作中面对一组数据时到底用数组、对象、Set 还是 Map可以从这张表确定方向场景推荐数据结构理由列表展示、可重复数据数组天然有序API 丰富去重、判断存在性、集合运算Set唯一性保证O(1) 查找字符串键的简单映射对象语法简洁字面量友好任意类型键、需要有序遍历、频繁增删Map键类型灵活遍历顺序稳定无原型链污染对象关联私有数据、避免内存泄漏WeakMap键为对象弱引用不阻塞回收对象集合唯一性判断WeakSet同上用于对象标记场景这个表不是死规则核心是理解每种结构的优势。一般我的判断顺序是先看要不要保持插入顺序再看键的类型再看是否需要弱引用最后看数据量级。工程实践里还有个经验不要滥用 Set 和 Map。如果数据只是简单展示数组天然直观如果是简单的固定枚举对象字面量更节省代码量。Set 和 Map 用在对的方向才是效率利器用在不必要的场景只会徒增理解成本。8. 后续值得继续深挖的方向与个人体会到这里Set 和 Map 的语法、原理、实战和坑基本都过了一遍。根据我个人经验这俩数据结构的价值不在文档表面而在于你愿不愿意在“数据建模”的时候多想一步这个数据集合的本质是“判断唯一”还是“映射关系”。想明白了选型自然就清晰。最后再分享一个小技巧写工具方法的时候尽量给函数设计成“接收 Set/Map 并返回 Set/Map”保持数据结构的一致性这样调用方可以放心链式组合不需要每步都做“数组转集合”的往返转换。面对复杂的数据清洗流程这样能把代码可读性提升一个档次。如果在实际项目里遇到“数据去重性能差”“状态映射混乱”或者“对象键带来的诡异 bug”先别急着在业务代码里堆逻辑回头审查一下是不是数据结构用错了。多数情况下换一个 Set 或 Map问题就迎刃而解。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询