news 2026/10/14 7:01:33

JavaScript Set与Map实战:从数组去重到哈希表性能优化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaScript Set与Map实战:从数组去重到哈希表性能优化

先提一句:如果你在 JavaScript 里处理过列表去重、字典映射、缓存标记这类需求,那么对 Set 和 Map 一定不陌生。这两个数据结构几乎年年出现在面试题里,业务代码里也处处可见,但能真正讲清楚“它们解决了什么问题、底层为什么快、哪些坑不能踩”的人其实不多。我见过太多同学拿着数组硬刚去重、把普通对象当 Map 用,最后在性能和隐蔽 bug 上吃了大亏。这篇文章就用实战视角,把 Set 和 Map 的核心知识完整过一遍,顺便聊聊我踩过的坑和推荐用法。

1. 为什么需要 Set 和 Map:从数组和对象的短板说起

1.1 数组去重:一行代码背后的算法变化

先看最常见的场景——数组去重。新手时期很多人这样写:

const arr = [1, 1, 2, 3, 3, 4]; const unique = arr.filter((item, index) => arr.indexOf(item) === index);

这段代码逻辑是对的,但有个很容易被忽略的缺点:indexOf每次遍历都要从头线性查找一次,整体时间复杂度是 O(n²)。当列表只有几十个元素时感受不到,一旦数据量到十万、百万级,页面会肉眼可见地卡顿。而且indexOf对NaN的支持很糟糕,[NaN, NaN].indexOf(NaN)返回的是 -1,因为indexOf用的是严格相等比较,而NaN !== NaN。

用 Set 之后一切变得非常简单:

const arr = [1, 1, 2, NaN, NaN, 3, 3, 4]; const unique = [...new Set(arr)]; // [1, 2, NaN, 3, 4]

Set 内部对元素去重遵循的是 SameValueZero 比较算法,它的核心特点有两个:第一,NaN被认为等于自身;第二,+0和-0被视为同一个值。这两点和大家直觉里“用 === 去重”的预期不同,所以刚接触时容易困惑,但算法的存在恰恰是为了在绝大多数场景下给出更合理的结果。

1.2 普通对象当字典:原型链与键名限制

很多人为了省事,直接用普通对象充当映射表:

const map = {}; map['a'] = 1; map['b'] = 2;

这个写法在小范围没问题,但它有两个先天缺陷。第一个是原型链污染:如果你存的键恰好叫toString、hasOwnProperty、constructor,会直接覆盖或穿透原型链上的同名属性,轻则取值异常,重则引发安全漏洞。第二个是键名限制:普通对象的键只能是字符串和 Symbol,数字会自动转成字符串,对象作为键时会变成"[object Object]"。

真正让人头疼的是整数键的遍历顺序。看这段代码:

const obj = {}; obj['2'] = 'b'; obj['1'] = 'a'; obj['x'] = 'c'; console.log(Object.keys(obj)); // ['1', '2', 'x']

引擎会把整数键按升序排列,而不是按插入顺序,这跟大多数人的直觉相反。明明代码里先写的'2'再写'1',遍历出来却是'1'在前。如果你依赖插入顺序做业务逻辑,比如维护一个“最近的搜索记录”,这种隐式排序会带来莫名其妙的问题。Map 的出现正是为了彻底解决这类痛点:它把键存储在设计好的哈希结构中,不区分整数键和字符串键,遍历时就老老实实按插入顺序来。

2. Set 的核心机制与实用场景

2.1 基本操作:增删查与链式调用

Set 的基本操作不多,但细节很多。初始化之后,add、has、delete、clear、size就是全部家当:

const s = new Set(); s.add(1).add(2).add(2); // add 返回 Set 本身,可以链式调用 console.log(s.size); // 2 console.log(s.has(1)); // true console.log(s.delete(1)); // true,删除成功返回布尔值 console.log(s.size); // 1 s.clear(); console.log(s.size); // 0

这里有两个值得注意的点。第一,delete返回的是一个布尔值,表示是否真的删除了某个元素,如果你删的本来就不存在,会返回false,这个返回值可以用来写更紧凑的条件逻辑。第二,重复add相同值不会报错,也不会增加size,只会被静默忽略。这种“幂等”设计让 Set 在业务里特别好用,比如收集用户选中项时,不需要先判断“是不是已经选过”,直接add进去就行。

2.2 迭代与转换:为什么 forEach 有两个参数

Set 是可迭代对象,所以for...of是最自然的遍历方式。它的迭代顺序严格等于元素插入顺序,这一点在规范里有明确要求,可以放心用:

const s = new Set(['a', 'b', 'c']); for (const item of s) { console.log(item); }

如果你习惯用forEach,会看到一个有点奇怪的签名:回调函数接收三个参数(value, valueAgain, set),其中前两个参数都是同一个值。这个设计是为了和 Map 的forEach保持一致,Map 回调里第一个参数是 value、第二个是 key,而 Set 没有 key,但为了保持“回调参数模式统一”,就把 value 传了两遍。知道这个机制后,看到别人的代码里写了两个同名参数就不会懵了。

把 Set 转成数组,最常用的三种方式:

const arr1 = [...s]; const arr2 = Array.from(s); const arr3 = Array.prototype.slice.call(s); // 不推荐,只是顺便提一嘴

反过来,数组转 Set 直接new Set(array)。特别提醒一下,Array.from还支持第二个参数做映射函数,所以当你需要“取出数组里每个对象的 id 并去重”时,可以一步完成:

const users = [{ id: 1 }, { id: 2 }, { id: 1 }]; const ids = Array.from(new Set(users.map(u => u.id)));

2.3 Set 的高阶玩法:集合运算与业务场景

Set 天然适合做数学意义上的集合运算。虽然原生没有提供交集、并集、差集的 API,但用一行代码就能组合出来:

const a = new Set([1, 2, 3]); const b = new Set([3, 4, 5]); // 并集 const union = new Set([...a, ...b]); // {1,2,3,4,5} // 交集 const intersection = new Set([...a].filter(x => b.has(x))); // {3} // 差集 const difference = new Set([...a].filter(x => !b.has(x))); // {1,2} // 对称差集 const symmetricDiff = new Set([...a, ...b].filter(x => a.has(x) !== b.has(x))); // {1,2,4,5}

这些运算在权限系统、标签筛选、多选联动场景里非常常用。比如两个角色各有一批可访问的菜单 ID,取交集就能快速算出“哪些菜单两个角色都能访问”,完全不需要两层 for 循环嵌套。

还有一个很容易踩的坑:Set 对对象的比较是基于引用,不是基于内容。所以下面这段代码的has返回false:

const s = new Set([{ id: 1 }]); console.log(s.has({ id: 1 })); // false

如果你要对“结构相同”的对象做去重,得先把对象映射成原始类型或唯一字符串。我在某个后台管理系统里就踩过这个坑:接口返回的列表里,每个对象都是新创建的,直接放进 Set 去重完全无效,后来改成item.id作为键才解决。

3. Map 的完整能力与键值对管理

3.1 Map 和普通对象的核心差异

Map 是专门为键值对场景设计的,它和普通对象的差异可以列一张表:

对比维度普通对象Map
键类型只能是字符串或 Symbol任意值:对象、函数、NaN、数字都可以
遍历顺序整数键会升序排序,其他键按插入顺序严格按插入顺序
size 属性无,需要 Object.keys 计算有直接可用的 size
原型链有,键名可能和原型属性冲突无原型链问题
性能键值少时优化不错,频繁增删不稳定专门为高频增删做了优化,通常更稳
JSON 序列化原生支持直接 JSON.stringify 得到空对象

这里重点说下“键类型”为什么重要。在普通对象里,obj[someObject]会把对象转成字符串"[object Object]",两个不同的对象会变成同一个键。而 Map 保存的是键本身的引用,你可以用同一个 DOM 元素、同一个组件实例、甚至同一个函数作为键。这种能力在管理 UI 状态时极其有用。

另一个容易忽略的是删除操作。对象删除属性用delete obj[key],但性能上通常不如 Map 的delete方法好;更重要的是,Map 可以安全地删除任意类型的键,而对象删除动态key时还得先转成字符串。高频增减键值对的场景,Map 是最稳的选择。

3.2 Map 的增删改查与遍历方法

Map 的基本 API 非常直白:

const m = new Map(); // 添加/更新 m.set('name', '张三'); m.set(42, '数字键'); m.set({ id: 1 }, '对象键'); // 查询 console.log(m.get('name')); // '张三' console.log(m.has(42)); // true // 删除 console.log(m.delete(42)); // true // 清空 m.clear();

遍历时,for...of解构出键值对是最优雅的写法:

const m = new Map([ ['a', 1], ['b', 2], ]); for (const [key, value] of m) { console.log(key, value); }

keys()、values()、entries()三个方法分别返回迭代器,实际上直接for (const [k,v] of m)用的就是entries()的默认迭代器。

对象和 Map 互转是我在业务里经常用到的两个命令:

// 对象转 Map const obj = { name: '张三', age: 18 }; const m = new Map(Object.entries(obj)); // Map 转对象 const m2 = new Map([['name', '李四'], ['age', 20]]); const obj2 = Object.fromEntries(m2);

Object.fromEntries刚出现时很多人没意识到它有多好用。以前要把 Map 数据给后端接口,得手动遍历组装对象,现在一行就搞定了。但要注意:如果 Map 里有非字符串的键,fromEntries会强制转成字符串,可能会丢失原始类型。

3.3 Map 在复杂场景中的实战应用

Map 最常见、也最有价值的场景之一,是用 DOM 元素或其他引用类型作为键。比如我之前做一个列表拖拽排序功能,需要在拖拽过程中记录每个元素的初始位置和尺寸信息。如果用对象存:

const positions = {}; const el = document.getElementById('item-1'); positions[el] = { x: 100, y: 200 }; // 等价于 positions["[object HTMLDivElement]"]

所有元素都会存到同一个键里,直接踩坑。换 Map:

const positions = new Map(); const el = document.getElementById('item-1'); positions.set(el, { x: 100, y: 200 });

这样每个 DOM 元素都精确对应一份位置数据,读取时直接positions.get(el),不需要给元素设置自定义属性,也不会污染 DOM。

另一个高频场景是缓存。用 Map 做记忆化函数时,可以把入参作为键、计算结果作为值:

const cache = new Map(); function fib(n) { if (n <= 1) return n; if (cache.has(n)) return cache.get(n); const result = fib(n - 1) + fib(n - 2); cache.set(n, result); return result; }

当然这个例子本身没有把 Map 的强大体现出来,因为参数是数字,换成对象参数会更明显。比如按“查询条件对象 + 分页信息”作为缓存键,下次用户执行完全相同的搜索时,直接从 Map 里取之前的结果,能省下大量接口请求。

4. WeakSet 和 WeakMap:被忽视的内存优化利器

4.1 弱引用到底是怎么回事

第一次看到 WeakMap 时,很多人的反应是“这不就是 Map 吗?为什么多了个 Weak ?”本质上,“弱引用”指的是:WeakMap 中键所引用的对象不会被垃圾回收机制视为“存活”的依据。

举个例子。正常写const m = new Map(),然后用一个 DOM 元素作为键往里面存数据。即使你把页面上的这个 DOM 元素删掉了,Map 里仍然保存着对这个元素的强引用,垃圾回收器出于安全考虑不会回收它。随着页面不断增删元素,Map 里的旧键会一直堆积,这就是“内存泄漏”的典型成因。

WeakMap 则不同,它的键必须是对象,但它是弱引用。当这个对象不再被外部引用时,垃圾回收器可以随时回收它,WeakMap 里对应的键值对也会自动消失。代价是:你不能遍历 WeakMap,也拿不到它的size,因为里面的内容可能在你查询之前就被回收了。

WeakSet 同理,成员只能是对象,并且对外部不可见、不可迭代。它最典型的用途是给对象打“标记”,比如标记某个元素是否已经初始化过、某个组件是否已经注册过事件。

4.2 WeakMap 实现私有属性与防泄漏

很多年前社区流行用 WeakMap 模拟私有属性。思路很简单:把每个类实例作为键,对应的私有数据作为值:

const privates = new WeakMap(); class Counter { constructor() { privates.set(this, { count: 0 }); } increment() { const data = privates.get(this); data.count += 1; return data.count; } }

外部拿不到privates的内容,也就无法直接修改count,这比给实例挂this._count要干净得多。而且每个实例被销毁后,privates里的对应记录自然被回收,不需要手动清理。

另一个实战场景是保存 DOM 节点的状态。比如你给一组按钮绑定点击事件,需要记录每个按钮的被点击次数,如果用一个 Map 持有按钮引用,按钮移除后 Map 还会占着引用不放手。改成 WeakMap 就稳妥了:

const clickCounts = new WeakMap(); function registerButton(button) { clickCounts.set(button, 0); button.addEventListener('click', () => { const count = clickCounts.get(button) + 1; clickCounts.set(button, count); }); }

按钮从页面移除时,它不再被任何变量引用,WeakMap 里的计数数据也会被释放。这里要特别注意:WeakMap 的键必须是对象,字符串或数字会直接报错;如果你需要按字符串做弱引用缓存,那做不到,还是得用普通 Map 并自己处理清理逻辑。

5. 性能实测、常见误用与避坑清单

5.1 性能对照:不同数据结构到底怎么选

很多人纠结“数组和 Set 谁快”“对象和 Map 谁快”,其实答案没有唯一的,但可以给出经验法则。

先说查找场景。数组的includes和indexOf是线性查找,时间复杂度 O(n);Set 的has和 Map 的get基于哈希表,平均 O(1)。我实际测过一个包含十万个元素的数组,用includes反复查一万次,耗时约在几百毫秒量级;换成 Set 的has后几乎感觉不到等待,差距能到百倍以上。所以只要涉及“大量数据 + 频繁查重/查是否存在”,Set 或 Map 是明确的选择。

插入和删除方面,数组push在尾部追加很快,但删除或插入中间元素需要移动后续元素;Set 的add和delete在设计上就是为频繁变动准备的。如果你经常需要往集合里增删元素,同时又需要快速判断某个值是否存在,Set 比数组合适得多。

Map 和普通对象在高频增删键值对上的差异也类似。V8 引擎对 Map 做了专门优化,频繁增删时 Map 通常比对象更稳定。但对象在一些小数据结构下也有自己的快路径,所以不要一概而论。我的建议是:键为字符串且结构固定、不需要遍历顺序保证、需要 JSON 序列化的场景,继续用对象;其他动态键、任意类型键、高频增删场景,优先 Map。

场景推荐结构
去重、成员判断、集合运算Set
动态键值映射、缓存、键为任意类型Map
静态配置、JSON 数据、简单结构普通对象
需要对象键且不希望妨碍 GCWeakMap
需要对象标记但不希望妨碍 GCWeakSet

5.2 高频错误与排查实录

坑一:Set 对对象按引用比较。前面已经说过,new Set([{id:1}]).has({id:1})是false。如果业务需求是“结构相同就算同一个”,必须先提取基础类型键。

坑二:Map 的get找不到键返回undefined,但键对应的值本身也可能是undefined。只看返回值无法区分是“键不存在”还是“值就是 undefined”,必须用has判断。

坑三:对 Set 或 Map 直接JSON.stringify,结果往往不是你想要的。JSON.stringify(new Map([['a',1]]))得到'{}',因为内部数据不是可枚举的普通属性。正确做法是先转数组:

const m = new Map([['a', 1], ['b', 2]]); const data = JSON.stringify([...m]); // '[["a",1],["b",2]]'

坑四:解构和扩展运算符对 WeakMap/WeakSet 无效。它们是弱引用结构,不能被遍历,也没有迭代器。如果你试图[...weakMap],会直接报错说对象不可迭代。

坑五:使用对象作为 Map 的键时,不要期望“内容相同就能拿到同一个键”。每次{ id: 1 }都创建了新引用,Map 会把它们当成两个不同键。如果需要按内容查找,可以考虑把对象序列化成字符串作为键。

绕到这再提一个实际排查经验:如果你发现某个页面内存持续上涨,优先检查有没有用 Map 持有大对象、DOM 节点等强引用作为键。用 WeakMap 代替之后,很多内存问题会自我修复,不需要写一堆手动delete清理代码。

5.3 调试与序列化技巧

调试 Set 和 Map 时,控制台输出默认不太直观。我喜欢用展开运算符转数组,再配合console.table查看:

const m = new Map([['a', 1], ['b', 2]]); console.table([...m]);

这样会在控制台呈现一个表格,键值一目了然。对于嵌套比较复杂的 Map,也可以先在浏览器 DevTools 里展开对象结构,远比打印字符串靠谱。

深拷贝问题上,现代运行时已经原生支持structuredClone,它可以正确克隆 Map 和 Set(WeakMap/WeakSet 不支持且没必要,因为弱引用无法保留):

const original = new Map([['a', new Set([1, 2])]]); const cloned = structuredClone(original);

这个能力在跨 iframe、跨 worker 传数据时尤其方便,手动递归拷贝很容易丢失类型信息。

6. 实战经验总结

我自己做了这么多年开发,最大的体会是:Set 和 Map 从来不是“更高大上的 API”,它们是解决特定问题的一等公民。Set 负责“唯一性”和“快速成员判断”,Map 负责“任意类型的键值映射”,WeakSet 和 WeakMap 则让你在享受这些能力的同时不去消耗额外内存。真正用好它们,靠的不是背 API,而是想清楚每个选择的代价。

最后分享一个小技巧:在老项目里重构时,把代码中所有obj[key]的动态取值改成map.get(key)的尝试通常收益巨大。很多隐蔽 bug 其实来自对象原型链遮蔽和整数键排序,Map 从设计上就绕开了这些坑。改完之后你会发现代码意图更清晰,那些莫名其妙的undefined也少了一大半。遇到复杂数据结构时先在控制台打印一下键的类型和引用,再决定用 Map 还是 Set,比闷头写循环快得多。

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

MathJax实战:Web前端LaTeX公式渲染、配置与性能优化

1. 为什么要在Web前端引入MathJax&#xff1a;一个被低估的渲染痛点做前端开发这几年&#xff0c;我越来越觉得"在网页里展示数学公式"这件事&#xff0c;是被大量开发者低估的技术点。很多同学遇到的需求场景其实非常普遍&#xff1a;在线教育平台要展示解题步骤、技…

作者头像 李华
网站建设 2026/10/14 7:01:21

API营销实战指南:从开发者体验到增长飞轮

1. 先聊透&#xff1a;为什么说营销逻辑被API彻底改写了聊这个话题之前&#xff0c;我先讲一个特别直观的场景。以前做营销&#xff0c;大家脑子里装的都是"漏斗"——曝光、点击、留资、转化、成交&#xff0c;一层一层往下漏&#xff0c;漏到最后剩下的才是客户。这…

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

Mycat 1.6.7.1 部署实战:分库分表与读写分离配置指南

简介&#xff1a;面向Linux/CentOS 7环境的Mycat 1.6.7.1发行版压缩包&#xff0c;为需要搭建分布式数据库中间层的架构师、运维人员及后端开发者提供完整运行环境。Mycat基于Java开发&#xff0c;通过分库分表、读写分离与自动路由解决大数据量下的水平扩展和高可用问题。压缩…

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

Ollama 本地大模型安装教程:硬件确认、踩坑排查与模型迁移

简介&#xff1a;面向需要在类Unix系统上安装并配置Ollama的用户&#xff0c;这份资源提供了一套完整的下载、安装与排障参考方案。压缩包内共3个文件&#xff0c;包括一个用于下载安装包的PHP脚本、一个可自动完成环境检查、依赖获取与权限设置的Shell脚本&#xff0c;以及一份…

作者头像 李华
网站建设 2026/10/14 7:01:01

香港人读内地金融EMBA值不值得-学费和报名条件

香港人读内地金融EMBA值不值得-学费和报名条件 香港人把「值不值得」拆开&#xff0c;复旦国金EMBA&#xff08;https://fisf.fudan.edu.cn/femba/&#xff09;的简章能对上的是报名条件和学费&#xff0c;没有投资回报率&#xff0c;也没有录取率。2027 级学费是 83.98 万元&a…

作者头像 李华
网站建设 2026/10/14 7:00:33

Python双框架实战:Django与Flask打造学习任务挑战系统

想做一款真正属于自己的学习任务管理工具&#xff0c;市面上那些任务清单、打卡软件用了一圈总觉得不够顺手&#xff0c;数据在自己手里、规则也改不了&#xff0c;于是动手做了这个基于 Python 的个人学习任务挑战系统。整体采用 Django 与 Flask 双框架协作&#xff0c;Djang…

作者头像 李华