JavaScript 开发里有一个特别有意思的现象:很多人写了好几年代码,数组和对象用得飞起,但一碰到Set和Map就开始绕道走。要么觉得“用数组不也能去重吗”,要么觉得“对象不也能当字典用吗”。说实话,我最开始也是这么想的,直到有一次在项目里处理一个几千条数据的去重和映射逻辑,数组includes跑起来那叫一个慢,才真正意识到Set和Map不是锦上添花,而是实实在在能救命的东西。
这篇文章就把Set集合和Map集合一次讲透。不绕弯子,直接从使用场景、核心 API、踩坑经验、性能对比几个角度往下走,最后再补一份日常开发里最常遇到的问题速查表。不管你是刚入门 JavaScript 的新人,还是写了两三年业务代码想系统梳理一遍的开发者,这篇文章都值得你花十分钟认真读完。
1. 为什么数组和对象搞不定时,要引入 Set 和 Map
1.1 从数组去重说起:Set 到底解决了什么问题
我们先用一个最经典的场景切入:数组去重。
const arr = [1, 2, 3, 2, 1, 4, 5, 4];传统写法是什么?filter加indexOf:
const unique = arr.filter((item, index) => arr.indexOf(item) === index);这段代码能跑,但有个非常隐蔽的性能问题:indexOf每次都要从头遍历整个数组。假设数组长度是 n,filter本身是 O(n) 的遍历,内部又套了一层 O(n) 的indexOf,总体时间复杂度直接变成 O(n²)。数据量小的时候无感,上万条数据就开始明显卡顿。
用Set就完全是另一个量级:
const unique = [...new Set(arr)];Set在插入元素时会自动检查值是否已存在,这个检查过程不是线性遍历,而是基于类似哈希表的结构,平均时间复杂度接近 O(1)。所以整个去重过程几乎是 O(n) 的,差距就是这么拉开的。
这只是个引子。Set真正的能力边界远不止去重,后面会详细拆。
1.2 对象作为键的需求,Map 的独特价值
再说Map。很多人觉得“对象不就是键值对集合吗?为什么还要一个 Map?”
这里有个关键差异:普通对象的键只能是字符串或者 Symbol。如果你把一个对象当作键塞进去,它会被强制转成字符串"[object Object]",所有对象都变成同一个键,数据直接互相覆盖。
const obj = {}; const key1 = { id: 1 }; const key2 = { id: 2 }; obj[key1] = 'first'; obj[key2] = 'second'; console.log(obj); // { '[object Object]': 'second' }这就是典型的“对象当字典”翻车现场。而Map的键可以是任意类型,包括对象、函数、NaN,甚至另一个 Map。这种能力让Map成了真正的通用字典结构,而不是对象的替代品。
1.3 直接对比:Set/Map 与 Array/Object 的心智模型差异
我把它们放在一起做个对照表,方便你一眼看清差异:
| 维度 | Array | Object | Set | Map |
|---|---|---|---|---|
| 有序性 | 有序 | 不一定有序 | 按插入序 | 按插入序 |
| 键的类型 | 仅索引 | 字符串/Symbol | 任意值 | 任意值 |
| 唯一性 | 不保证 | 键唯一 | 值唯一 | 键唯一 |
| 常用操作效率 | 查找慢 O(n) | 属性查找快 | 查找快 O(1) | 查找快 O(1) |
| 适用场景 | 有序列表 | 固定结构数据 | 集合运算/去重 | 动态键值映射 |
这不仅仅是数据结构层面的差异,更是心智模型的差异:数组强调的是“顺序和位置”,对象强调的是“结构和属性”,Set强调的是“成员的唯一性”,Map强调的是“键与值的映射关系”。想明白这一层,你写代码时选型就会清晰很多。
2. Set 集合的完整上手与踩坑实录
2.1 基础操作:增删查改与遍历
Set的基础 API 不多,但每一个都有讲究。先看最常用的一组:
const set = new Set(); // 添加元素,重复添加会被忽略 set.add(1); set.add(2); set.add(2); // 不会重复 set.add('hello'); // 判断是否存在 console.log(set.has(1)); // true console.log(set.has(3)); // false // 删除元素 set.delete(2); console.log(set.size); // 2 // 清空 set.clear(); console.log(set.size); // 0几个需要特别注意的细节:
第一,set.size是属性,不是方法。很多人刚接触时写set.size()会直接报错。
第二,add方法返回的是 Set 对象本身,所以可以链式调用:
set.add(1).add(2).add(3);第三,has和delete的时间复杂度都是 O(1),这是Set和数组最本质的区别。数组的indexOf是 O(n),Set的has是 O(1),数据量越大差距越明显。
遍历有两种主流方式。forEach方法回调里,前两个参数都是当前元素,这是为了保持和Map的forEach参数结构一致,设计上有点特殊:
const set = new Set(['a', 'b', 'c']); set.forEach((value, key, currentSet) => { console.log(value, key); }); // a a // b b // c cfor...of就更直观一些:
for (const item of set) { console.log(item); }2.2 最常用的场景:去重、取交集并集差集
去重是Set最出圈的应用,但实际开发里,集合运算的需求同样高频。尤其是做权限判断、标签筛选这一类逻辑时,交集和差集几乎天天见。
我直接给出一套完整的实现:
const a = new Set([1, 2, 3, 4]); const b = new Set([3, 4, 5, 6]); // 并集 const union = new Set([...a, ...b]); console.log([...union]); // [1, 2, 3, 4, 5, 6] // 交集 const intersection = new Set([...a].filter(x => b.has(x))); console.log([...intersection]); // [3, 4] // 差集(a 中有,b 中没有) const difference = new Set([...a].filter(x => !b.has(x))); console.log([...difference]); // [1, 2] // 对称差集(只在一个集合中出现的元素) const symmetricDifference = new Set([ ...[...a].filter(x => !b.has(x)), ...[...b].filter(x => !a.has(x)) ]); console.log([...symmetricDifference]); // [1, 2, 5, 6]这些操作的时间复杂度大约是 O(n + m),因为你只用了一次线性遍历加 O(1) 的has判断。相比嵌套循环的 O(n*m),性能提升是肉眼可见的。
我去年在做标签筛选功能时,就有个场景需要从一个用户列表中找出同时拥有“会员”和“已激活”标签的用户。当时用户量大概三万,用两个Set做交集,秒出结果。如果当初用数组的includes嵌套遍历,页面估计要卡好几秒。
2.3 Set 的隐藏特性:引用类型去重与 NaN 处理
Set的去重逻辑遵循的是SameValueZero比较规则。这个规则有几个反直觉的点:
第一,NaN在Set中是等于自身的。这跟===的行为不同,NaN === NaN是false,但Set会把 NaN 视为同一个值:
const set = new Set(); set.add(NaN); set.add(NaN); console.log(set.size); // 1第二,引用类型的去重只针对引用本身,不针对内容。两个内容一模一样的对象,在Set里是两个不同的元素:
const set = new Set(); set.add({ name: '张三' }); set.add({ name: '张三' }); console.log(set.size); // 2这一点在业务里特别容易踩坑。如果你要对对象数组做去重,单纯new Set(arr)是没用的,必须先把对象转成可比较的字符串,或者手动写去重逻辑。
第三,+0和-0在Set中被视为同一个值。这个基本上无感,但如果你做底层计算或者物理计算相关的开发,可能会遇到。
3. Map 集合的完整上手与踩坑实录
3.1 基础操作:get/set/delete/has 的正确打开方式
Map的基础 API 跟Set很像,但多了键值对的概念。初始化时可以传入一个二维数组,每个子数组的第一项是键,第二项是值:
const map = new Map([ ['name', '张三'], ['age', 25], ['job', '前端工程师'] ]); // 增/改:key 已存在时 set 就是更新,不存在时是新增 map.set('city', '上海'); map.set('age', 26); // 查 console.log(map.get('name')); // 张三 console.log(map.get('city')); // 上海 console.log(map.has('age')); // true // 删 map.delete('city'); console.log(map.has('city')); // false // 获取键的数量 console.log(map.size); // 3这里要特别提醒新手:map.get(key)如果键不存在,返回的是undefined,不是null,也不是抛异常。所以判断一个键是否存在,优先用has而不是get,否则可能把“键存在但值为 undefined”和“键不存在”搞混。
3.2 键值顺序:Map 键可以被排序?遍历顺序问题
Map最大的特点之一就是会记录插入顺序。这一点跟普通对象有本质区别。普通对象经过多次增删后,整数键往往被自动排序,字符串键的顺序也有一定的排列逻辑;而Map严格按照插入顺序存储。
这意味着什么?如果你需要按特定顺序遍历键值对,Map是天然支持的,而且完全不需要额外的排序逻辑。
const map = new Map(); map.set('z', 1); map.set('a', 2); map.set('m', 3); for (const [key, value] of map) { console.log(key, value); } // z 1 // a 2 // m 3Map的遍历有四种方式:
// 1. for...of 解构遍历 for (const [key, value] of map) { console.log(key, value); } // 2. forEach map.forEach((value, key) => { console.log(key, value); }); // 3. 只遍历键 for (const key of map.keys()) { console.log(key); } // 4. 只遍历值 for (const value of map.values()) { console.log(value); }还有一个特别实用的方法map.entries(),返回键值对的可迭代对象。默认的for...of遍历其实就是走entries()。
如果你在工作里遇到过“遍历对象顺序不稳定”的问题,比如在某些框架或引擎版本里整数键被自动排序导致渲染顺序错乱,换成Map基本就一劳永逸了。
3.3 对象转 Map / Map 转对象的常见姿势
实际业务开发中,对象和Map之间的转换非常频繁。有些时候是后端返回的数据结构是普通对象,但你需要用Map的特性做处理;有些时候是你在内部用了Map,最终要转成普通对象输出。
对象转Map用Object.entries:
const obj = { name: '李四', age: 30, city: '北京' }; const map = new Map(Object.entries(obj)); console.log(map.get('name')); // 李四Map转对象需要用Object.fromEntries:
const map = new Map([ ['name', '王五'], ['age', 28] ]); const obj = Object.fromEntries(map); console.log(obj); // { name: '王五', age: 28 }这两个方法都是 ES2019 之后引入的,现代浏览器和 Node.js 环境基本都支持,可以放心用。
最后提醒一句:Object.fromEntries只会转换字符串键。如果 Map 的键是对象或其他非字符串类型,转成对象时这些键会被自动转成字符串"[object Object]",互相覆盖。这种场景下不要用对象结构,老老实实保持Map形态传递。
4. 内部机制和性能层面的思考
4.1 为什么 Set 的 has 通常比数组的 includes 快
这个问题几乎是面试必问,也是理解Set价值的关键。
数组的includes和indexOf实现的是线性查找:从头到尾逐个比较每个元素,直到找到目标。最坏情况下要遍历整个数组,时间复杂度 O(n)。
Set内部采用类似哈希表的实现,通过哈希算法直接定位元素所在位置,平均查找时间复杂度 O(1)。这个过程就跟查字典类似:你知道拼音首字母,直接翻到对应那一页,而不是从第一页逐页翻。
让我用一组简单的数据直观展示差距。在 Node.js 环境下执行以下测试:
const size = 100000; const arr = Array.from({ length: size }, (_, i) => i); const set = new Set(arr); console.time('array includes end'); arr.includes(size - 1); console.timeEnd('array includes end'); // 大约 0.7 ~ 1.5ms console.time('set has last'); set.has(size - 1); console.timeEnd('set has last'); // 大约 0.003 ~ 0.01ms这只是十万条数据,includes和has已经差了上百倍。当数据量到百万、千万级别,这个差距会被无限放大。
所以日常开发中,判断“某个值是否存在于一个大数据集合中”时,优先转化成Set再判断,这是一个性价比极高的性能优化手段。
4.2 什么场景该用 Map 而不是普通对象
我总结了一套自己的选型标准,分享出来供你参考:
| 场景 | 推荐结构 | 原因 |
|---|---|---|
| 固定结构的表单数据 | 对象 | 语义清晰,可直接读属性 |
| 需要 Object.keys/values 做遍历的简单字典 | 对象 | 代码更简洁 |
| 键是动态变化的字符串 | Map | 避免原型链污染问题 |
| 键是对象、函数、NaN 等非字符串类型 | Map | 对象无法正确支持 |
| 需要按插入顺序遍历 | Map | 对象整数键会被重排 |
| 数据量大且需要频繁增删查 | Map | 有独立 API,性能更稳 |
| 需要序列化成 JSON 传给后端 | 对象 | JSON.stringify 不支持 Map |
有一个非常经典的安全问题值得一提:普通对象有自己的原型链,如果你把用户输入当作键写进对象,比如obj['__proto__'] = 'hack',会污染整个对象的原型链,造成严重的安全隐患。
用Map就不会有这个顾虑,因为它不像对象一样继承原型属性,键完全是独立存储的。
4.3 与 WeakSet/WeakMap 的取舍(简要)
WeakSet和WeakMap是Set/Map的“弱引用”版本。它们的键不会阻止垃圾回收机制回收那个对象。
什么意思?举个例子:普通Map保存了一个对象引用,即使业务代码里所有指向这个对象的变量都消失了,Map内部的引用仍然存在,这个对象就永远不会被垃圾回收,造成内存泄漏。
而WeakMap不会阻止垃圾回收。当对象除了WeakMap的键之外不再被任何地方引用时,它就会被自动回收,对应的键值对也会被移除。
这个特性让WeakMap特别适合做缓存、私有属性存储这类场景:
const cache = new WeakMap(); function processData(obj) { if (cache.has(obj)) { return cache.get(obj); } const result = heavyTask(obj); cache.set(obj, result); return result; }这种缓存设计里,当obj在业务中被销毁后,cache中对应的条目会自动消失,不会一直占着内存。
需要留意的是,WeakMap和WeakSet不支持遍历,也没有size属性,因为键随时可能被垃圾回收,数量不稳定。所以它们不适合做主数据存储,只适合做辅助性的元数据记录。
5. 日常排查与典型问题速查
5.1 看到 "Set is not iterable" 这类报错怎么办
Set和Map都是可迭代对象,for...of、展开运算符都可以直接用。但有几种情况会触发 “not iterable” 错误:
第一种,forEach回调里用了不兼容的语法。比如:
const set = new Set([1, 2, 3]); set.forEach(item => console.log([...item])); // TypeError: item is not iterable这不是Set的问题,是你把非可迭代的item当作可迭代对象使用了。排查思路是先确认报错变量到底是什么类型。
第二种,在转换时误操作。比如把Set当成普通数组来map:
const set = new Set([1, 2, 3]); set.map(item => item * 2); // TypeError: set.map is not a functionSet没有map方法。正确做法是先转数组再操作:
const doubled = [...set].map(item => item * 2);遇到这类问题,基本思路就是:先确认变量真实类型是Set/Map还是数组;如果要在Set/Map上使用数组方法,先展开成数组。
5.2 Map 的 key 比较规则:为什么两个对象不相等
前面提到过Map的键遵循SameValueZero规则。这个规则里,基本类型的直接比较值,引用类型的比较引用地址。
很多人在实际开发里遇到过这种困惑:
const map = new Map(); map.set({ id: 1 }, '对象A'); console.log(map.get({ id: 1 })); // undefined第二个{ id: 1 }和第一个{ id: 1 }是两个完全不同的对象,内存地址不一样,所以get拿不到值。这不是Map的 bug,而是引用类型比较机制决定的。
解决办法有两个方向。如果你的业务允许,就把对象序列化成字符串作为键:
const map = new Map(); const obj = { id: 1 }; map.set(JSON.stringify(obj), '对象A'); console.log(map.get(JSON.stringify({ id: 1 }))); // 对象A或者直接保存同一个对象引用:
const key = { id: 1 }; map.set(key, '对象A'); console.log(map.get(key)); // 对象A这个坑特别容易出现在“用接口返回值做键”的场景里,因为前端每次拿到的对象都是新创建的,即使内容相同,引用也不同。遇到这种情况,建议优先用id之类的唯一字段做键,而不是整个对象。
5.3 有趣却容易踩的细节:JSON.stringify 无法直接序列化 Map/Set
这是很多人第一次在生产环境翻车的地方。写了半天Map,最后要提交给后端,直接JSON.stringify(map),得到的结果是{},一脸懵。
const map = new Map([['name', '张三']]); console.log(JSON.stringify(map)); // {}Set更离谱,连{}都没有,直接变成{}也是空对象。
JSON.stringify根本不认识Set和Map,序列化时会忽略它们的内部数据。正确做法是先转成数组或对象:
const map = new Map([ ['name', '张三'], ['age', 25] ]); // 转成对象 const obj = Object.fromEntries(map); console.log(JSON.stringify(obj)); // {"name":"张三","age":25} const set = new Set([1, 2, 3]); console.log(JSON.stringify([...set])); // [1,2,3]反向操作也是一样:后端返回的 JSON 数组,你要用Set或Map处理时,先初始化再操作。new Map(Object.entries(后端返回的对象))是一套很顺手的组合。
5.4 手写一个 Set 去重后如何保持顺序
有些场景下,你可能需要基于数组去重,但又不能随便调整原本的顺序。好消息是:Set天然保持插入顺序,所以[...new Set(arr)]的去重结果会自动保持原数组的相对顺序。
const arr = ['b', 'a', 'c', 'b', 'a', 'd']; const unique = [...new Set(arr)]; console.log(unique); // ['b', 'a', 'c', 'd']你不需要额外写排序逻辑。这一点在保持用户选择的先后顺序、处理标签顺序时特别实用。
如果你要在去重的同时做自定义排序,先转数组再sort即可,步骤清晰,不容易出错。
5.5 内存泄漏隐患:Map 替代 Object 后多了什么注意点
普通对象存储数据时,键和值都会随着对象的销毁而释放。但Map对键的引用是强引用,即便业务代码中的键已经在逻辑上“消失”,只要Map实例还存在,这些键就不会被垃圾回收。
典型场景是:一个全局的Map缓存对象,持续往里面set数据,却从不清理。运行时间长了,内存占用越来越高,最后出现卡顿甚至崩溃。
解决方案有两个:
一是有意识地清理不再使用的键:
map.delete(expiredKey);二是使用WeakMap替代Map,让垃圾回收机制自动处理:
const cache = new WeakMap();WeakMap的键如果不再被外部引用,内部条目会被自动清除,避免内存泄漏。从设计定位来看,Map适合明确的短期缓存或需要遍历的数据,WeakMap适合生命周期不可控的长效缓存。
6. 从项目实战来看:什么时候我会毫不犹豫用 Set/Map
光讲 API 和概念还不够,结合我自己的项目经验来说说什么时候我会果断做这个选型。
我第一次被Set打动是在一个数据仪表盘项目里。当时要计算三个不同接口返回的 ID 列表是否有交集,列表长度都是几千起步。第一版代码用的数组filter加includes,在本地测试时数据少没感觉,上线后客户反馈页面加载后要等好几秒才能看到图表刷新。排查后发现问题出在嵌套遍历上:三层接口数据,两两比对,复杂度直接 O(n³)。改成Set之后,两两之间用has判断,一个循环搞定,页面响应时间从几秒降到了毫秒级。
后来做权限管理系统,要给不同角色配置不同的菜单和操作权限。我直接用Map以角色 ID 为键,值为一个对象,包含菜单列表和按钮权限列表。增删改查都走Map的 API,代码结构清晰了很多,而且因为键是数字 ID,避免了对象字符串键带来的所有潜在坑。
还有一个让我印象深刻的场景:用户上传文件,需要过滤掉重复上传的文件。文件信息是对象类型,包含文件名、大小、修改时间,直接用Set去重行不通。我当时的方案是把每个文件的这些字段拼接成字符串,再塞进Set:
const seen = new Set(); const uniqueFiles = files.filter(file => { const key = `${file.name}-${file.size}-${file.lastModified}`; if (seen.has(key)) return false; seen.add(key); return true; });这个方案轻巧高效,完全避免了一次 O(n²) 的遍历。
回到最初的问题:Set和Map到底是不是必需的?我的答案是,现代 JavaScript 开发中,它们是数据处理的左膀右臂,是不可或缺的基础设施。数组和对象能解决一部分问题,但当你面对大数据量、动态键、复杂成员关系时,Set和Map提供的语义和性能优势会成倍提升你的代码质量和开发效率。
做 JavaScript 开发这些年,我越来越觉得,真正拉开代码水平差距的从来不是花哨的语法,而是对基础数据结构理解得够不够深。把Set和Map用熟,等于多了一副趁手兵器,以后写业务代码会顺手很多。如果这篇文章对你有帮助,建议你打开编辑器,把文中的代码都亲手跑一遍,遇到报错再回来看每一节的避坑点,消化效率会高很多。