☰
JavaScript Map 与 Object 区别:键类型、原型链与选型
2026/11/3 15:01:45 网站建设 项目流程

“JavaScript Map 和 Object 的区别”这个话题,几乎每次技术面试都会被问,但我发现真正在代码里做对选择的人并不多。过去几年我参与排查过几次数据错乱的线上问题,往上追到根因,都是同一个问题:该用 Map 的地方用了普通对象,或者反过来。有的是数字 ID 被隐式转成字符串后撞了键,有的是拿对象当键结果全部变成了[object Object],还有的是delete用得太频繁导致对象性能骤降。这些问题在开发阶段几乎不会暴露,等到数据量上来才开始出问题,排查成本很高。

这篇内容会把这两个容器从键的语义、顺序规则、原型链、性能表现、序列化互操作到实际场景选型,一层层拆开讲。核心关键词是 JavaScript Map 和 Object 的区别,但我不会停在“Map 的键可以是任意类型”这种结论上,而是把每条差异背后的规范行为和工程后果都讲清楚,配上能直接跑的代码片段和实测观察。不管你是刚学完基础语法、还在纠结什么时候该用哪个的新手,还是写过几年代码、想把这些边角知识系统化一遍的老手,都能从中拿到可以直接抄用的判断标准和写法。文中所有场景都是虚构代称,讲的都是通用原理和可复现的操作。

1. 先把问题摆正:两个容器各自的定位

1.1 一次键名被隐式合并的数据错乱

先说个典型场景。某项目里有个订单模块,需要一个“订单号到订单对象”的索引,订单号在部分渠道返回的是数字,在另一部分渠道返回的是字符串。开发同学用了普通对象做索引:const index = {},然后index[orderId] = order。看起来没问题,直到有用户反馈“同一个订单出现了两次”。排查过程很朴素:把 index 打印出来,发现Object.keys(index).length比预期少了一大截。原因很简单,index[1001]和index["1001"]在对象里是同一个键,因为对象在设置键的时候会把数字转成字符串。两批来源不同的订单号,一个数字一个字符串,被合并成了同一条记录。

后面又出现过更隐蔽的一次:某同学想用“用户对象”作为键,缓存用户的权限计算结果,写的是cache[user] = perms。测试的时候只有一个用户,一切正常;上线后所有用户都返回了第一个用户的权限。打印Object.keys(cache),长度是 1,键名是"[object Object]"。对象作为键时会走toString转换,所有普通对象都转成同一个字符串,等于所有用户共用一个槽位。这两个例子背后是同一件事:Object 的键空间只有字符串和 Symbol 两种类型,Map 保留了键的原始身份。这就是两者最根本的分界线。

顺便说一句,这类问题最讨厌的地方在于它不会报错。JS 不会告诉你“这个键类型不合法”,它只会默默转换,然后给你一个错误的、看起来还挺合理的结果。所以选型的时候不能只看“能不能跑通”,得看“语义上是否安全”。

1.2 一张选型速查表,先给结论

在展开细节之前,先把结论摆出来,方便你对照自己手上的代码快速判断。这张表是后面所有讨论的浓缩版,每一条的“为什么”在后续章节会逐个展开。

维度ObjectMap
键的类型只能是 string 或 symbol,其他类型会被隐式转换任意类型,包括对象、函数、NaN
键的比较方式字符串相等SameValueZero(NaN 等于自身,+0 等于 -0)
元素个数需要Object.keys().length,O(n) 且产生临时数组size属性,O(1)
迭代方式for...in会带上原型链,需配hasOwn过滤for...of直接拿到[key, value],不会串到原型
属性顺序整数型键优先升序,其余按插入顺序严格按插入顺序
原型链干扰有,obj.toString等键名会命中原型无,键空间与实例属性空间隔离
JSON 序列化原生支持不支持,JSON.stringify得到{}
深拷贝structuredClone支持structuredClone支持,JSON 方案不支持
高频增删delete代价较高,可能触发对象结构退化相对稳定,专为动态增删设计
语法简洁度字面量、解构、可选链都很顺手需要new Map和get/set调用

从这张表能看出来,两者不是“新旧替代”的关系,而是面向不同使用场景的两套工具。对象更像是“结构化记录”,适合字段相对固定、需要和外部数据格式(比如 JSON 接口)对齐的场景;Map 更像是“动态字典”,适合键集合不确定、增删频繁、键类型复杂的场景。把这两者混为一谈,最容易在数据量增长之后付出代价。

注意:不要把“Map 比 Object 新”当成选型理由。ES6 引入 Map 是为了解决对象作为字典使用时的语义缺陷,不是为了淘汰对象字面量。用对象表示一条用户记录完全正确,用对象当缓存容器才是问题。

2. 键的语义差异:字符串化带来的隐性影响

2.1 Object 的键只有两种归宿

规范里把对象的属性键类型定义为PropertyKey,取值只有字符串和 Symbol 两种。你写obj[1]、obj[true]、obj[{a:1}],运行时会先把方括号里的表达式做一次ToPropertyKey转换:数字1变成字符串"1",布尔true变成"true",对象走ToPrimitive再走toString,普通对象结果是"[object Object]",数组[1,2,3]结果是"1,2,3"。

这个规则带来三个必须警惕的后果。第一,数字键和对应字符串键会被合并,前面订单号的例子就是它。第二,不同对象会被合并成同一个键,所有普通对象都转成"[object Object]",除非你给对象自定义了toString(但那又会引入新的不确定性)。第三,数组、日期、正则作为键时会得到看起来“能用”但语义混乱的字符串,比如new Date()做键会变成"Wed Jan 01 2025 ..."这样的长字符串,一旦时间精度变化,键就对不上了。

const o = {}; o[1] = 'number-key'; o['1'] = 'string-key'; console.log(Object.keys(o)); // ['1'] console.log(o[1]); // 'string-key' o[true] = 'bool'; console.log(o['true']); // 'bool' const a = { id: 1 }; const b = { id: 2 }; o[a] = 'from-a'; o[b] = 'from-b'; console.log(Object.keys(o).includes('[object Object]')); // true console.log(o[a]); // 'from-b',b 覆盖了 a

Symbol 是对象键的另一个合法选项,但它有自己的限制:Symbol 键不会出现在Object.keys、for...in、JSON.stringify的结果里,只能通过Object.getOwnPropertySymbols或者Reflect.ownKeys拿到。也就是说,用 Symbol 做键虽然避免了字符串碰撞,但也让这些键在常规遍历中被忽略,用之前得想清楚后续的数据流转是否依赖遍历。

2.2 Map 保留键的原始身份

Map 的键可以是什么类型都行,包括对象、函数、Symbol、NaN、undefined。它判断两个键是否相同用的是SameValueZero算法:NaN和NaN视为相等(这点和===不同),+0和-0视为相等(这点和Object.is不同),其余情况按严格相等处理,对象按引用比较。

const m = new Map(); m.set(NaN, 'not-a-number'); console.log(m.get(NaN)); // 'not-a-number',=== 在这里会失败 m.set(-0, 'zero'); console.log(m.get(0)); // 'zero',+0 和 -0 视为同一个键 const key1 = { id: 1 }; const key2 = { id: 1 }; m.set(key1, 'first'); m.set(key2, 'second'); console.log(m.size); // 2,两个不同引用是两个键 console.log(m.get(key1)); // 'first'

对象按引用比较这一点非常关键,它既是 Map 的优势也是它的坑。优势在于,你可以把 DOM 节点、实体对象、函数本身当作键,建立起真正的“对象到元信息”映射,没有序列化成本,也没有碰撞风险。坑在于,如果你不小心用了一个新建的、内容相同的对象去取值,是取不到的。

const m = new Map(); m.set({ id: 1 }, 'value'); console.log(m.get({ id: 1 })); // undefined,引用不同

这种错误在函数参数传递中特别容易发生。比如你写了一个setUserMeta(user, meta),内部map.set(user, meta),调用方后续拿到的user是另一个新对象(比如接口重新拉取后新建的),那map.get(newUser)必然拿不到。解决办法要么是确保全程传递同一个引用,要么改用稳定 ID 作为键。这个取舍后面在场景章节还会讲。

2.3 几个容易被忽略的边界行为

有几个边角行为在排查问题时经常让人困惑,单独列一下。第一,Map 的NaN键在get时能正常命中,而对象里obj[NaN]会被转成"NaN"字符串,虽然也能存,但语义完全不同。第二,-0和+0在 Map 里是同一个键,如果你需要区分正负零(极少见,比如某些数值计算场景),Map 做不到。第三,对象的__proto__键是个特殊存在,直接赋值会改变原型而不是新增属性,必须用Object.defineProperty或者Object.create(null)才能当成普通键用,而 Map 完全没有这个问题。

const o = {}; o['__proto__'] = { polluted: true }; console.log(o.polluted); // true,原型被改了 console.log(Object.keys(o)); // [],压根没新增属性 const safe = Object.create(null); safe['__proto__'] = 'just a key'; console.log(Object.keys(safe)); // ['__proto__']

这类行为不需要背下来,但要知道存在。写字典类代码时,只要键名可能来自外部输入(用户输入、接口返回的字段名、URL 参数),就必须假定它可能是__proto__、constructor、toString这些名字,这时候要么用Object.create(null),要么直接上 Map。

3. 顺序、长度与迭代:运行时的实际影响

3.1 Object 的属性顺序有三条优先级

很多人以为对象的属性就是按插入顺序遍历的,实际上规范里分了三个层级:整数索引键按数值升序排在最前面,然后是字符串键按插入顺序,最后是 Symbol 键按插入顺序。这里的“整数索引键”指的是可以转成 0 到 2³²-2 之间整数的字符串。

const o = {}; o.b = 1; o[2] = 2; o[1] = 3; o.a = 4; console.log(Object.keys(o)); // ['1', '2', 'b', 'a']

这个规则在需要保持“最近插入顺序”的场景下是致命的。比如你想记录用户最近访问的页面,用对象按访问时间插入,然后取Object.keys的前几个,结果数字型的页面 ID 全跑到前面去了,顺序完全乱掉。而 Map 的顺序规则非常单纯:严格按插入顺序,删除再重新插入会移动到末尾,set一个已存在的键只更新值、不改变位置。

const m = new Map([['b', 1], ['a', 2], ['1', 3], ['2', 4]]); console.log([...m.keys()]); // ['b', 'a', '1', '2'],完全按插入顺序 m.delete('b'); m.set('b', 5); console.log([...m.keys()]); // ['a', '1', '2', 'b'],重新插入排到最后

这个差异在做 LRU 缓存、最近使用列表、操作日志这类功能时非常关键。Map 天然支持“删了再插入等于移到末尾”这个语义,实现 LRU 只需要几行代码;用对象实现则要先删除再新增,而delete本身就有性能代价(下一章会讲)。

3.2 长度统计的成本差异很大

对象的“长度”需要现算:Object.keys(o).length会创建一个包含所有可枚举字符串键的数组,时间复杂度和空间复杂度都是 O(n)。如果在一个循环里反复取长度,成本会累积。Map 的size是一个访问器属性,引擎内部维护着计数,取值是 O(1)。

这个差异在小数据量下几乎感知不到,但如果你要判断“缓存是否已满”、在循环条件里写i < Object.keys(cache).length,那就等于每次迭代都做一次完整遍历,复杂度直接翻倍。这类写法在实际项目里我见过不止一次,改成先存一个变量就好了,但更好的办法是用 Map,从根上避免这个问题。

3.3 迭代过程中增删的表现不一样

Map 的迭代器是“活”的,它会按内部条目列表推进。规范规定:迭代过程中删除尚未访问的键,那个键不会被访问到;迭代过程中新增的键,会被访问到。这个行为在批量处理数据的场景里要特别小心。

const m = new Map([['a', 1], ['b', 2]]); for (const [k, v] of m) { if (k === 'a') { m.delete('b'); // b 还没被访问,删了就不会访问 m.set('c', 3); // c 是新增的,会被访问到 } console.log(k); // 输出 a、c }

对象的for...in行为在迭代期间修改属性时不那么可预期,而且它还会遍历原型链上的可枚举属性。想只遍历自身属性,得配Object.hasOwn(ES2022)或者hasOwnProperty.call做过滤。更推荐的做法是用Object.keys或Object.entries先取快照再遍历,这样在循环里增删属性不会影响本轮迭代。

const o = { a: 1, b: 2 }; for (const k of Object.keys(o)) { if (k === 'a') delete o.b; console.log(k); // 输出 a、b,因为遍历的是快照 }

这里有个使用习惯上的建议:用对象做数据容器时,遍历一律走Object.keys/Object.entries的快照,别直接用for...in。for...in的适用场景非常窄,基本只有在你确实需要连同继承属性一起遍历的时候才用,而那种需求在业务代码里几乎不存在。

4. 原型链与键空间:为什么“干净的字典”很重要

4.1 用普通对象做字典,总会带上原型

每个对象字面量都默认继承Object.prototype,上面挂着toString、valueOf、constructor、hasOwnProperty、isPrototypeOf等属性。这意味着两件事:访问不存在的键时,你可能拿到的不是undefined;使用in运算符判断键是否存在时,结果里包含继承属性。

const dict = {}; console.log(dict.toString); // [Function: toString],不是 undefined console.log('constructor' in dict); // true console.log(Object.hasOwn(dict, 'constructor')); // false

真正会带来业务 bug 的是计数场景。经典的词频统计写法:

const counts = {}; function count(word) { counts[word] = (counts[word] || 0) + 1; } count('constructor'); console.log(counts.constructor); // 一个函数拼上 1 得到的怪字符串

counts['constructor']初始拿到的是Object构造函数,它是 truthy,所以|| 0不会生效,直接进入字符串拼接,结果变成一个函数源码加1的怪字符串。如果后续还有+ 1的操作,还会继续在字符串上拼接。这类问题在字典键来自用户输入时(比如统计搜索词、标签、错误码)出现的概率不低,而且极难通过肉眼审查发现。

4.2 三种防御写法及其适用场景

第一种是Object.create(null),创建一个没有原型的对象,键空间彻底干净。

const dict = Object.create(null); dict['toString'] = 'safe'; console.log(dict.toString); // 'safe'

代价是这个对象没有hasOwnProperty、toString等方法,console.log时显示为[Object: null prototype],某些工具函数的兼容性也要注意(比如有些库内部会调用obj.hasOwnProperty,遇到无原型对象会抛错)。用之前建议先做一次兼容性确认。

第二种是用Object.hasOwn(obj, key)做存在性判断,替代key in obj和obj[key] !== undefined。Object.hasOwn是 ES2022 引入的静态方法,语义清晰,不用考虑原型链。

if (Object.hasOwn(dict, 'constructor')) { // 只有真正写入过的键才进来 }

第三种就是直接用 Map。Map 的键空间和实例方法完全隔离,map.set('size', 1)不会覆盖map.size,map.get('get')也不会拿到方法本身。

const m = new Map(); m.set('size', 'my value'); m.set('get', 'my get'); console.log(m.size); // 1 console.log(m.get('get')); // 'my get' console.log(typeof m.get); // 'function',方法还在

这种“数据空间与方法空间分离”的设计,是从根上消除了键名冲突的可能。如果你要处理的键来自外部输入,而你又不想每次都记着用Object.create(null),Map 是最省心的选择。

注意:Object.create(null)创建的对象在做JSON.stringify时是正常的,但在某些调试工具、日志序列化库、模板引擎里可能表现不同。生产代码里如果要传给第三方库,建议先转成普通对象或者用 Map 承载,避免在边界处踩坑。

4.3 Map 也有它自己的使用约束

Map 的键空间干净,但它不能直接用点语法访问,也不能参与对象解构、展开运算符,{...map}得到的是空对象。它对 JSON 不友好,JSON.stringify直接给{}。它没有Object.freeze那套不可变语义(Object.freeze对 Map 的内容无效,只能防住size之类的属性)。这些约束意味着,Map 不适合跨越模块边界作为数据载体,更适合在模块内部作为数据结构使用,对外输出时转成数组或普通对象。

5. 性能这件事:微基准会骗人,要看模式

5.1 复杂度表只是起点

从规范层面看,两者的平均查找复杂度都是 O(1),但常数因子和实际行为差别不小。对象在引擎里通常以隐藏类(hidden class)的形式存储,属性名固定、结构稳定的对象访问非常快,因为引擎可以把它编译成固定的内存偏移。一旦属性被delete,或者属性名动态变化幅度很大,对象会退化成“字典模式”,访问变成哈希查找,性能明显下降。

Map 的实现天生就是哈希表,增删查的路径比较稳定,不依赖“结构是否稳定”这个前提。所以粗线条的判断是:键集合基本固定、以读取为主的对象更快;键集合频繁变动、增删频繁的 Map 更稳。

操作模式更适合的容器原因
字段固定的记录,反复读取Object隐藏类优化,属性访问接近直接内存偏移
键名动态、数量增长快Map无隐藏类退化问题
频繁delete后重新插入Map对象delete容易触发字典模式
需要遍历全部条目Map迭代器无需创建中间数组
需要频繁取长度Mapsize是 O(1)
需要与 JSON 互转Object原生序列化支持

5.2 一个可以自己跑的微基准骨架

网上流传的“Map 比 Object 快 3 倍”之类的结论,绝大多数没有说明测试条件,直接照搬很容易得出相反结论。想验证自己场景下的表现,写个简单的基准就够了,但要注意几个陷阱:必须预热(让 JIT 完成编译)、必须有足够多的迭代次数、必须防止结果被优化掉(把结果累加后打印)、必须在同一台机器同一时间对比。

function benchmark(label, fn, rounds = 5) { const results = []; for (let r = 0; r < rounds; r++) { const start = performance.now(); const sink = fn(); const cost = performance.now() - start; results.push(cost); if (sink === undefined) throw new Error('防止优化'); } results.sort((a, b) => a - b); const median = results[Math.floor(results.length / 2)]; console.log(label, 'median:', median.toFixed(2), 'ms'); return median; } const N = 100000; benchmark('Object 写入', () => { const o = {}; for (let i = 0; i < N; i++) o['k' + i] = i; return o['k0'] + o['k' + (N - 1)]; }); benchmark('Map 写入', () => { const m = new Map(); for (let i = 0; i < N; i++) m.set('k' + i, i); return m.get('k0') + m.get('k' + (N - 1)); });

这个骨架只能说明“在你的机器上、这个数据规模下、这种操作模式中”的相对表现,不能作为通用结论。我实测下来的普遍规律是:小数据量(几十个键以内)纯读写,对象明显更快,内存占用也更低;数据量上万、以增删为主时,Map 的优势开始显现;delete密集的场景,Map 通常稳定得多。这些数字会随引擎版本变化,所以结论要落在“模式”上,而不是“倍数”上。

5.3 内存层面的一个观察

同样数量的键值对,Map 的内存占用通常高于对象,因为每个条目除了键值还有额外的内部结构开销。如果项目中存在大量小型、结构固定的映射(比如几十个常量项的映射表),用对象字面量更省内存,而且写法更直观。反之,如果映射的条目数会随业务数据增长到几万甚至几十万,那就别用对象硬扛了,内存和性能双输。

提示:delete obj.key在 V8 中会让对象从快速模式退化。如果你确实要频繁删除对象属性,可以用obj[key] = undefined或delete后重新赋值,也可以考虑整体替换成新对象。但更干净的做法是换用 Map,让引擎自己去处理这些细节。

6. 序列化、拷贝与互转:实战必备写法

6.1 JSON 不认识 Map

这是 Map 在实际项目里最常被投诉的一点。JSON.stringify(new Map([['a', 1]]))的结果是"{}",不是'{"a":1}'。因为 Map 的可枚举自有属性为空,序列化时拿不到任何内容。

const m = new Map([['a', 1], ['b', 2]]); console.log(JSON.stringify(m)); // '{}' console.log(JSON.stringify([...m])); // '[["a",1],["b",2]]' console.log(JSON.stringify(Object.fromEntries(m))); // '{"a":1,"b":2}'

三种输出对应三种用途。要保留键类型信息(比如键是对象),只能用数组形式;要生成人类可读或接口友好的 JSON,用Object.fromEntries。这里要注意,Object.fromEntries会把非字符串键强制转换,数字键变成字符串,对象键变成"[object Object]",所以只有在确认键是字符串类型时,转对象才是无损的。

反过来,从对象构造 Map 用new Map(Object.entries(obj))。同样有信息损失:Object.entries跳过 Symbol 键、跳过不可枚举属性、把数字键变成字符串。如果原对象上有 Symbol 键,需要额外用Object.getOwnPropertySymbols补一遍。

function objectToMap(obj) { const m = new Map(Object.entries(obj)); for (const sym of Object.getOwnPropertySymbols(obj)) { m.set(sym, obj[sym]); } return m; }

6.2 深拷贝用 structuredClone,别用 JSON 往返

JSON.parse(JSON.stringify(x))这个土办法流传很广,但它在遇到 Map、Set、Date、RegExp、undefined、函数、循环引用时都会出问题。Map 会被变成{},Date 会变成字符串,undefined会被丢掉。

structuredClone是相对较新的原生能力,支持 Map、Set、Date、RegExp、ArrayBuffer 等类型,也处理循环引用。它不能克隆函数和原型链信息,但对于纯数据场景已经够用。

const original = new Map([['a', { n: 1 }]]); const clone = structuredClone(original); clone.get('a').n = 999; console.log(original.get('a').n); // 1,深拷贝生效 console.log(clone instanceof Map); // true

如果你维护的代码需要兼容不支持structuredClone的环境,那就得自己写递归拷贝,或者用成熟的工具库。自写的时候别忘了处理 Map、Set、Date、循环引用这几种情况,网上很多“二十行深拷贝”的代码在这几项上都是漏的。

6.3 序列化为自定义格式

如果 Map 需要持久化到存储(本地缓存、配置文件),建议自定义序列化格式,明确标出类型,避免反序列化时猜。

function serializeMap(map) { return JSON.stringify({ __type: 'Map', entries: [...map.entries()].map(([k, v]) => [ typeof k === 'object' && k !== null ? { __ref: k.id } : k, v, ]), }); } function deserializeMap(text) { const raw = JSON.parse(text); if (raw.__type !== 'Map') throw new Error('格式不匹配'); return new Map(raw.entries); }

这段代码只处理了“对象键带 id”这一种情况,实际项目里要按自己的键类型设计。重点在于:一旦 Map 要跨越进程边界,就必须显式定义契约,不能指望运行时帮你推断。

7. 场景决策:把选择落到具体代码

7.1 配置表、常量映射、接口数据:选对象

这类数据的共同特征是键名在编码时已经确定,属于“结构化记录”而非“动态字典”。用对象字面量写出来直观、可读、支持解构和可选链,还能直接交给 JSON 序列化。

const STATUS_TEXT = { 1: '待处理', 2: '处理中', 3: '已完成', };

这里用数字字面量作键是合理的,因为键名固定且数量少,数字会被转成字符串也无所谓,取值时STATUS_TEXT[1]一样能命中。但如果这个映射的键来自后端动态返回、数量会增长,就该换成 Map,避免后续的键冲突和顺序问题。

判断标准很简单:键名是否在源码里写死。写死就用对象,不写死就考虑 Map。

7.2 缓存、计数器、索引、去重:选 Map

下面这几种需求,Map 几乎总是更稳妥的选择。

缓存:键可能是数字 ID、字符串、甚至对象引用。用 Map 可以避免类型合并,size取值 O(1),淘汰策略实现也方便。

class SimpleCache { constructor(limit = 100) { this.limit = limit; this.store = new Map(); } get(key) { if (!this.store.has(key)) return undefined; const value = this.store.get(key); this.store.delete(key); this.store.set(key, value); // 重新插入,移到末尾 return value; } set(key, value) { if (this.store.has(key)) this.store.delete(key); this.store.set(key, value); if (this.store.size > this.limit) { const oldest = this.store.keys().next().value; this.store.delete(oldest); } } }

这段 LRU 的核心就是 Map 的两个特性:keys()按插入顺序返回,删除后重新set会移到末尾。换成对象实现,顺序规则会被整数键打乱,delete也有性能代价。

计数器:避免了原型链上的constructor、toString之类的键名污染。

const counter = new Map(); function incr(key) { counter.set(key, (counter.get(key) ?? 0) + 1); }

去重:需要保留原始类型时,Set 更合适;需要附带元信息时用 Map。

const seen = new Map(); for (const item of items) { if (!seen.has(item.id)) { seen.set(item.id, { firstSeen: Date.now(), item }); } }

7.3 需要对象作键:Map 或 WeakMap

有些场景天然需要“对象到数据”的映射,比如给 DOM 节点挂状态、给实体对象挂计算缓存、给函数挂元信息。这类需求的键是引用类型,对象做不到,Map 是唯一选择。

const nodeState = new Map(); function markActive(node) { nodeState.set(node, { active: true, at: Date.now() }); }

但要注意,Map 对键是强引用,只要 Map 存在,键对象就不会被回收。如果键的生命周期短于 Map(比如 DOM 节点会被移除),就会出现内存泄漏。这种时候应该用 WeakMap:

const nodeState = new WeakMap(); function markActive(node) { nodeState.set(node, { active: true, at: Date.now() }); }

WeakMap 的键是弱引用,键对象被回收后对应条目自动消失,不会阻止垃圾回收。代价是它不可枚举、没有size、没有clear,只能get/set/has/delete。所以它只适合“附加元信息”这类单向查询场景,不能当成可遍历的容器用。

7.4 什么时候反而不该用 Map

有几个场景我见过用 Map 反而添乱。第一,需要频繁和 JSON 互转的数据结构,每次转换都要写样板代码,出错概率上升。第二,需要传给不熟悉 Map 的第三方库或模板引擎,兼容性问题会消耗大量调试时间。第三,键集合很小(十几个以内)且固定,用对象字面量可读性明显更好,代码审查时也更一目了然。

选型不是追求“更先进”,而是追求“更少出错”。一个只有五个固定字段的配置对象用 Map 写,除了让代码变长,没有任何收益。

8. 常见问题速查与避坑清单

8.1 症状、原因、处理对照表

实际排查中遇到的问题,大多能归到下面这几类。表格里的“原因”一栏是关键,理解了原因才能举一反三。

症状原因处理方式
对象字典的键数量比预期少数字键和字符串键被合并,或对象键都转成[object Object]改用 Map,或确保键统一为字符串
用不同对象取值取不到Map 的键按引用比较用稳定 ID 作键,或确保传递同一引用
遍历顺序和插入顺序不一致对象的整数索引键优先升序需要顺序语义时改用 Map
计数器结果变成奇怪字符串键名撞上constructor、toString等原型属性用Object.create(null)或 Map
JSON.stringify(map)得到{}Map 没有可枚举自有属性转成Object.fromEntries(map)或数组
'key' in obj判断总是 truein包含原型链属性改用Object.hasOwn
深度拷贝后 Map 变成普通对象用了 JSON 往返改用structuredClone或自定义拷贝
内存持续增长不释放Map 强引用键对象,键已无用但未删除改用 WeakMap,或显式delete
频繁delete后变慢对象退化为字典模式改用 Map,或整体替换对象
Map 展开成对象得到空对象{...map}只复制自有可枚举属性用Object.fromEntries(map)

8.2 几个我踩过的具体坑

坑一:用 Map 做缓存但忘了处理引用键。早期写过一个按 DOM 节点缓存样式的工具,用的是Map,节点从文档移除后没有清理,缓存一直增长。改成WeakMap之后问题消失,因为节点被回收时条目自动消失。这个教训是:只要键是对象,先问一句“这个对象什么时候会被回收”,如果答案是“可能比容器先消失”,那 WeakMap 才是对的。

坑二:用Object.keys判断有没有数据。有些地方为了省事,写if (Object.keys(obj).length > 0)来做判空,这在数据量大、调用频繁的路径上开销不小。更稳的写法是用Object.hasOwn配合一个已知必有的字段,或者干脆在整个模块里用 Map 承载,判空直接看size === 0。

坑三:从 Map 转对象时丢掉了非字符串键。有个模块内部用 Map 存数据,键是数字 ID,导出时用了Object.fromEntries,数字键全变成字符串。导回时再new Map(Object.entries(...)),键类型已经回不去了。后来在导出函数里加了注释说明“键会被字符串化”,并在导入侧加了断言,才把这个问题封住。跨越边界的转换一定要写清楚类型契约,这比写多少注释都管用。

坑四:在for...of里遍历 Map 的同时删除当前键。规范允许删除当前正在访问的键,迭代器会继续往后走,这个行为是安全的。但如果在遍历中调用了一个会重置整个 Map 的函数(比如某些框架的清理逻辑),迭代器就会失效或者跳过条目。我的习惯是:批量处理前先[...map]拿快照,遍历快照,需要修改时再操作原 Map,虽然多一次数组分配,但行为完全可预期。

坑五:以为Object.freeze能冻结 Map。Object.freeze(map)不会阻止map.set(),因为 Map 的内容不在自有属性上。需要不可变语义时,得自己包一层只读代理,或者约定好规则,别指望语言帮你兜底。

8.3 几条可以直接抄的写法

最后整理几个日常高频的小片段,都是我用得比较顺手的版本。

// 安全字典:键来自外部输入时优先用这个 const dict = new Map(); dict.set(key, value); // 对象转 Map,保留 Symbol 键 function toMap(obj) { const m = new Map(Object.entries(obj)); Object.getOwnPropertySymbols(obj).forEach(s => m.set(s, obj[s])); return m; } // Map 转对象,键必须是字符串才无损 function toObject(map) { const o = Object.create(null); for (const [k, v] of map) { if (typeof k !== 'string' && typeof k !== 'symbol') { throw new TypeError('键类型无法无损转为对象属性'); } o[k] = v; } return o; } // 按字段分组的通用写法,比 reduce 加对象更好读 function groupBy(list, keyFn) { const groups = new Map(); for (const item of list) { const k = keyFn(item); if (!groups.has(k)) groups.set(k, []); groups.get(k).push(item); } return groups; }

groupBy这个片段值得多说一句。用 Map 的版本,键可以是任意类型(数字、对象、布尔),分组逻辑不会被类型转换破坏;用对象的话,数字键会被转字符串,对象键会全部合并,需要额外做序列化。分组结果后续如果要渲染成列表,用[...groups]或groups.forEach都很直接,顺序也和遍历输入的顺序一致。这类“中间态数据结构”是我最推荐用 Map 的地方:它不直接对外暴露,不涉及序列化,纯粹在内存里做聚合,Map 的所有优势都能发挥,所有劣势(JSON 不友好、语法稍长)都不构成负担。

日常写代码时,我的判断顺序大概是这样:先看键名是否在源码里写死,是就用对象;再看键类型是否可能是非字符串,是就用 Map;接着看增删是否频繁、是否需要顺序语义,满足任一条就用 Map;最后看是否会跨越序列化边界,会就转回对象或数组。这个顺序过一遍,绝大多数场景的选型都不会错。

我个人在实际项目里的体会是,这两个容器的选择难点从来不在 API 上,API 就那么几个,翻文档五分钟就记住了。真正的难点在于你是否清楚自己手上的这份数据“将来会怎么变”——键会不会变成动态的、条数会不会涨到几万、会不会有人在循环里删它、会不会被丢进 JSON。想清楚这三个问题,选型就是水到渠成的事;想不清楚,那就先用 Map,它对动态场景的容忍度更高,等到确认数据是固定结构了再换成对象也不迟,改动量通常很小。

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

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

立即咨询