很多前端同学写 JavaScript 的数据映射关系,第一反应就是丢一个普通对象{}上去。确实,键值对表达在绝大多数场景下够用了,能读能写能遍历,没什么特别别扭的地方。但一旦你遇到“拿对象当键”“保持插入顺序”“频繁增删还得知道长度”这类具体需求,普通对象就会开始露怯。ES6 里新增的 Map 集合类型,就是专门用来解决这些问题的标准方案。
这篇文章不打算按文档腔调把 API 挨个念一遍,我按自己实际项目里的使用经验来聊:先对比 Map 和 Object 的本质差异,再把 API 和常见用法过一遍,最后用一个购物车模块的完整案例把知识点串起来。适合已经能熟练写 ES5/ES6 基础语法、但还没系统用过 Map 的同学,也适合想把手头代码里的{}换成更严谨数据结构的开发者。
1. 先搞清楚 Map 和普通对象的核心差异
1.1 键的类型,决定了 Map 的不可替代性
普通对象的键,经过的是一个字符串化过程。哪怕你写obj[1] = 'a',这个键也会被转成字符串"1";写obj[true]也一样。更关键的是,如果你把一个对象当作键传给对象,它不会以对象身份被保存,而是被转成"[object Object]"这样的字符串。
这意味着,当你想用某个 DOM 节点、某个业务实例甚至某个函数去关联附加数据时,普通对象是做不到的。Map 则完全没有这个限制。它的键可以是任意的 JavaScript 值:对象、数组、函数、Symbol、NaN,都没问题。因为 Map 在比较键的时候,使用的是严格相等(===)语义,唯一特殊的是NaN会被认为等于自身(虽然NaN !== NaN),这一点在实际开发里反而很省心。
举一个我实际用过的例子:页面上有一组状态卡片,每张卡片对应一个对象实例,实例自身并不适合挂太多业务字段(比如要传给第三方库,对方会深拷贝并检查多余属性)。这时候用new Map(),把实例对象当键,把它的状态信息、缓存结果放在值里,读写都不需要额外遍历查找。这个场景换成普通对象根本写不了,因为对象键会被强制转字符串,所有实例都会塌缩成同一个"[object Object]"。
1.2 顺序、长度与遍历语义的区别
普通对象在 ES6 之后虽然也规定了属性顺序:整数键按升序排在最前面,字符串键按插入顺序跟在后面。这种“混合排序”在不少场景下会带来隐蔽的 bug。Map 则完全不同,它内部维护一个真正的有序结构,所有键值对严格按照插入顺序排列,先插的先被遍历到。如果你在做步骤记录、操作日志、配置表这类对顺序有硬性要求的映射结构,Map 会可靠得多。
另一个容易被忽略的差异是长度获取。普通对象要得到一个对象的键值对数量,得Object.keys(obj).length,还得先创建一个数组,既不直观也有额外开销。Map 直接用map.size,像一个真正的集合类型一样随时告诉你当前元素个数。而且这个是内部属性,读取成本极低。
遍历方式也不太一样。普通对象通常用for...in,但for...in会带着原型链上的可枚举属性一起跑,必须配合hasOwnProperty判断;用Object.keys也只能拿到键,再自己去取对应的值。Map 则直接实现了可迭代协议,你可以for...of遍历,也可以用forEach回调,还能分别拿到keys()、values()、entries()三种迭代器。语义更清晰,代码也更短。
1.3 性能侧的真实取舍
严格比性能的话,在现代 JavaScript 引擎里,普通对象经过大量的内联优化,单一属性读写往往非常快。Map 在底层实现上通常采用哈希表或类似结构,在频繁增删键值对、且键集合较大的场景下,整体表现往往更稳定。普通对象的形状一旦频繁变化,引擎的优化措施会失效,性能反而会波动。
但我不建议你因为在某个基准测试里看到差距就被带节奏。日常业务开发里,性能差异通常在数据量很小的时候根本感知不到。真正值得关注的是语义是否匹配:如果你要的就是一个以任意值为键的映射表,并且需要知道长度、需要安全遍历,Map 就是合理选择。为了这点性能差异去硬写对象加数组的复合结构,反而把代码搞复杂了。
2. 把 Map 的 API 和基本操作盘一遍
2.1 增删改查六件套,一个都不能少
Map 的核心操作非常集中,就是set、get、has、delete、clear、size这六个。下面这段代码把这几个 API 的基本用法全部覆盖了:
const map = new Map(); // 新增或更新 map.set('name', '张三'); map.set('age', 25); map.set('name', '李四'); // 同名键会覆盖,Map 里键是唯一的 console.log(map.size); // 2 // 读取 console.log(map.get('name')); // 李四 console.log(map.get('age')); // 25 // 判断键是否存在 console.log(map.has('age')); // true console.log(map.has('gender')); // false // 删除单个键 map.delete('age'); console.log(map.has('age')); // false console.log(map.size); // 1 // 清空所有键值 map.clear(); console.log(map.size); // 0这里有一个新手容易混淆的点:set方法返回的是 Map 对象本身,所以可以连写,比如map.set('a', 1).set('b', 2).set('c', 3)。我写代码的时候很喜欢这种链式风格,尤其是在固定配置表的时候,一眼看过去非常整齐。get在键不存在时返回undefined,这点和普通对象读取不存在的属性一样,但如果你想区分“键不存在”和“键存在但值确实是 undefined”,就必须用has来判断,两个方法的语义要拎清楚。
还有一个细节:delete会返回布尔值,告诉你这次删除是否真的删掉了某个键。如果删除一个不存在的键,它不会报错,只会返回false。这在做一些容错逻辑的时候很有用,不用先has再delete,两步合并成一步。
2.2 遍历:for...of 与 forEach 该怎么选
Map 遍历的手段不少,核心是三类:for...of直接遍历整个 Map、遍历迭代器、以及forEach回调。
const scores = new Map([ ['张三', 88], ['李四', 92], ['王五', 75], ]); // 方式一:直接 for...of 遍历整个 Map,每次得到 [key, value] for (const [key, value] of scores) { console.log(key, value); } // 方式二:遍历 keys() 迭代器 for (const key of scores.keys()) { console.log(key); } // 方式三:遍历 values() 迭代器 for (const value of scores.values()) { console.log(value); } // 方式四:遍历 entries() 迭代器,和方式一等价 for (const [key, value] of scores.entries()) { console.log(key, value); } // 方式五:forEach,注意参数顺序是 value 在前、key 在后 scores.forEach((value, key) => { console.log(key, value); });我个人的习惯是:如果只需要值,用for...of配合values();如果键值都要,直接解构[key, value];而forEach的写法最紧凑,但在回调里要注意它和数组的forEach不同,参数顺序是值在前、键在后,刚切换过来的人经常写反。另外一个实用技巧是:Map 的解构能力很强,配合数组的map、filter、reduce等函数,你可以非常方便地把它转换成其他结构再处理,这样就不用局限于 Map 自带的遍历方法。
2.3 和其他数据结构互转的常见套路
实际项目中,Map 经常需要在数组和普通对象之间来回转换,主要有这么几套固定转换公式:
// 二维数组转 Map const entries = [['name', '张三'], ['age', 25]]; const map1 = new Map(entries); // Map 转二维数组(使用展开运算符) const arrayFromMap = [...map1]; console.log(arrayFromMap); // [['name', '张三'], ['age', 25]] // Map 转普通对象(假设键都是字符串或数字) const obj = Object.fromEntries(map1); console.log(obj); // { name: '张三', age: 25 } // 普通对象转 Map const map2 = new Map(Object.entries({ name: '李四', age: 30 }));这里最常用的是Object.fromEntries,它是 ES2019 带来的方法,正好和Object.entries互为逆运算。有了这两个方法,Map 和普通对象之间的转换就是一行代码的事。不过在转换之前一定要确认键的类型:如果 Map 里的键是对象或函数,转成普通对象时会被强制字符串化,数据就丢了。转换不是万能的,得在语义合适的时候才用。
3. 四个见真章的使用场景
3.1 对象当键:给 DOM 节点绑定状态
普通对象做不到的“对象当键”,是 Map 最有价值的场景之一。特别是前端里经常出现“某个节点/组件实例需要关联一份不属于它的数据”这种需求。
举个例子:一个可拖拽的面板列表,每个面板拖拽后需要记录它的位置状态。你当然可以往面板对应的 DOM 对象上手动挂属性,比如panel.dataset.x = 100,但这样会把数据混在 DOM 上,不便于统一管理,也显得很粗暴。用 Map 就干净得多:
const positionMap = new Map(); function savePosition(panelElement, x, y) { positionMap.set(panelElement, { x, y }); } function getPosition(panelElement) { return positionMap.get(panelElement) || { x: 0, y: 0 }; }因为键是元素本身,取值的时候不需要额外查 id、不需要遍历数组,而且不污染 DOM。类似的应用还有:给列表项缓存展开状态、给不同的图表实例缓存配置项、给某个定时器 ID 关联超时回调。一旦你习惯用“对象当键”来思考,很多以前需要绕路的数据结构设计都会变得直接。
3.2 需要严格保持插入顺序的配置表
普通对象有一个让人头疼的特性:整数键会自动按升序排到最前面。比如你插入了{'3': 'c', '1': 'a', '2': 'b'},遍历的时候出来的顺序是1、2、3,而不是你插入的3、1、2。如果你需要一个严格按用户操作顺序排列的配置项集合,普通对象在键是数字的时候根本不靠谱。
Map 不存在这种问题,我项目的配置项模块就用 Map 来管理的。用户按顺序添加条件,页面按 Map 的插入顺序渲染,调整顺序时先删除再重新插入,展示顺序稳稳的和操作顺序一致。而且这种场景下size属性还能直接反映配置项数量,比Object.keys().length清晰很多。
const configMap = new Map(); configMap.set(1, '基础设置'); configMap.set(3, '高级配置'); configMap.set(2, '网络选项'); console.log([...configMap.keys()]); // [1, 3, 2],严格按插入顺序3.3 高频增删的数据映射,Map 带来的真实手感
如果你有一段逻辑需要高频地往集合里加数据、删数据,同时还要判断数据是否存在,Map 显然比对象更顺手。一个很典型的场景是管理一组设备的在线状态,或者统计一段文本里的词频。
词频统计如果用对象写,你得担心原型链上是不是有个toString之类的键导致判断出错;用 Map 就没这个烦恼,因为 Map 的键完全独立于原型链:
const words = ['apple', 'banana', 'apple', 'orange', 'banana', 'apple']; const countMap = new Map(); for (const word of words) { countMap.set(word, (countMap.get(word) || 0) + 1); } console.log(countMap.get('apple')); // 3 console.log(countMap.size); // 3这种“先取旧值,再加一,再放回去”的模式在 Map 上非常自然,读起来也不绕。换成对象的时候,如果词正好是constructor或__proto__,还会踩到原型链的坑,Map 直接帮你把这个雷拆了。
3.4 一健多值:Map 加 Set 的组合建模
Map 的值可以是任意类型,所以当我们需要建立“一个键对应多个值”的结构时,Map 和 Set 的组合非常顺手。比如一个班级里一个学生可能属于多个社团,用Map<student, Set<club>>来建模就非常清晰。
const studentClubs = new Map(); function addClub(student, club) { if (!studentClubs.has(student)) { studentClubs.set(student, new Set()); } studentClubs.get(student).add(club); } addClub('张三', '篮球社'); addClub('张三', '摄影社'); addClub('张三', '篮球社'); // Set 自动去重 console.log(studentClubs.get('张三')); // Set(2) { '篮球社', '摄影社' }这种嵌套结构的可读性远比{ studentName: { clubs: [...] } }要直观。而且因为键是学生对象本身而不是字符串,即使两个学生同名,只要引用不同,数据就不会串,这在实际业务里非常有价值。
4. 完整案例:用 Map 实现一个购物车模块
4.1 业务需求和设计思路
前面聊了那么多场景,现在用一个可运行的完整案例把它们串起来。假设我们要实现一个购物车模块,需求如下:
- 可以添加商品,同一件商品重复添加时数量自动累加
- 可以修改某件商品的数量
- 可以删除某件商品
- 可以获取购物车中的商品总数量和总价
- 推荐用商品对象作为键,直接关联数量和选中状态
为什么用商品对象而不是商品 id 作为键?因为在一个复杂的业务系统里,同一个商品可能在不同模块中被封装成不同的对象引用,但只要它来自同一个数据源、引用地址一致,用 Map 就能正确匹配。而且用对象作为键,你还可以在值里存额外的状态位(比如是否勾选),不用把业务字段硬塞到商品对象里。
数据模型定为:Map<product, { count: number, checked: boolean }>。
4.2 核心代码实现与讲解
class ShoppingCart { constructor() { // 键是商品对象,值是数量和选中状态 this.items = new Map(); } // 添加商品,重复添加时数量累加 add(product, count = 1) { if (this.items.has(product)) { const item = this.items.get(product); item.count += count; } else { this.items.set(product, { count, checked: true }); } return this; } // 修改指定商品的数量 update(product, count) { if (count <= 0) { this.remove(product); return; } if (this.items.has(product)) { this.items.get(product).count = count; } } // 删除商品 remove(product) { this.items.delete(product); } // 切换选中状态 toggleChecked(product) { const item = this.items.get(product); if (item) { item.checked = !item.checked; } } // 获取购物车内全部商品数量 getTotalCount() { let total = 0; for (const { count } of this.items.values()) { total += count; } return total; } // 获取选中商品的总价 getCheckedTotalPrice() { let total = 0; for (const product, { count, checked } of this.items) { if (checked) { total += product.price * count; } } return total; } // 清空购物车 clear() { this.items.clear(); } }几个值得强调的细节:
第一,add方法里用了链式返回this,这样调用方可以连续添加多个商品,代码更紧凑。
第二,update方法里做了数量边界的防御。数量小于等于 0 时直接调用remove,避免购物车里出现数量为 0 甚至负数的无效条目。
第三,遍历方式上,getTotalCount只关心值,所以用values();getCheckedTotalPrice需要同时拿商品对象和购物车条目,所以用for...of直接解构。注意我在遍历 Map 时解构的是[product, { count, checked }],这种解构方式比先取item再读属性更直接。
如果把它放到 React 或 Vue 的页面里,这个类可以非常自然地配合响应式状态管理使用,只要你把整个cart.items实例放到响应式容器里,Map 的set和delete方法同样能被框架的响应式系统追踪到,页面上购物车角标和总价就能自动更新。
4.3 如果换成普通对象实现,会踩到哪些坑
为了对比,我们把同样的购物车用普通对象写一遍,看看会碰见什么问题。
const cart = {}; function add(product) { const key = product.id; if (cart[key]) { cart[key].count += 1; } else { cart[key] = { product, count: 1 }; } }第一个问题是键的碰撞。如果用的是product.id作为键,id 是数字时,对象的键会被强制转成字符串,这倒不影响读取,但遇到 id 是"__proto__"或"constructor"这类字符串时,就会造成原型链污染或者取值异常。第二个问题是拿不到准确的条目数,需要Object.keys(cart).length额外计算。第三个问题是遍历顺序不一定是插入顺序,如果商品的 id 是数字且不是按照递增顺序加的,遍历时会发现它们全按 id 升序排了,界面显示完全不符合用户预期。
这些问题用 Map 一个都没有。键是商品对象本身,天然避开字符串键各种诡异分支;size直接给出有效条目数;遍历顺序严格按加入购物车的顺序来。虽然有经验的开发者可以用Object.create(null)去创建一个无原型对象来规避部分问题,但普通对象在语义上终究不是为“任意值键映射集合”这种需求设计的,选择合适的数据结构,比在错误的结构上打补丁要优雅得多。
5. 我踩过的坑和排查思路
5.1 JSON.stringify 直接丢掉了 Map 的形态
这是我第一次用 Map 时踩得最结实的一个坑。把new Map([['a', 1]])直接传给JSON.stringify,你会得到'{}',因为 Map 内部没有可枚举属性,默认的序列化逻辑拿不到任何东西。这意味着如果你要把 Map 数据存到 localStorage 或者发送到后端,直接序列化是行不通的。
我的习惯是定义一个辅助函数,在序列化前把 Map 转成数组,反序列化时再转回 Map:
function serializeMap(map) { return JSON.stringify([...map]); } function deserializeMap(str) { return new Map(JSON.parse(str)); } const originalMap = new Map([['a', 1], ['b', 2]]); const str = serializeMap(originalMap); const restoredMap = deserializeMap(str); console.log(restoredMap.get('a')); // 1如果 Map 里的值本身还有嵌套结构,建议在项目里统一封装序列化工具,而不是到处手动转换,这样维护成本更低。另外,ES6 规范里允许我们在 Map 上挂一个自定义的toJSON方法,这样直接JSON.stringify(map)就会自动调用它。但对团队项目来说,显式函数比隐式魔法更不容易引起误解。
5.2 引用类型作为键的相等性,必须用同一个引用
Map 对键的比较是基于严格相等的,意思是两个结构一模一样、但引用地址不同的对象,在 Map 眼里是两个完全不同的键。
const map = new Map(); map.set({ id: 1 }, '小明'); console.log(map.get({ id: 1 })); // undefined !这个坑特别隐蔽,尤其在从接口拿数据、每次都重新生成新对象的情况下。比如你往 Map 里存了一份配置,之后又用一个新的对象去读取,可能读到的永远都是undefined。所以在设计阶段就要想清楚:用对象作为键时,必须保证后续读写用的是同一个引用;如果做不到,就退回到用原始值(id、字符串)或者 Symbol 作为键。
5.3 Map 和 WeakMap 到底怎么选
很多时候我们都会纠结用 Map 还是 WeakMap。WeakMap 的键只能是对象,而且键是弱引用:当外部没有其他引用指向这个键对象时,它会被垃圾回收机制自动回收,对应的键值对也就没了。这个特性在缓存场景特别合适,因为你不用担心缓存对象一直霸占内存无法释放。但 WeakMap 也因此不可遍历、没有size、没有清空方法。
我的一般选择规则是:如果键是对象,并且希望随着对象销毁而自动清理数据,用 WeakMap;如果键是原始值,或者你需要遍历、需要知道数量,用 Map。比如前面购物车的例子,购物车本身生命周期很清晰,需要遍历,显然更适合 Map。而一个存放组件实例和临时数据的缓存,当组件卸载后不期望缓存残留,用 WeakMap 就更合适。
5.4 遍历过程中删除元素,最好先收集再处理
还有一个容易忽略的坑:在遍历 Map 的过程中直接删除某些键,是安全的,因为 Map 的迭代器会继续按照剩余元素访问;但如果你在遍历过程中新增元素,那就有可能在迭代器逻辑上引起不同引擎下行为不一致的隐患。为了保险起见,我习惯先把要删除的键收集到一个数组里,遍历结束后再统一删除。
const map = new Map([[1, 'a'], [2, 'b'], [3, 'c']]); const keysToDelete = []; for (const [key, value] of map) { if (value === 'a' || value === 'c') { keysToDelete.push(key); } } for (const key of keysToDelete) { map.delete(key); } console.log(map); // Map(1) { 2 => 'b' }这个模式看起来多写了一行,但它把“遍历”和“删除”两个阶段分开了,逻辑更明确,排查问题的时候也更省时间。
6. 一点个人的实践体会
要我说,Map 并不是来替代普通对象的,它更像是给开发者多了一个更精确的工具。我现在写代码有个习惯:先问自己“这个映射结构的键,到底是字符串还是任意值?我需要严格按插入顺序遍历吗?我需要时刻知道元素个数吗?”这三个问题一过,答案基本就清楚了。
踩过几次坑之后我越发觉得,JavaScript 的数据结构选择很大程度上决定着代码的可读性和稳定性。Map 解决的不只是 API 层面的问题,它把“映射”这个语义真正做了出来。尤其是对象当键的场景,一旦你用过,就很难再退回以前那种用字符串拼接 Key 的日子了。希望这篇文章能帮你把 Map 用得更顺手,遇到下一个需要映射关系的业务场景时,脑海里第一个浮现的不再是{},而是new Map()。最后再分享一个小技巧:打开浏览器的控制台,把文中的每个例子亲手敲一遍,哪怕只是把for...of换成forEach,你对 Map 遍历行为的体感都会完全不一样。