☰
深入理解JavaScript Set与Map:原理、实战与性能优化
2026/10/14 7:01:36 网站建设 项目流程
## 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遍历、被解构。这一特性极大提升了它们在函数式编程场景里的适用性。

先看 Set:

const s = new Set([1, 2, 3]); const arr = [...s]; // [1, 2, 3] const max = Math.max(...s); // 3

三个点解决“集合转数组”的高频需求。再配合 filter 或 map,你甚至可以直接对 Set 做链表式处理(先转数组,再回 Set)。

再看 Map:

const 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)永远为 false,set.delete(NaN)永远无法命中,这会让数据的“唯一性判断”失效。ES6 设计者专门引入 SameValueZero 来修复这个坑。

但要注意:它和Object.is也不完全一样。Object.is(NaN, NaN)是 true,Object.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遍历列表显得很低效,此时可以把列表转为 Map:

const 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 缓存正在进行的 Promise:

const 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,问题就迎刃而解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询