ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript 数据结构进阶:Map 与 Set 核心用法、原理对比及 WeakMap/WeakSet 实践指南

JavaScript 数据结构进阶:Map 与 Set 核心用法、原理对比及 WeakMap/WeakSet 实践指南 写业务代码写久了你会发现很多团队里还在用Object当普通键值表、用array.includes检查元素在不在。ES6 之后Map和Set早就给出了更顺手、更可靠的方案但不少人对它们的印象只停留在“可以用”的层面真到选型时又说不出区别。这篇文章就从实际使用角度把Map和Set的核心知识完整过一遍包括基本 API、内部比较机制、与Object/Array的横向对比以及WeakMap/WeakSet的进阶玩法适合正在夯实 JavaScript 基础的开发者也适合准备面试前想系统梳理数据结构的同学。内容不追求面面俱到但保证每个结论都能落到代码上。1. 从需求出发Map和Set到底解决了什么问题很多人第一次接触Map和Set是在面试题或者刷题网站里平时业务代码很少主动用。这其实是好事说明你常用写法已经能对付日常需求但如果你想优化代码的语义和性能或者处理一些稍微复杂的数据状态Map和Set的价值立刻就会体现出来。先说一个最典型的场景状态映射。比如你维护一个用户列表需要根据用户 id 快速找到对应的昵称、年龄、权限。多数人第一反应是写一个对象{ userId: userInfo }这没错对象本来就是这种场景的原生解法。但当你开始遇到“键不一定是字符串”“需要保证插入顺序”“得频繁查看有多少个键值对”这些需求时Object就开始别扭了。至于去重和存在性判断array.includes在数据量小的时候毫无感觉一旦列表上到几千几万条嵌套循环带来的性能压力马上就能把你拖垮。下面我从痛点出发看看Map和Set到底是冲着哪些问题来的。1.1 Object当字典用的三个痛点第一个痛点是键的类型限制。Object的键只能是字符串或者Symbol其他类型会被自动转成字符串。比如obj[1]和obj[1]实际访问的是同一个键这在动态拼键时很容易踩坑。有时候你从接口拿到的是数字 id写代码时可能没注意类型最后 debug 半天发现是字符串和数字打架。第二个痛点是原型链。普通对象字面量{}继承自Object.prototype所以当你用objtoString这类键时可能取到的是原型上的方法而不是你自己存的数据。虽然很多人说“我怎么会用 toString 当键”但业务里用户输入的字段名可不一定听你指挥。更稳妥的做法是用Object.create(null)创建完全干净的对象或者直接换Map后者天然没有这个烦恼。第三个痛点是顺序和长度的获取。Object的键顺序规则比较绕整数键会按升序排在最前字符串键按插入顺序Symbol键最后处理。当你需要按照入参顺序遍历配置项时Object很容易给出意料之外的结果。同时想拿数量只能Object.keys(obj).length每次都要创建一次数组既啰嗦又有微小的性能开销。Map和Set直接用size属性就能拿到数量遍历顺序也稳定等于插入顺序这几个设计让日常开发舒服很多。1.2 数组查重的低效困境如果你用数组做“某个元素是否已经存在”的判断第一反应是arr.includes(item)或者arr.indexOf(item) -1。这两个方法本质都是线性遍历复杂度是 O(n)。如果这段代码放在循环里复杂度直接变成 O(n²)数据量一旦过千浏览器 UI 线程就开始卡顿。更麻烦的是数组的语义里本身没有“唯一性”这个保证。你想实现去重必须自己写循环、用indexOf过滤或者依赖第三方库。可是去重这个需求太常见了应该由一个专门的数据结构来承担这就是Set存在的原因。Map也类似它的价值不是“让你能存东西”而是“让你在频繁读写和判断存在性时不需要手动做类型转换和遍历”。1.3 Map/Set的设计目标简单来说Map和Set是 JavaScript 语言给自己的数据结构拼图上补上的两块关键板子。Map解决“键值集合”问题键可以是任意类型读写、删除、判断存在都接近 O(1) 复杂度遍历顺序保持插入序还有内置的size属性。Set解决“唯一值集合”问题成员天然不重复适合去重、存在性判断、集合运算。理解它们的目标之后你就不会再把Map当作一个“升级版 Object”来看待而是把它看作一个独立的、面向动态键值场景的数据容器。Set也不是“只能去重”的小工具它其实是一套集合运算的基础设施和数学里的交集并集概念一一对应。带着这种视角去用代码结构会清晰很多。2. Map核心知识速查常用操作与原理拆解Map是 JavaScript 里一个真正意义上的“字典结构”key-value配对key不限制类型提供了一套完整的读写接口。下面我把最常用的操作拆开来讲顺便解释一些细节设计背后的原因。2.1 构造与存储Map里到底有什么创建Map有三种常用方式。第一种是空构造const map new Map();第二种是传入一个二维数组数组的每个元素都是[key, value]const map new Map([ [name, 张三], [age, 30], ]);第三种是从另一个Map或者其他可迭代对象拷贝const map1 new Map([[a, 1]]); const map2 new Map(map1);需要注意Map构造时会把所有键值对按插入顺序保存。你传进去的二维数组如果包含重复键后面的值会覆盖前面的值但插入的位置保留第一次出现的位置。Map的键可以是任意类型这是它和Object最本质的区别const map new Map(); const keyObj { id: 1 }; map.set(keyObj, 对象键); map.set(function () {}, 函数键); map.set(NaN, NaN键);这里的键引用非常关键Map存的是键的引用只有同一个引用才能命中同一个键。两个内容完全一样的对象在Map看来是两个不同的键这也是面试里经常出现的考点。2.2 set/get/has/delete/clear与sizeMap的 API 不多但每个都有细节。set(key, value)返回Map实例本身所以可以链式调用const map new Map(); map.set(a, 1).set(b, 2).set(c, 3);get(key)返回对应值没有则返回undefined。这里有个隐蔽点如果键存在但值本身就是undefinedget也会返回undefined所以判断键是否存在要优先用hasmap.set(x, undefined); console.log(map.get(x)); // undefined但键其实存在 console.log(map.has(x)); // truedelete(key)返回布尔值表示是否真的删掉了键。clear()清空所有键值对。size是一个属性不是方法直接读取即可这点和数组的length一样自然。2.3 遍历顺序与迭代能力这是Map的隐藏优点Map最大的隐藏优点之一是遍历顺序它就是插入顺序。这一点对于配置管理、状态回放这类场景特别重要。你可以用for...of直接遍历拿到的是[key, value]数组const map new Map([ [a, 1], [b, 2], ]); for (const [key, value] of map) { console.log(key, value); }Map还提供了三个迭代器方法keys()、values()、entries()。entries()是默认迭代器所以上面直接遍历map等价于遍历map.entries()。forEach的回调签名是(value, key, map)注意顺序是值在前、键在后这点和数组的forEach习惯相反很容易写反。map.forEach((value, key) { console.log(${key}: ${value}); });2.4 几个容易翻车的细节第一个坑是NaN。在Map中NaN可以被当作键而且多次set(NaN, ...)命中同一个键。原因是Map内部使用SameValueZero算法比较键它认为NaN等于自身但NaN NaN却是false。如果你用对象模拟字典就很难处理好这种边界。第二个坑是引用类型键。Map比较两个对象时比的是引用地址不是深度比较const map new Map(); map.set({ id: 1 }, hello); console.log(map.get({ id: 1 })); // undefined这里两次{ id: 1 }是新创建的两个对象引用不同所以取不到。需要取同一个引用才能命中。第三个坑是Map转Object时可能丢数据。如果键是字符串const map new Map([[name, 张三]]); const obj Object.fromEntries(map); // { name: 张三 }但如果键是对象或者数字Object.fromEntries会把键转成字符串语义就变了。反过来Object转Map倒是很顺畅new Map(Object.entries(obj))。3. Set核心知识速查唯一性保证与集合思维如果说Map是字典那Set就是数学意义上的集合元素不重复、没有索引、不提供“取第几个”的接口。它的核心价值在于唯一性保证和集合运算。3.1 Set的构造与唯一性机制创建Set可以直接传一个可迭代对象进去数组、字符串、Map、另一个Set都行const set1 new Set([1, 2, 3, 3, 2]); // {1, 2, 3} const set2 new Set(hello); // {h, e, l, o} const set3 new Set(set1); // 拷贝一个新SetSet内部的唯一性判断同样使用SameValueZero算法。这意味着两个NaN会合并成一个元素。0和-0会合并成一个元素。两个内容相同但引用不同的对象会被视为两个不同元素。用代码验证const set new Set([NaN, NaN]); console.log(set.size); // 1 const objSet new Set([{ id: 1 }, { id: 1 }]); console.log(objSet.size); // 23.2 add/has/delete/clear/sizeadd(value)返回Set本身可以链式调用。has(value)是关键方法它的复杂度接近 O(1)替代数组的includes时性能提升非常明显。delete(value)返回布尔值表示是否删除了元素。clear()清空所有元素。size属性直接给出元素个数。Set没有get方法因为它的语义里没有“根据键取对应值”这一说只有“在不在、有多少、怎么遍历”。如果你确实需要从Set中取某个元素通常是用迭代器遍历或者先转成数组const set new Set([a, b, c]); const arr [...set]; const last arr[arr.length - 1];3.3 数组去重一行代码背后的边界情况数组去重已经是Set最出名的应用之一写法非常简单const arr [1, 2, 3, 3, 2, 1]; const unique [...new Set(arr)]; // [1, 2, 3]但这个写法有几个边界需要注意。第一它去重时不会做类型转换数字1和字符串1是不同元素所以[...new Set([1, 1])]结果是[1, 1]不会合并。第二NaN会被正确去重[NaN, NaN]去重后只有一个NaN。第三对象去重按引用判断两个结构相同但地址不同的对象不会被合并。如果你的业务期望“两个对象内容相同就算重复”直接套Set是行不通的得先用JSON.stringify之类的方法生成唯一标识或者使用Map配合规范化 key。3.4 集合运算并集、交集、差集、对称差集的写法Set的好处在于你可以直接用原生 API 实现数学上的集合运算。我整理了一份可以直接抄的代码// 并集 function union(a, b) { return new Set([...a, ...b]); } // 交集 function intersect(a, b) { return new Set([...a].filter((item) b.has(item))); } // 差集在a中但不在b中 function difference(a, b) { return new Set([...a].filter((item) !b.has(item))); } // 对称差集a、b中各自独有的元素 function symmetricDifference(a, b) { return new Set([ ...[...a].filter((item) !b.has(item)), ...[...b].filter((item) !a.has(item)), ]); }这里的核心思想是利用has代替数组的includes。如果两个集合大小分别是 m 和 n用includes做差集是 O(m×n)用has则在哈希表理想情况下能接近 O(mn)数据量大时差距极其明显。实际业务里求两个用户列表的共同权限、比较两个标签集合的差异、合并配置项都可以直接用这几个函数。3.5 有序与迭代Set的遍历顺序也是插入顺序这一点很多人容易忽略。它同样支持for...of、forEach、keys()、values()、entries()。由于集合没有独立的键和值概念keys()和values()返回的内容完全一样entries()返回的每一项是[value, value]。这个设计是为了让Set和Map的接口保持对称方便互换使用。4. Map与Set、Object与Array的横向对比与选型指南很多人的纠结点不是“某个 API 怎么用”而是“什么时候该用谁”。这里我整理了一份对照思路帮你在代码里快速做决定。4.1 一张表说清核心差异维度MapObjectSetArray键类型任意类型字符串或Symbol任意值元素任意值元素键顺序插入顺序整数键优先、规则复杂插入顺序索引顺序大小获取size属性Object.keys().lengthsize属性length属性存在性判断has(key)需自行处理原型链has(value)includes(value)删除操作delete(key)delete obj[key]delete(value)splice或者filter原型链影响无有无无JSON序列化需要手动转换原生支持需要手动转换原生支持典型场景动态键值映射、缓存结构化数据、DTO去重、集合运算有序列表、随机访问这张表里最值得记住的一条是Object适合结构固定、能直接用字面量表达的“记录”类型数据Map适合键不确定、需要频繁增删的“映射”类型数据Set适合只关心“是否存在、是否唯一”的“集合”类型数据。4.2 什么时候该用Map而不是Object反过来呢我自己的选型经验是只要出现以下任一信号优先Map键的类型不是字符串或Symbol。比如用对象、数组、函数作为键。需要频繁添加、删除键值对并关心size。需要保证遍历顺序等于插入顺序。不希望键名意外命中原型链比如存在用户自定义字段名。反过来Object在几个场景仍然不可替代需要直接放到JSON.stringify里传输。结构是固定的“记录”比如员工信息{ name, age, department }。需要挂在对象上的方法利用原型链组织行为。举个现实中的例子前端经常做“权限点映射”const permissionMap new Map(); permissionMap.set(user:view, [read, profile]); permissionMap.set(user:edit, [read, write, profile]); if (permissionMap.has(user:edit)) { // 动态判断权限点是否存在 }这里键是字符串其实Object也能实现但Map的size和delete语义更直观。更重要的是如果某天权限点来自用户输入不用再担心__proto__、constructor这类字段被原型链污染。4.3 什么时候该用Set而不是数组Set和Array的共同点是都能装一组数据但语义差异极大。数组保留重复、支持索引、适合位置敏感的场景Set自动去重、存在性判断高效、不支持索引。如果一个列表里业务上不应该出现重复项那直接使用Set可以从数据结构层面杜绝问题而不是每次插入前手动查一遍。经典案例是消息列表中的已读记录const readSet new Set(); readSet.add(message-1); readSet.add(message-2); function handleMessage(id) { if (readSet.has(id)) { return; // 已处理过直接跳过 } readSet.add(id); // 处理消息 }用数组的话每次都得写if (!arr.includes(id)) arr.push(id)又慢又啰嗦。换成Set后既不用查重语义也更清晰。如果你最后需要把Set渲染到表格或者列表里转数组也很方便[...readSet]或者Array.from(readSet)。4.4 性能实测与经验数字我从实际测试里得到的数据不是绝对标准但趋势很稳定。在本地跑 10 万条数据Set.has比Array.includes快好几个数量级Map.get和Object属性访问的差距没有那么大但在频繁delete和新增的场景里Map的优势非常明显因为Object删除属性会触发引擎的隐藏类优化失效容易出现性能抖动。这里补充一个容易忽略的点Object的delete操作在某些引擎里会导致对象从“快速模式”降级为“字典模式”后续属性访问变慢。而Map.delete是原生设计不会有类似副作用。如果你有一段代码会高频增删键值对直接用Map比在Object上折腾要省心得多。5. WeakMap与WeakSet内存不泄漏的进阶玩法WeakMap和WeakSet是Map/Set的“弱引用版本”它们的存在主要为了解决内存泄漏问题。这一节是进阶内容但实际工程里非常有用。5.1 弱引用到底是什么意思理解弱引用之前先看一下普通Map的问题。如果你把一个对象当作Map的键那么这个对象会一直被Map引用着。即使其他地方都不再使用这个对象了只要Map还在垃圾回收器就不会把它回收导致内存占用一直在涨。WeakMap对键的引用是弱引用当对象在其他地方没有引用时它就可以被垃圾回收WeakMap里的键值对也会随之消失。用生活类比来说普通Map像一个记名储物柜你存进去的东西只要不取就一直占着位置WeakMap像一个公共寄存处如果箱子里外的东西都不被需要了清洁工第二天就自动清走了。WeakMap的键必须是对象不能是基本类型。它没有size属性也不能迭代因为垃圾回收的时机不固定任何时刻读取到的内容都可能变少语言层面就不让你依赖这些信息。5.2 WeakMap的典型场景私有字段与缓存一个经典场景是用WeakMap保存对象的私有数据。比如const privateData new WeakMap(); class Person { constructor(name) { privateData.set(this, { name }); } getName() { return privateData.get(this).name; } } const p new Person(张三); console.log(p.getName()); // 张三这段代码的关键在于外部无法直接拿到privateData除非同一个作用域里有这个WeakMap。对象实例如果被释放对应的私有数据也会自动清理不会内存泄漏。另一个典型场景是缓存。假设你有一个纯计算函数根据传入对象计算结果const cache new WeakMap(); function compute(obj) { if (cache.has(obj)) { return cache.get(obj); } const result expensiveCalculation(obj); cache.set(obj, result); return result; }这里把缓存绑定在传入对象上。对象随后在业务里被丢弃时缓存条目也会被垃圾回收不会误导我们以为“缓存必须手动清理”。5.3 WeakSet的用途与限制WeakSet和Set的区别在于它只接受对象且弱引用没有size、不能迭代。最常用的场景是标记对象是否已经被处理过或者记录某个函数是否已经调用过const handled new WeakSet(); function processNode(node) { if (handled.has(node)) { return; } handled.add(node); applyAction(node); }用WeakSet而不是Set的好处很明显node一旦从 DOM 中移除或不再被业务代码引用它就会从handled中自动消失不用担心节点越来越多导致内存膨胀。使用时要注意WeakSet不存在“查看全部元素”的说法。如果你需要遍历、统计、排序就别用弱集合回到普通Set或数组。6. 常见问题与日常踩坑实录这一节把我真实写代码时踩过、见别人踩过的一些问题整理成速查形式能帮你少走弯路。6.1 为什么Map/Set的遍历顺序和Object不一样Object的键顺序并不完全等于插入顺序。整数类型的键会优先按数值升序排列之后才是字符串键按插入顺序最后是Symbol键按插入顺序。也就是说{ b: 1, 2: 2, a: 3 }遍历时会先输出2再输出b和a这在做配置展示时容易造成困惑。Map和Set完全按插入顺序遍历如果你需要“看到的就是存进去的顺序”直接用它们不会出错。6.2 对象元素内容相同但引用了不同Set去重失败常见写法const arr [{ id: 1 }, { id: 1 }]; const unique [...new Set(arr)]; console.log(unique.length); // 2去重失败因为Set比较对象时用的是引用地址不是内容。如果你业务上就是“只要 id 相同就算重复”可以先用 id 生成唯一键或者改用Mapconst map new Map(); for (const item of arr) { map.set(item.id, item); } const unique [...map.values()];这个方案本质是“以 id 为键做覆盖”能保证相同 id 只保留最后一个对象比直接用Set更符合业务直觉。6.3 JSON.stringify无法直接序列化Map和Set直接对Map或Set执行JSON.stringify得到的结果是{}因为序列化机制不认识这两个数据结构。要传输到后台通常有两种做法。一种是在序列化前转成数组const map new Map([[a, 1]]); const mapJson JSON.stringify([...map.entries()]); // [[a,1]] const set new Set([1, 2]); const setJson JSON.stringify([...set]); // [1,2]另一种是给Map和Set定义toJSON方法配合JSON.stringify的自动调用机制Map.prototype.toJSON function () { return [...this.entries()]; }; Set.prototype.toJSON function () { return [...this]; };定义之后JSON.stringify(map)就会自动输出数组形式。但这会污染原生原型一般不建议在线上公共代码里这么干更适合在局部脚本或明确小团队项目里用。6.4 在React/Vue里操作Map/Set为什么有时候刷新不更新现代框架对Map和Set的响应式支持情况不同。Vue 3 的响应式系统原生支持Map和Set可以直接监听set、add、delete等操作。React 则更需要注意不可变性直接map.set(key, value)虽然能修改数据但组件不会感知到引用变化视图不会更新。更稳妥的做法是生成新实例再 setStateconst nextMap new Map(this.state.map); nextMap.set(key, value); this.setState({ map: nextMap });这里的原理是 React 依赖引用变化触发重渲染。Map和Set的原生修改方法不会改变对象本身引用所以要靠新建实例的方式模拟不可变更新。这也是很多新人在 React 里用Map后数据改了但视图不动的原因。6.5 常见错误速查表错误写法问题说明正确写法delete map.keyMap没有属性删除方式map.delete(key)map.forEach((key, value) ...)回调参数顺序是值在前map.forEach((value, key) ...)new Set(a).has({ id: 1 })对象字面量每次都是新引用先保存同一个对象引用再判断JSON.stringify(new Map())结果是{}先转[...map.entries()]再序列化new Set(arr)[0]Set不支持索引访问使用[...new Set(arr)][0]map.size()size是属性不是方法直接读map.sizenew Map(object)普通对象不是可迭代的键值对集合先Object.entries(object)再传入这些坑未必每个都会遇到但只要在团队里带过几次新人基本都能看到它们反复出现。把这些规则记熟写Map/Set的姿势会标准很多。我个人在实际项目里的习惯是需要维护“某条数据是否处理过”的标记时直接用Set需要按业务 id 快速定位一条记录时毫不犹豫选Map只有面对结构固定的配置对象、需要序列化传输时才继续用Object。最后再分享一个小技巧调试Map数据时用console.table(Array.from(map, ([key, value]) ({ key, value })))能在一张表格里清楚看到全部键值对比直接打印Map对象直观得多。这个技巧在你处理几十条配置映射时尤其好用。
返回列表