前端别再只用数组对象了:Map与Set实战指南
2026/9/15 3:33:36 网站建设 项目流程

我刚工作那会儿,做数据报表页面,后端一次返回两千多条数据,前端要按分类筛选、按状态统计、按 ID 快速回显,我全程用数组filterfindincludes硬写。页面一卡,我就怀疑后端接口慢,直到大佬过来瞄了一眼,说了一句让我记到现在的话:“不是接口慢,是你拿数组当字典用,复杂度全写在脸上了。”

现在回想,当时缺的就是这份“前端别再只用数组对象了”的意识。日常业务里,遇到重复查找、批量去重、频繁增删、以对象为键,用Array和普通Object硬扛,代码能跑,但性能和可读性都吃亏。而 ES6 的MapSet,恰恰是为这些场景准备的。

所以这篇文章,我不是给你列 API 文档,而是用自己踩过的坑,把MapSet到底解决什么问题、什么场景下真香、什么场景下别乱用,完整聊一遍。无论你是刚入门的前端,还是已经写了两三年业务代码,读完都能直接在项目里用起来。

1. 为什么你该换换思路:从数组对象到 Map/Set

1.1 先搞清楚 Map 和 Set 到底是什么

很多老铁对MapSet的第一反应是“不就用ObjectArray就够了嘛”。这个想法我太理解了,毕竟我们绝大多数业务场景里,Object存属性、Array存列表,已经成了肌肉记忆。

MapSet不是来替代ObjectArray的,它们更像是为“特定数据结构需求”准备的专业工具。

Map是什么?一句话:一种真正的键值对集合,而且键可以是任意类型。你拿对象、数组、函数当 key 都行,它不会像Object那样把 key 隐式转成字符串。它内部基于哈希表实现,自带size属性,遍历时保持插入顺序。

Set是什么?一句话:一种值不重复的集合,你可以把它理解成“数学上的集合”。往Set里加重复的值,它只会保留一个。它同样基于哈希表实现,addhasdelete都是 O(1) 时间复杂度的操作。

这里有一个非常容易搞混的点:Array.prototype.map()是数组的遍历方法,它跟集合类型Map完全不是一回事。一个是操作数组的手段,一个是数据结构本身。你平时说“给我 map 一下”,大概率是前者;但你要是说“我建一个 Map 来存”,那就是后者。名字像,但它们是两个物种。

1.2 本质差异:哈希表 vs 线性扫描

要理解为什么Map/Set在某些场景“真香”,必须抛开 API 层面的花活,看底层的数据结构差异。

数组在内存里是线性存储的。你调用arr.find(item => item.id === 5)或者arr.includes(target)时,它的底层逻辑就是从头到尾遍历一遍,一个个比,直到命中。这意味着,数组长度为 10,查找耗时约 10 个单位;数组长度为 10000,查找耗时约 10000 个单位。时间复杂度是 O(n)。

MapSet内部是哈希表结构。你调用map.get(key)或者set.has(value)时,底层逻辑是对 key/value 计算哈希值,然后直接定位到存储位置,不用遍历。无论集合里有 10 个元素还是 10000 个元素,单次查找耗时基本恒定。时间复杂度是 O(1)。

用生活类比解释就是:数组查值像你在一个没有标签的抽屉里翻袜子,要从第一只翻到最后一只,运气不好翻到底才能找到。Map/Set查值像你给每只袜子贴上标签并登记了位置,直接跑过去拿就行,抽屉里袜子再多也只是多占点空间,查找速度几乎不变。

所以,当你频繁执行“按 ID 找对象”“判断某个值存在不存在”这类操作时,用数组就是在用 O(n) 的复杂度做 O(1) 就能完成的事。数据量小没问题,一上量就卡。

2. Map 实战:那些我用数组硬撑到想哭的场景

2.1 缓存与取值场景:别再写两层 for 循环了

我印象最深的一个真实场景是:后端返回了一个商品列表,页面里需要根据用户勾选的商品 ID 数组,频繁获取对应商品详情并展示。

我第一版代码长这样:

const goodsList = [/* 几百个商品对象 */]; const selectedIds = [101, 205, 367]; function getGoodsById(id) { return goodsList.find(item => item.id === id); } selectedIds.forEach(id => { const goods = getGoodsById(id); render(goods); });

表面看没啥问题,但goodsList.find()每次都是全列表遍历。如果selectedIds也有几百个,那就是几百乘几百的遍历次数,页面交互明显卡顿。

Map改造后是这种写法:

const goodsMap = new Map(goodsList.map(item => [item.id, item])); selectedIds.forEach(id => { const goods = goodsMap.get(id); render(goods); });

这里我先把列表数据“预处理”成一个Map,key 是商品 ID,value 是商品对象。这样后面每次取值都是 O(1) 直接命中,不用一轮轮遍历。

这个模式的本质,是把“多次查询”的成本转换成“一次建表”的成本。如果列表只查一两次,用find没啥问题;但如果要反复查、多次查、不同模块查,先建Map绝对划算。

实际项目里,我甚至会写一个小工具函数来统一处理这种场景:

function toMap(arr, key = 'id') { return new Map(arr.map(item => [item[key], item])); }

后续不管谁要按 ID 取数据,一行搞定。现在这工具已经传遍我们小组了。

2.2 以对象为键:Map 真正不可替代的地方

用数组取值这事,用普通对象也能解决一部分,比如 key 是字符串 ID 的时候,obj[id]一样是 O(1)。那Map到底赢在哪?

答案是:当 key 是引用类型的时候,只有Map能做到,普通Object根本做不到。

因为Object的 key 会被强制转成字符串。你把一个对象当 key 传进去,它默默变成"[object Object]",所有对象都撞车。

我曾经在一个可视化项目里,需要给每个图表实例绑定一个状态对象。我一开始想用普通对象:

const stateMap = {}; const chartInstance = initChart(); stateMap[chartInstance] = { zoom: 2, filter: 'ALL' }; console.log(stateMap); // 输出的是 { '[object Object]': { ... } }

等你想取出来的时候,只能通过那个被转成字符串的 key 去取,根本没办法用原对象还原对应关系。这种场景下,Object是完全不可用的。

换成Map毫无负担:

const stateMap = new Map(); const chartInstance = initChart(); stateMap.set(chartInstance, { zoom: 2, filter: 'ALL' }); // 之后无论什么时候,用同一个对象引用当 key 取就行 const state = stateMap.get(chartInstance);

类似的场景还包括:需要给 DOM 节点存附加数据、给组件实例缓存内部状态、按钮点击事件里关联业务对象。只要 key 是“某个对象”,优先考虑Map。它保存的是对象的引用本身,而不是对象的字符串化结果。

2.3 遍历顺序与频次:Map 的隐藏优势

还有一个很实用但容易被忽略的点:Map严格保持插入顺序。

普通Object的键顺序其实是有规则的,但也有限制:整数型的 key 会被自动排到前面并按升序排列,字符串 key 才保持插入顺序。这个“自动排序”行为在某些场景下会坑人。

我之前做一个动态表单,需要用对象存储字段配置:

const fieldConfig = {}; fieldConfig['name'] = { label: '姓名' }; fieldConfig['age'] = { label: '年龄' }; fieldConfig['1'] = { label: '自定义字段1' }; for (let key in fieldConfig) { console.log(key); // 输出顺序可能是 '1', 'name', 'age' }

字段顺序莫名其妙变成了数字优先,渲染出来表单顺序和定义顺序对不上。这个问题的根源就是Object对整数 key 的特殊处理。

换成Map后:

const fieldConfig = new Map(); fieldConfig.set('name', { label: '姓名' }); fieldConfig.set('age', { label: '年龄' }); fieldConfig.set('1', { label: '自定义字段1' }); for (let [key, value] of fieldConfig) { console.log(key); // 输出永远是 'name', 'age', '1' }

Map的遍历完全按照set的顺序,没有任何额外规则。对于“顺序敏感”的数据,比如表单字段、步骤配置、缓存淘汰队列,Map是你的可控性更可靠的选择。

2.4 Map 和 Object 到底怎么选:一张表看懂

我见过很多指南试图把ObjectMap的优势说得很复杂,其实落到实际选择上,核心就几条判断标准。下面这张表是我根据自己的项目经验整理的,可以直接贴到团队文档里:

判断维度推荐选择理由
key 是对象、数组等引用类型MapObject会强制转字符串,无法正确识别引用类型 key
需要频繁增删键值对MapObject的增删操作在隐藏类机制下可能触发性能退化,Map增删更稳定
key 需要保持插入顺序MapObject对整数 key 有自动升序规则,顺序不可控
需要知道键值对总数Mapmap.size直接可读,ObjectObject.keys().length
数据需要被 JSON 序列化ObjectJSON.stringify不直接支持Map,需要先转换
只是少量静态配置Object语法更简洁,可读性好,没必要上Map
需要通过.语法直接访问Objectobj.name写起来确实比map.get('name')方便

核心逻辑就是:当数据“需要被当作真正的键值对集合”来操作时,用Map;当数据只是“一组固定的属性”时,用Object前端老铁别陷入非此即彼的选择困难症,两者是互补关系。

3. Set 实战:去重、集合运算与判存

3.1 数组去重:一行代码搞定的事

前端面试题里,数组去重简直是八股文常客。很多人写filter + indexOf,或者reduce + includes,其实有了Set之后,去重就是一行代码的事:

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

原理非常简单:Set天然具有“不能有重复值”的特性,把数组展开成Set构造参数时,重复值自动被去除,再用扩展运算符把Set转回数组。

这是最基础的去重。还有两个进阶点你们一定用得上。

进阶一:对象数组怎么去重?

如果你直接new Set(arr)——一个包含对象的数组——会发现去了个寂寞。因为Set判断是否重复时,对引用类型比较的是引用地址,而不是对象内容。两个结构相同但地址不同的对象,Set认为是两个不同值。

所以对象数组去重要先指定“唯一依据”,比如按id去重:

const list = [ { id: 1, name: '张三' }, { id: 2, name: '李四' }, { id: 1, name: '张三' }, ]; const deduped = [...new Map(list.map(item => [item.id, item])).values()]; // 先用 id 做 Map 的 key,后面出现的重复 id 会覆盖前面的,再取 values

这一招其实结合了MapSet两种结构的思维。先去重,后取值,一行代码解决对象数组按字段去重的问题。

进阶二:数组去重的兼容性问题。

new Set(arr)在绝大多数现代浏览器里都没问题。但如果你的项目要兼容非常老的运行环境(比如某些内置 WebView),需要做降级处理,用filter + indexOf兜底。2026 年再搞前端开发,基本可以无视这个兼容性顾虑,但面试时候能说出降级方案,比只会背一行代码要强。

3.2 交集、并集、差集:Set 的数学天赋

前端开发中处理集合运算,最常见的场景是权限、标签或筛选条件。比如两个角色拥有的权限列表,要算交集、并集、差集,用数组处理非常痛苦,但用Set可以实现得非常优雅。

交集:两个集合中共同存在的值。

const a = new Set([1, 2, 3, 4]); const b = new Set([3, 4, 5, 6]); const intersection = [...a].filter(item => b.has(item)); // [3, 4]

并集:合并两个集合,自动去重。

const union = new Set([...a, ...b]); // Set { 1, 2, 3, 4, 5, 6 }

差集:在 A 中但不在 B 中的值。

const difference = [...a].filter(item => !b.has(item)); // [1, 2]

这几个写法里,核心工具都是Set.prototype.has()。判断一个值在不在集合里,O(1) 复杂度,配合数组的filter遍历,逻辑一行就表达清楚了。

我在实际项目里封装过一个通用工具函数:

function setOperation(setA, setB, type = 'intersection') { const aArr = [...setA]; if (type === 'intersection') { return new Set(aArr.filter(v => setB.has(v))); } if (type === 'difference') { return new Set(aArr.filter(v => !setB.has(v))); } if (type === 'union') { return new Set([...setA, ...setB]); } }

前端能碰到的集合运算,绝大多数是这些简单场景。记住这几个模式,比每次现场想逻辑要快得多。

3.3 判断存在性:从 includes 到 Set.has 的飞跃

还有一个高频操作是“判断某个值是否存在”。

以前我们用数组处理:

const allowedRoles = ['admin', 'editor', 'viewer']; if (allowedRoles.includes(userRole)) { // 放行 }

功能没问题,但includes底层是遍历比较。如果allowedRoles有几百上千个值,而每次渲染都要判断多次,这个遍历开销会被放大。

更好的写法:

const allowedRoles = new Set(['admin', 'editor', 'viewer']); if (allowedRoles.has(userRole)) { // 放行 }

换成Set之后,判断存在性的复杂度从 O(n) 降到 O(1),在大量判断场景下性能提升非常明显。

另一个实际体验很深的点是用户勾选场景。比如页面里有一堆可选项,用户勾选一些,需要记录“哪些被勾选了”,同时要判断某个选项是否已勾选。

数组写法通常是这样:

const selected = []; function toggle(item) { const idx = selected.indexOf(item); if (idx > -1) { selected.splice(idx, 1); } else { selected.push(item); } } function isSelected(item) { return selected.includes(item); }

这个写法问题不少:indexOf返回 0 的时候判断要小心、splice改变数组索引、频繁增删导致多次遍历。换成Set之后:

const selectedSet = new Set(); function toggle(item) { if (selectedSet.has(item)) { selectedSet.delete(item); } else { selectedSet.add(item); } } function isSelected(item) { return selectedSet.has(item); }

代码量更少,逻辑更直白,而且不管集合多大,判断和增删都是 O(1)。我还经常利用Setsize属性直接取选中数量,不必每次length操心重复项。

4. WeakMap 和 WeakSet:进阶选手的内存管理

4.1 弱引用是什么?为什么有必要

聊完MapSet,如果只说业务层面就停了,那这文章也就到及格线。真正让我觉得“数据结构思维开窍”的,是WeakMapWeakSet

WeakMapWeakSetMap/Set的“弱引用版本”。什么叫弱引用?简单说,如果一个对象只被WeakMapWeakSet引用着,而没有被其他代码引用,那这个对象就可以被垃圾回收机制回收掉。

用生活类比:强引用像你把某人微信好友天天联系,对方一直在你通讯录里;弱引用像你只是在大街上瞥了某人一眼,擦肩而过之后,对方就从你的记忆里消失了,你也不会占用任何“关注名额”。

普通Map持有的是强引用。这意味着:

let user = { name: '张三' }; const userMap = new Map(); userMap.set(user, { visits: 10 }); user = null; // 我把 user 变量置空了 // 但 userMap 里那个 key 仍然持有原对象,它不会被回收

在这个例子里,你以为把user置空就能释放内存了,但Map还牢牢攥着那个对象,垃圾回收器无法回收它。如果这种场景很多,就会造成内存泄漏。

WeakMap的 key 必须是对象,而且它不会阻止垃圾回收:

let user = { name: '张三' }; const userWeakMap = new WeakMap(); userWeakMap.set(user, { visits: 10 }); user = null; // 此时 WeakMap 里的 key 不再阻止垃圾回收 // 对象可以被回收了

WeakMap对这个对象很“佛系”:你用得上它,它在;你不用了,它就自动消失,不拖泥带水。

4.2 防内存泄漏的实战场景

我自己印象最深的场景是给 DOM 节点存数据。假设页面里有大量列表项,每个列表项节点都关联着一个状态对象。

如果你用Map

const stateMap = new Map(); listItems.forEach((node, index) => { stateMap.set(node, { index, visible: true }); }); // 当某些节点被删除时 container.innerHTML = ''; // 问题来了:stateMap 仍然持有被删除节点的引用 // 这些节点和状态对象都无法被垃圾回收

WeakMap

const stateWeakMap = new WeakMap(); listItems.forEach((node, index) => { stateWeakMap.set(node, { index, visible: true }); }); // 当节点被从页面移除、且外部没有其他引用时 // 节点和对应的状态对象会自动被垃圾回收

如果用Map存 DOM 节点的数据,节点删除后Map还在占着内存不放。用WeakMap,节点从页面消失,它的数据也跟着消失,干净利落。

WeakSet的应用类似,比如你要记录“哪些对象已经被处理过”,但不希望这个记录本身阻止对象被回收:

const processed = new WeakSet(); function processData(obj) { if (processed.has(obj)) return; // 处理逻辑... processed.add(obj); }

这里用WeakSet的好处是:处理完的对象将来不再被使用、引用全部释放后,processed不会阻碍垃圾回收。如果用Set,这些对象会被长期记住,白白占着内存。

不过要注意,WeakMapWeakSet有个不方便的地方:它们不可遍历,也没有size属性。因为里面的条目可能随时被回收,API 设计上就不允许你去枚举它们。所以它们是“工具型”结构,适合内部辅助记录,不适合当业务数据的主要存储容器。

5. 三个真实场景串讲:组合拳才见真章

5.1 场景一:接口数据分组缓存

有一次做数据看板,后端一次性返回了全量销售记录,前端需要按不同的区域分组展示,并且后续要支持按“区域 + 日期”快速筛选。

用数组硬写的话,每切一次筛选条件,就要filter一遍全量数据。数据量一多,页面直接卡成 PPT。

我用Map做了一个分组缓存器:

const groupCache = new Map(); function getGroupedData(rows, groupKey) { // 缓存 key 由分组字段决定,避免重复分组计算 if (groupCache.has(groupKey)) { return groupCache.get(groupKey); } const grouped = new Map(); rows.forEach(row => { const key = row[groupKey]; if (!grouped.has(key)) { grouped.set(key, []); } grouped.get(key).push(row); }); groupCache.set(groupKey, grouped); return grouped; }

第一次调用时做全量分组,之后同样的分组条件直接命中缓存。再看板组件频繁切换筛选条件时,体感完全不一样。

这个场景的核心就是把Map的两个能力叠在一起:分组结果存储 + 按条件缓存。

5.2 场景二:权限管理里的 Set 应用

权限判断是最能体现Set优势的业务场景之一。

业务里有不同角色,每个角色对应一组可操作的按钮编码。判断用户能不能点某个按钮,传统做法是遍历权限数组,验证某个编码在不在里面。

我用Set存用户可见操作权限,判断时直接has

// 用户登录后,后端返回权限码列表 const permissionList = ['user:create', 'user:edit', 'user:delete']; // 转成 Set,查询效率从 O(n) 变 O(1) const permissionSet = new Set(permissionList); function canOperate(code) { return permissionSet.has(code); } // 模板里用 if (canOperate('user:delete')) { // 显示删除按钮 }

这套方案在小权限列表时看不出差别,但权限码一旦多起来,尤其是每次渲染要判断几十个按钮时,区别就出来了。

另外,多个角色合并权限时,Set的并集操作能直接利用起来:

function mergePermissions(roles) { const result = new Set(); roles.forEach(role => { role.permissions.forEach(p => result.add(p)); }); return result; }

因为Set天然去重,多角色权限合并后不会出现重复项,不需要额外做去重逻辑。这就是数据结构的“自带属性”帮你少写业务代码。

5.3 场景三:复杂状态联动里的 Map+Set 组合

去年我做了一个配置平台,左侧是组件树,右侧是配置面板,中间还要维护一条“依赖关系”。这个项目我把MapSet的组合用到了极致。

数据结构设计是这样的:

// 每个组件节点对应一个 Set,记录它依赖了哪些其他组件 const dependencyMap = new Map(); // 添加依赖 function addDependency(componentId, dependsOnId) { if (!dependencyMap.has(componentId)) { dependencyMap.set(componentId, new Set()); } dependencyMap.get(componentId).add(dependsOnId); } // 判断是否已依赖 function hasDependency(componentId, dependsOnId) { return dependencyMap.has(componentId) && dependencyMap.get(componentId).has(dependsOnId); } // 获取某个组件的全部依赖 function getDependencies(componentId) { return dependencyMap.get(componentId) || new Set(); }

Map管理“组件ID -> 依赖集合”的映射,用Set管理“一个组件的依赖集合”。增删查都是 O(1),而且语义非常清晰,你自己过一个月回来看代码也能秒懂。

如果在数组方案里做同样的功能,每次判断是否依赖都要两层遍历,而且要自己处理重复添加的问题。数据结构选对,复杂状态联动直接降维打击。

6. 前端老铁的常见误区与面试高频题

6.1 误区一:把 Map 和 Array.prototype.map 混为一谈

这是我在团队 Code Review 时见过最多的问题。很多刚接触 ES6 的同学,看到“Map”两个字就要愣一下:到底是数据结构还是数组方法?

强烈建议在团队内部统一说法:数据结构Map叫“映射集合”,数组方法map叫“映射遍历”。开会和写注释时都用全称,避免歧义。

另外注意,new Map()的构造参数是“可迭代的键值对列表”,比如二维数组或另一个Map

const map = new Map([ ['name', '张三'], ['age', 18], ]);

Array.prototype.map()接收一个回调函数,做的是遍历转换:

const numbers = [1, 2, 3].map(n => n * 2);

这俩连参数类型都不一样,一起出现时,你要能自动切换到不同的语义模式。

6.2 误区二:任何时候都用 Map/Set,反而更慢

说句公道话,MapSet不是银弹。在数据量极小(比如几个元素)时,数组和普通对象往往更快,因为它们底层是紧凑的线性存储,缓存友好度高,而且 JIT 引擎会对常规对象做深度优化。

还有一种场景别用Map:你需要直接对数据结构做JSON.stringify序列化。JSON.stringifyMap的支持很尴尬,默认输出{},你要先转成普通对象或数组:

const map = new Map([['name', '张三']]); const obj = Object.fromEntries(map); // 或直接转数组传后端 const arr = [...map];

如果你需要把数据存到localStorage、发给后端、或者和其他非前端系统对接,Map的反序列化往往需要额外处理,普通对象反而一路畅通。

所以,选择数据结构前先问自己三个问题:数据量有多大?操作频率多高?要不要序列化?想清楚了再决定用不用Map/Set

6.3 面试高频考点:Map/Set 面试题怎么答

前端面试里,MapSet基本是必问常规题。我整理几个最高频的问题和答题思路,你照着这个思路准备,基本覆盖面试官所有出题角度。

问题一:Map 和 Object 有什么区别?

答题框架:先把 key 类型、遍历顺序、size、增删性能、序列化五个维度列清楚,再强调“Map 的 key 可以是任意类型,Object 的 key 只能是字符串或 Symbol”,最后补一句“频繁增删用 Map,固定配置用 Object”。

问题二:Set 怎么实现数组去重?

直接给代码[...new Set(arr)],然后补充“如果是对象数组,需要根据唯一字段去重”,再给[...new Map(list.map(item => [item.id, item])).values()]的方案。面试官如果追问复杂度,你要能说出Set.has是 O(1),比includes的 O(n) 高效。

问题三:WeakMap 的 key 为什么必须是对象?

因为弱引用机制只对对象有意义,基本类型(字符串、数字)不存在“回收”的问题。你可以反问一句:如果有人用WeakMap,说明他关注内存管理,这样回答会加分。

问题四:能遍历 WeakMap 吗?为什么?

不能。因为WeakMap里的元素随时可能被垃圾回收,遍历出来的结果不稳定,所以 API 设计上只提供getsethasdelete,没有keys()values()forEach()这些遍历方法,也没有size属性。

我面试别人的时候,还喜欢加一个“陷阱题”:问Map和普通对象谁的key可以是undefined。其实两者都可以,但Map是真正把它当成一个独立 key,而Object会把undefined转成字符串"undefined"。能答出这个细微差别的,基本是对数据结构有真实理解的候选人。

写在最后

前端开发这个行当,入门靠 API,进阶靠数据结构意识。写业务代码时,多想想“我现在用的数组,到底适不适合这个场景”,你就已经赢过一半人了。

我个人现在写代码有个习惯:凡是出现findincludesindexOf的地方,先停下来想三秒——这里是不是应该用Map或者Set?这个习惯让我少踩了很多性能坑,也让代码的可读性和自解释性上了一层。

真香不真香,试一次就知道。老铁们赶紧去把项目里那些硬用数组的地方翻一翻,该换的数据结构换起来,早用早舒坦。

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

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

立即咨询