
JavaScript 开发里有一个特别有意思的现象很多人写了好几年代码数组和对象用得飞起但一碰到Set和Map就开始绕道走。要么觉得“用数组不也能去重吗”要么觉得“对象不也能当字典用吗”。说实话我最开始也是这么想的直到有一次在项目里处理一个几千条数据的去重和映射逻辑数组includes跑起来那叫一个慢才真正意识到Set和Map不是锦上添花而是实实在在能救命的东西。这篇文章就把Set集合和Map集合一次讲透。不绕弯子直接从使用场景、核心 API、踩坑经验、性能对比几个角度往下走最后再补一份日常开发里最常遇到的问题速查表。不管你是刚入门 JavaScript 的新人还是写了两三年业务代码想系统梳理一遍的开发者这篇文章都值得你花十分钟认真读完。1. 为什么数组和对象搞不定时要引入 Set 和 Map1.1 从数组去重说起Set 到底解决了什么问题我们先用一个最经典的场景切入数组去重。const arr [1, 2, 3, 2, 1, 4, 5, 4];传统写法是什么filter加indexOfconst unique arr.filter((item, index) arr.indexOf(item) index);这段代码能跑但有个非常隐蔽的性能问题indexOf每次都要从头遍历整个数组。假设数组长度是 nfilter本身是 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 的心智模型差异我把它们放在一起做个对照表方便你一眼看清差异维度ArrayObjectSetMap有序性有序不一定有序按插入序按插入序键的类型仅索引字符串/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.entriesconst obj { name: 李四, age: 30, city: 北京 }; const map new Map(Object.entries(obj)); console.log(map.get(name)); // 李四Map转对象需要用Object.fromEntriesconst 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当成普通数组来mapconst 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去重行不通。我当时的方案是把每个文件的这些字段拼接成字符串再塞进Setconst 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用熟等于多了一副趁手兵器以后写业务代码会顺手很多。如果这篇文章对你有帮助建议你打开编辑器把文中的代码都亲手跑一遍遇到报错再回来看每一节的避坑点消化效率会高很多。