先记住这个答案
本质上,普通对象的键必须是字符串或 Symbol,运行时会将其他类型自动转换;而 Map 的键可以是任何值,包括对象、函数和原始类型,且基于 SameValueZero 算法比较键的唯一性,不进行类型转换。Map 没有默认键,避免了原型链上的属性冲突,适合频繁增删键值对和需要对象作为键的场景。
- 对象键只能是字符串或 Symbol,其他类型自动转换
- Map 键可为任意值,无类型转换
- Map 无原型键,避免属性名冲突
键类型的规范与实现机制
普通对象的属性键在规范中限定为 String 或 Symbol。当使用数字、布尔值等作为属性名时,JavaScript 引擎会将其隐式转换为字符串。例如 obj[1] 实际创建了键为 '1' 的属性,这可能导致覆盖,比如 obj[1] 和 obj['1'] 指向同一位置。对象还继承了原型链上的属性,因此 obj['toString'] 可能返回函数,而非自有数据。
Map 在内部使用哈希表或搜索树存储键值对,键的类型不设限制。比较键的唯一性时,Map 采用 SameValueZero 算法:严格相等的基础上,将 NaN 视为相同,且不区分 +0 和 -0。对于对象键,比较的是引用,而非结构。因此,两个内容相同的对象是不同的键。这种设计使 Map 可以直接使用 DOM 节点、函数等作为键,而无需担心转换为字符串或原型链覆盖。
用 Map 缓存 DOM 节点的元数据
假设在 Web 应用中,需要为若干已存在的 DOM 元素附加状态,如点击次数。能否用对象实现?直接以元素为键不行,因为对象会将其转为字符串,导致所有元素都变成 '[object HTMLDivElement]',状态相互覆盖。使用 Object.create(null) 也无济于事,因为键类型限制仍在。
改用 Map 则简洁安全:const visits = new Map(); visits.set(button, count++); 按钮对象作为键被保留,后续通过元素引用即可 get 对应值。Map 不依赖 DOM 元素上自定义属性,避免了属性污染,且能够存储对象本身作为键。当元素被移除时,若不使用 WeakMap,需要手动清理,但 Map 的通用性足够。
Map 键的优势与陷阱边界
Map 的键虽然任意,但若使用对象作为键,必须持有原引用才能读取。例如 map.get({}) 会返回 undefined,因为每次创建的对象引用不同。对于原始类型,NaN 特殊处理:所有 NaN 均视为同一个键,若需区分不同 NaN,则不可能,因为 NaN 之间不可区分。
Map 不适合需要序列化或与 JSON 交互的场景,因为 JSON.stringify 会将 Map 序列化为空对象。若需要在对象中使用动态字符串键,普通对象反而更直接。此外,Map 的增删操作平均复杂度为次线性(如 O(1) 或 O(log N)),但若需按属性顺序遍历,Map 按插入顺序,对象键则有整数属性优先的复杂规则。
容易答错的地方
- 认为对象键可以是任意值
- 常见误解是给对象设置数字键没问题,但实际会被转换为字符串。例如
obj[1]和obj['1']等价。这会导致困惑,尤其是键为对象时变为[object Object]。 - 忽略原型链键冲突
- 普通对象默认继承 Object.prototype,所以
obj['toString']可能不是自己的数据。虽然可以用Object.create(null),但很少这么做,而 Map 默认没有这些干扰键。
面试官还会怎么问?
Map 与 WeakMap 在键类型和引用上有什么不同?
WeakMap 的键必须是对象,不能是原始值,且键为弱引用,不影响垃圾回收。Map 的键可为任意值,强引用阻止回收。WeakMap 不可遍历,无 size 属性,适合存储对象私有数据。
为什么对象键转换会导致原型链污染?
若键为字符串如 __proto__,赋值时可能触发原型链的 setter,例如 obj['__proto__'] = {...} 会改变对象的原型。Map 存储这类键则安全,因为不涉及原型链。
如何判断一个值是否适合作为 Map 的键?
Map 支持任何值,但要注意引用类型需变量引用一致。原始值可直接比较。若需按值比较对象,则需自行实现哈希,或用序列化字符串作为键。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。