news 2026/9/14 17:33:08

JavaScript Set和Map集合详解:从底层原理到实战性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaScript Set和Map集合详解:从底层原理到实战性能优化

JavaScript 开发里有一个特别有意思的现象:很多人写了好几年代码,数组和对象用得飞起,但一碰到SetMap就开始绕道走。要么觉得“用数组不也能去重吗”,要么觉得“对象不也能当字典用吗”。说实话,我最开始也是这么想的,直到有一次在项目里处理一个几千条数据的去重和映射逻辑,数组includes跑起来那叫一个慢,才真正意识到SetMap不是锦上添花,而是实实在在能救命的东西。

这篇文章就把Set集合和Map集合一次讲透。不绕弯子,直接从使用场景、核心 API、踩坑经验、性能对比几个角度往下走,最后再补一份日常开发里最常遇到的问题速查表。不管你是刚入门 JavaScript 的新人,还是写了两三年业务代码想系统梳理一遍的开发者,这篇文章都值得你花十分钟认真读完。

1. 为什么数组和对象搞不定时,要引入 Set 和 Map

1.1 从数组去重说起:Set 到底解决了什么问题

我们先用一个最经典的场景切入:数组去重。

const arr = [1, 2, 3, 2, 1, 4, 5, 4];

传统写法是什么?filterindexOf

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 的心智模型差异

我把它们放在一起做个对照表,方便你一眼看清差异:

维度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);

第三,hasdelete的时间复杂度都是 O(1),这是Set和数组最本质的区别。数组的indexOf是 O(n),Sethas是 O(1),数据量越大差距越明显。

遍历有两种主流方式。forEach方法回调里,前两个参数都是当前元素,这是为了保持和MapforEach参数结构一致,设计上有点特殊:

const set = new Set(['a', 'b', 'c']); set.forEach((value, key, currentSet) => { console.log(value, key); }); // a a // b b // c c

for...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比较规则。这个规则有几个反直觉的点:

第一,NaNSet中是等于自身的。这跟===的行为不同,NaN === NaNfalse,但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-0Set中被视为同一个值。这个基本上无感,但如果你做底层计算或者物理计算相关的开发,可能会遇到。

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 3

Map的遍历有四种方式:

// 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,最终要转成普通对象输出。

对象转MapObject.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价值的关键。

数组的includesindexOf实现的是线性查找:从头到尾逐个比较每个元素,直到找到目标。最坏情况下要遍历整个数组,时间复杂度 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

这只是十万条数据,includeshas已经差了上百倍。当数据量到百万、千万级别,这个差距会被无限放大。

所以日常开发中,判断“某个值是否存在于一个大数据集合中”时,优先转化成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 的取舍(简要)

WeakSetWeakMapSet/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中对应的条目会自动消失,不会一直占着内存。

需要留意的是,WeakMapWeakSet不支持遍历,也没有size属性,因为键随时可能被垃圾回收,数量不稳定。所以它们不适合做主数据存储,只适合做辅助性的元数据记录。

5. 日常排查与典型问题速查

5.1 看到 "Set is not iterable" 这类报错怎么办

SetMap都是可迭代对象,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 function

Set没有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根本不认识SetMap,序列化时会忽略它们的内部数据。正确做法是先转成数组或对象:

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 数组,你要用SetMap处理时,先初始化再操作。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 列表是否有交集,列表长度都是几千起步。第一版代码用的数组filterincludes,在本地测试时数据少没感觉,上线后客户反馈页面加载后要等好几秒才能看到图表刷新。排查后发现问题出在嵌套遍历上:三层接口数据,两两比对,复杂度直接 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²) 的遍历。

回到最初的问题:SetMap到底是不是必需的?我的答案是,现代 JavaScript 开发中,它们是数据处理的左膀右臂,是不可或缺的基础设施。数组和对象能解决一部分问题,但当你面对大数据量、动态键、复杂成员关系时,SetMap提供的语义和性能优势会成倍提升你的代码质量和开发效率。

做 JavaScript 开发这些年,我越来越觉得,真正拉开代码水平差距的从来不是花哨的语法,而是对基础数据结构理解得够不够深。把SetMap用熟,等于多了一副趁手兵器,以后写业务代码会顺手很多。如果这篇文章对你有帮助,建议你打开编辑器,把文中的代码都亲手跑一遍,遇到报错再回来看每一节的避坑点,消化效率会高很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/14 17:32:54

上海猫舍选猫流程含预约看猫签约,2026年9月费用按品相核算

在上海,周末预约去猫舍看猫,已经成了不少年轻家庭和独居白领的固定行程。矮脚猫凭借短腿和甜美长相,热度一直居高不下,但选猫流程、费用核算方式却让很多新手摸不着头脑。2026年9月,市场上按品相定价的模式越来越普遍&…

作者头像 李华
网站建设 2026/9/14 17:32:01

SpringBoot党员学习平台开发与架构设计实践

1. 项目概述与核心价值这个基于SpringBoot的党员学习交流平台,本质上是一个专为党组织成员设计的数字化学习管理系统。我在实际开发中发现,这类平台需要同时满足三个核心需求:知识管理的系统性、交流互动的便捷性、以及组织管理的规范性。从技…

作者头像 李华
网站建设 2026/9/14 17:28:33

Less.js 备忘清单:CSS 预处理器核心语法与内置函数实战速查

Less.js 备忘清单:CSS 预处理器核心语法与内置函数实战速查 【免费下载链接】reference 面向开发者的技术速查清单(Cheat Sheets)集合,整理常见技术、工具与开发流程,帮助快速查阅关键信息,提高开发效率。 …

作者头像 李华