深度解析ES6 Set与Map:相同点、核心差异及实战选型
ES6引入的Set和Map,是JavaScript中两种重要的"集合类型",用于解决传统数组、对象在数据存储与查找中的痛点------比如数组去重繁琐、对象键名只能是字符串/ Symbol 类型。很多开发者在使用时,常混淆二者的用法,比如用Set存储键值对、用Map实现去重,导致代码冗余或性能损耗。
本文将从设计初衷、相同特性、核心差异、底层原理、实战场景、避坑指南六个维度,系统性拆解Set与Map的本质:既讲清二者的共性,更聚焦语义、用法、适用场景的核心区别,结合ECMAScript规范与真实开发案例,帮你从"会用"升级到"懂原理、选对场景",写出更高效、规范的JS代码。
一、先明确:Set与Map的设计初衷
Set和Map均诞生于ES6,核心目标是补充传统数组、对象的不足,但设计语义截然不同,这是二者所有差异的根源:
-
Set:设计初衷是"存储唯一值的集合",核心解决"数组/对象中重复值的管理"问题(如去重、判断值是否存在)。它是一种"值-值"的集合(无键名,仅存值),强调"值的唯一性"。
-
Map:设计初衷是"存储键值对的集合",核心解决"对象键名类型限制"问题(对象键名仅支持字符串、Symbol,Map支持任意类型)。它是一种"键-值"的集合(类似强化版对象),强调"键的多样性与映射关系"。
一句话总结:Set是"无键的唯一值集合",Map是"可任意键的键值对集合",二者的定位从根源上决定了它们的用法边界。
二、Set与Map的相同特性(6个核心共性)
作为ES6同期推出的集合类型,Set与Map共享诸多底层设计与API特性,这些共性让它们比传统数组、对象更灵活、更高效:
2.1 均为"有序集合"(插入顺序可追溯)
Set和Map都会记录元素的插入顺序,遍历输出时会严格按照插入顺序返回,这一点区别于对象(ES6前对象无顺序,ES6后仅保证字符串/ Symbol 键的插入顺序,数字键会排序)。
// Set:插入顺序决定遍历顺序 const set = new Set(); set.add(3); set.add(1); set.add(2); console.log([...set]); // [3, 1, 2](严格遵循插入顺序) // Map:插入顺序决定遍历顺序 const map = new Map(); map.set(3, 'a'); map.set(1, 'b'); map.set(2, 'c'); console.log([...map.keys()]); // [3, 1, 2](键的插入顺序)
2.2 均支持"迭代器遍历"
Set和Map都实现了可迭代协议(Iterable Protocol),支持for...of循环、扩展运算符(...)、forEach()方法遍历,无需手动转换格式(区别于对象,需先通过Object.keys()等方法转换)。
// Set遍历(遍历的是"值") const set = new Set([1, 2, 3]); for (const val of set) { console.log(val); // 1, 2, 3 } set.forEach((val) => console.log(val)); // 1, 2, 3 // Map遍历(遍历的是"键值对数组") const map = new Map([[1, 'a'], [2, 'b']]); for (const [key, val] of map) { console.log(key, val); // 1 a, 2 b } map.forEach((val, key) => console.log(key, val)); // 1 a, 2 b
2.3 均不允许"重复键/值"(语义一致)
Set的核心是"值唯一",重复添加相同值会被忽略;Map的核心是"键唯一",重复设置相同键会覆盖原有值------二者都通过" SameValueZero "算法判断元素是否相同(与===类似,但NaN === NaN 为true)。
// Set:重复值被忽略 const set = new Set(); set.add(NaN); set.add(NaN); // 重复,被忽略 set.add(1); set.add(1); // 重复,被忽略 console.log(set.size); // 2(仅NaN和1) // Map:重复键覆盖值 const map = new Map(); map.set(NaN, 'a'); map.set(NaN, 'b'); // 重复键,覆盖值为'b' map.set(1, 'c'); map.set(1, 'd'); // 重复键,覆盖值为'd' console.log(map.size); // 2(仅NaN和1两个键)
2.4 均有"动态长度"属性(size)
Set和Map都通过size属性获取集合中元素的个数,区别于数组(length)和对象(需手动计算),size是只读属性,无法手动修改。
const set = new Set([1, 2, 3]); console.log(set.size); // 3(Set的元素个数) const map = new Map([[1, 'a'], [2, 'b']]); console.log(map.size); // 2(Map的键值对个数) // 错误用法:size只读,无法修改 set.size = 0; map.size = 0; console.log(set.size, map.size); // 3, 2(无变化)
2.5 均支持"清空、删除"操作
Set和Map都提供了统一的API用于删除元素、清空集合,方法名一致(clear()清空所有元素,delete()删除指定元素),语义清晰、用法简洁。
// Set:delete(值) 删除指定值,返回布尔值(是否删除成功) const set = new Set([1, 2, 3]); set.delete(2); // true console.log(set); // Set(2) {1, 3} set.clear(); // 清空所有元素 console.log(set); // Set(0) {}
2.6 均为"引用类型",不存储原始值副本
Set和Map都是引用类型,存储的是值/键的引用(而非原始值副本):若存储引用类型元素(对象、数组),修改元素内部属性会影响集合中的值;但集合的"唯一性"判断仅基于引用地址(而非元素内部属性)。
// Set存储引用类型(对象) const obj = { name: '张三' }; const set = new Set([obj]); obj.age = 20; // 修改对象内部属性 console.log([...set]); // [{ name: '张三', age: 20 }](集合中的值也会变) // Map存储引用类型(数组作为键) const arr = [1, 2]; const map = new Map([[arr, 'a']]); arr.push(3); // 修改数组内部属性 console.log(map.get(arr)); // 'a'(键的引用未变,仍能获取值)
三、Set与Map的核心差异(8个维度全面拆解)
尽管二者有诸多共性,但设计语义的不同,导致它们在数据结构、API、适用场景上存在本质差异。以下从8个核心维度对比,覆盖开发中最常遇到的区分点:
| 对比维度 | Set(集合) | Map(映射) |
|---|---|---|
| 核心数据结构 | 值-值(无键名,仅存储唯一值) | 键-值(键映射值,类似强化版对象) |
| 元素唯一性判断 | 值唯一(SameValueZero算法) | 键唯一(SameValueZero算法),值可重复 |
| 核心API(新增/获取) | add(值):新增值;无get()方法(无法通过值获取值) | set(键, 值):新增键值对;get(键):通过键获取值 |
| 遍历方式细节 | 遍历的是"值"(keys()、values()返回结果一致) | 遍历的是"键值对",可分别遍历键(keys())、值(values()) |
| 判断元素是否存在 | has(值):判断值是否在集合中 | has(键):判断键是否在映射中(无法直接判断值是否存在) |
| 元素删除 | delete(值):通过值删除元素 | delete(键):通过键删除对应的键值对 |
| 默认初始化方式 | 可接收数组(数组元素作为Set的值) | 可接收二维数组(子数组[键, 值]作为Map的键值对) |
| 核心适用场景 | 去重、判断值是否存在、存储不重复的独立值 | 键值对映射、任意类型键存储、需通过键快速查找值 |
关键差异补充(避坑重点)
-
Set无get()方法 :Set仅关注"值是否存在",不支持通过值获取值(因为值唯一,获取值本身无意义);若需通过"标识"获取对应内容,必须用Map。
const set = new Set([1, 2, 3]); ``set.get(1); // undefined(Set无get方法) `` ``const map = new Map([[1, 'a']]); ``map.get(1); // 'a'(Map通过键获取值) -
Map的键可任意类型 :这是Map与对象的核心区别,也是Map的核心优势------键可以是原始类型(字符串、数字、Symbol、null、undefined),也可以是引用类型(对象、数组、函数);而对象的键只能是字符串、Symbol(其他类型会自动转为字符串)。
// Map的键可以是对象 ``const objKey = { id: 1 }; ``const map = new Map([[objKey, '张三']]); ``console.log(map.get(objKey)); // '张三'(通过对象键获取值) `` ``// 对象的键会自动转为字符串 ``const obj = {}; ``obj[objKey] = '张三'; ``console.log(obj[objKey]); // '张三'(看似可用) ``console.log(Object.keys(obj)); // ['[object Object]'](键被转为字符串) -
Set的forEach()参数特殊 :Set的forEach()回调函数有3个参数(val, val, set),前两个参数都是"值"(因为Set无键,为了与Map的forEach()参数格式统一);Map的forEach()回调函数参数是(val, key, map)。
// Set的forEach回调(val, val, set) ``set.forEach((val1, val2, set) => { `` console.log(val1 === val2); // true(两个val都是Set中的值) ``}); `` ``// Map的forEach回调(val, key, map) ``map.forEach((val, key, map) => { `` console.log(val, key); // 值和键 ``});
四、底层原理:Set与Map的实现逻辑
Set和Map的高效性(插入、删除、查找的时间复杂度均为O(1)),源于其底层的"哈希表"实现------与对象类似,但优化了键的存储与查找逻辑,尤其针对Map的任意类型键做了特殊处理。
4.1 Set的底层实现
Set底层基于哈希表,存储结构是"哈希桶":每个值作为哈希表的"键"(因为值唯一),哈希表的"值"无实际意义(仅用于占位)。当调用add(val)时,引擎会通过SameValueZero算法判断该值是否已存在,若不存在则插入哈希桶;调用has(val)、delete(val)时,通过哈希值快速定位值的位置,实现O(1)时间复杂度。
4.2 Map的底层实现
Map底层同样基于哈希表,但存储结构是"键-值对哈希桶":引擎会为每个键计算哈希值(即使是引用类型键,也会通过引用地址计算哈希值),将键值对存储在对应哈希桶中。由于键的哈希值唯一,因此能通过键快速查找值,同时支持任意类型键的存储。
4.3 与对象/数组的性能对比
-
去重场景:Set(O(n))远优于数组(需嵌套循环或indexOf,O(n²));
-
查找场景:Map/Set(O(1))远优于对象(O(1)但存在键类型转换开销)、数组(O(n));
-
删除场景:Map/Set(O(1))远优于数组(splice()会移动元素,O(n))、对象(delete操作会导致哈希表重构,性能较差)。
五、实战场景选型:Set与Map该怎么用?
结合二者的核心差异,选型原则非常明确:仅需"存储唯一值、判断值是否存在"用Set;需"键值对映射、通过键查找值"用Map,具体场景适配如下:
5.1 优先用Set的3类场景
-
数组去重 :这是Set最常用的场景,简洁高效,一行代码即可实现去重(比filter()+indexOf()更简洁、性能更好)。
const arr = [1, 2, 2, 3, 3, 3]; ``const uniqueArr = [...new Set(arr)]; // [1, 2, 3] ``const uniqueArr2 = Array.from(new Set(arr)); // 另一种写法 -
判断值是否存在 :如判断用户ID是否在黑名单中、判断标签是否已选中,Set的has()方法比数组indexOf()、includes()更高效。
// 黑名单判断(高效) ``const blackList = new Set([1001, 1002, 1003]); ``function isBlackList(userId) { `` return blackList.has(userId); // O(1)时间复杂度 ``} -
存储不重复的独立值 :如存储页面中的已选中标签、存储用户的浏览记录(去重)、存储无关联的唯一标识(如订单号去重)。
// 存储已选中标签(去重) ``const selectedTags = new Set(); ``function toggleTag(tag) { `` if (selectedTags.has(tag)) { `` selectedTags.delete(tag); `` } else { `` selectedTags.add(tag); `` } ``}
5.2 优先用Map的3类场景
-
键值对映射场景 :如存储用户信息(键为用户ID,值为用户详情)、存储配置项(键为配置名,值为配置值)、存储字典映射(键为英文,值为中文)。
// 存储用户信息(键为用户ID,值为用户对象) ``const userMap = new Map(); ``userMap.set(1001, { name: '张三', age: 20 }); ``userMap.set(1002, { name: '李四', age: 25 }); `` ``// 通过用户ID快速获取用户信息 ``console.log(userMap.get(1001)); // { name: '张三', age: 20 } -
需要任意类型键的场景 :如用对象/数组作为键,存储对应的数据(如组件缓存、依赖映射);对象无法满足此类需求,必须用Map。
// 组件缓存(键为组件实例,值为组件状态) ``const componentCache = new Map(); ``function cacheComponent(component, state) { `` componentCache.set(component, state); ``} `` ``function getComponentCache(component) { `` return componentCache.get(component); ``} -
频繁添加/删除/查找键值对的场景 :如购物车数据(键为商品ID,值为商品数量)、临时缓存数据,Map的性能优于对象(避免对象键类型转换、哈希表重构的开销)。
// 购物车数据管理 ``const cartMap = new Map(); ``// 添加商品 ``cartMap.set(1, { name: '手机', count: 1 }); ``// 修改商品数量 ``cartMap.set(1, { ...cartMap.get(1), count: 2 }); ``// 删除商品 ``cartMap.delete(1); ``// 查找商品 ``console.log(cartMap.has(1));
六、避坑指南:这些错误用法一定要避开
6.1 坑1:用Set存储键值对,试图通过值获取值
// 错误示例:用Set存储键值对(语义错误) const set = new Set([['name', '张三'], ['age', 20]]); // 无法通过"name"获取"张三" set.get('name'); // undefined // 正确示例:用Map存储键值对 const map = new Map([['name', '张三'], ['age', 20]]); map.get('name'); // '张三'
6.2 坑2:用Map实现去重(冗余且低效)
// 错误示例:用Map去重(多此一举) const arr = [1, 2, 2, 3]; const map = new Map(); arr.forEach(val => map.set(val, val)); const uniqueArr = [...map.values()]; // 正确示例:用Set去重(简洁高效) const uniqueArr = [...new Set(arr)];
6.3 坑3:误以为Map的键是"值相等"即唯一
Map的键唯一基于"引用地址"(引用类型)或"值本身"(原始类型),而非"值相等"------两个内容相同但引用不同的对象,会被视为两个不同的键。
// 错误示例:误以为两个内容相同的对象是同一个键 const map = new Map(); map.set({ id: 1 }, '张三'); map.set({ id: 1 }, '李四'); // 两个对象引用不同,视为不同键 console.log(map.size); // 2(而非1) // 正确示例:用同一个引用作为键 const objKey = { id: 1 }; map.set(objKey, '张三'); map.set(objKey, '李四'); // 覆盖原有值 console.log(map.size); // 1
6.4 坑4:混淆Set与Map的forEach()参数
// 错误示例:误用Set的forEach参数(把第二个val当键) const set = new Set([1, 2, 3]); set.forEach((val, key) => { console.log(`键:${key},值:${val}`); // 错误:key其实是val }); // 正确示例:Set的forEach参数(前两个都是val) set.forEach((val) => { console.log(`值:${val}`); }); // Map的forEach参数(val, key) const map = new Map([[1, 'a']]); map.forEach((val, key) => { console.log(`键:${key},值:${val}`); // 正确 });
七、总结:Set与Map的核心原则与最佳实践
Set和Map不是"替代关系",而是"互补关系",它们的核心价值的是解决传统数组、对象的痛点,让数据存储与查找更高效、更规范。核心原则可总结为:
-
明确语义选型:"唯一值存储、判断值存在"用Set;"键值对映射、通过键查找值"用Map,不混淆二者的设计语义。
-
发挥各自优势:Set的优势是"去重、高效判断存在",Map的优势是"任意类型键、高效键值对映射",避免用其劣势场景(如Set存键值对、Map去重)。
-
牢记底层特性:引用类型作为元素时,Set/Map判断唯一性基于引用地址;Map的键可任意类型,Set无get()方法,这些细节是避坑关键。
-
优先替代传统方案:去重、判断存在场景,用Set替代数组;键值对映射场景,用Map替代对象(尤其当键为非字符串/ Symbol 类型时)。
ES6的Set与Map,看似简单,却能体现开发者对JavaScript基础的掌握程度。在日常开发中,合理运用二者,不仅能简化代码、提升性能,更能让代码的语义更清晰、可维护性更强------细节处见真章,这也是高质量代码的核心体现。