JavaScript 中的对象很棒。它们可以做任何事情!万事万物皆为对象。〔^ǒ^〕 但是,就像所有事物一样,仅仅因为你可以做某事,并不意味着你应该(一定)这样做。
javascript
const mapOfThings = {}
mapOfThings[myThing.id] = myThing
delete mapOfThings[myThing.id]
例如,如果你在 JavaScript 中使用对象来存储任意键值对,并且会频繁添加和移除键,那么你真的应该考虑使用 Map
而不是普通对象。
javascript
const mapOfThings = new Map()
mapOfThings.set(myThing.id, myThing)
mapOfThings.delete(myThing.id)
对象的性能问题
对象中的删除操作符以性能差而闻名,而 Map
则针对这种情况进行了优化,在某些情况下可以显著提高速度。
当然,这只是一个示例基准测试(在 Core i7 MBP 上使用 Chrome v109 运行)。像这样的微基准测试通常并不完美,所以要保持审慎。
话虽如此,你无需相信我的或任何其他人的基准测试结果,因为 MDN 自身澄清了,与对象相比,Map 是专门针对频繁添加和移除键的这种使用情况进行了优化,而对象则没有针对这种使用情况进行优化:
如果你想知道为什么,这与 JavaScript 虚拟机如何通过假设对象的形状来优化 JS 对象有关,而 Map 则是专门为哈希映射的用例而构建的,其中键是动态的且不断变化的。
一篇很棒的文章是《单态化有什么问题》,它解释了 JavaScript 中对象的性能特性,以及为什么它们不太适用于频繁添加和移除键的哈希映射样式用例。
但除了性能之外,Map 还解决了对象存在的几个问题。
内置键问题
对象在类似哈希映射的使用情况下的一个主要问题是,对象内置了大量的键。
javascript
const myMap = {}
myMap.valueOf // => [Function: valueOf]
myMap.toString // => [Function: toString]
myMap.hasOwnProperty // => [Function: hasOwnProperty]
myMap.isPrototypeOf // => [Function: isPrototypeOf]
myMap.propertyIsEnumerable // => [Function: propertyIsEnumerable]
myMap.toLocaleString // => [Function: toLocaleString]
myMap.constructor // => [Function: Object]
因此,如果你尝试访问这些属性中的任何一个,即使这个对象应该是空的,它们每一个都已经有了值。
这一点单独就足以成为不将对象用于任意键的哈希映射的明确理由,因为它可能会导致一些非常棘手的 bug,而这些 bug 你只能在以后才会发现。
遍历的尴尬
说到 JavaScript 对象处理键的奇怪方式,遍历对象充满了坑。
例如,你可能已经知道不要这样做:
javascript
for (const key in myObject) {
// TODO
}
而你可能被告知应该这样做:
javascript
for (const key in myObject) {
if (myObject.hasOwnProperty(key)) {
// TODO
}
}
但这仍然有问题,因为 myObject.hasOwnProperty
可以很容易被任何其他值覆盖。没有任何东西能阻止任何人执行 myObject.hasOwnProperty = () => explode()
。
所以,你真的应该做这些乱七八糟的事情:
javascript
for (const key in myObject) {
if (Object.prototype.hasOwnProperty.call(myObject, key) {
// 😕
}
}
或者如果你不希望你的代码看起来很混乱,你可以使用较新添加的 Object.hasOwn
方法:
javascript
for (const key in myObject) {
if (Object.hasOwn(myObject, key) {
// 😐
}
}
或者你可以完全放弃使用 for
循环,而只是使用 Object.keys
结合 forEach
方法。
javascript
Object.keys(myObject).forEach(key => {
// 😬
})
然而,对于 Map
,根本不存在这样的问题。你可以使用标准的 for 循环,配合标准的迭代器,以及一个非常好的解构模式,一次性获取键和值:
javascript
for (const [key, value] of myMap) {
// 😍
}
事实上,这样做非常好,我们现在有了 Object.entries
方法,可以对对象进行类似操作。虽然多了一个步骤,感觉不是那么一流,但它还是能用的。
javascript
for (const [key, value] of Object.entries(myObject)) {
// 🙂
}
在 对象中的循环很丑陋,但对于 Map
来说,可以简单而优雅的内置方法直接迭代。
此外,你还可以只迭代键或值:
javascript
for (const value of myMap.values()) {
// 🙂
}
for (const key of myMap.keys()) {
// 🙂
}
键的排序
Map
的另一个额外好处是它们保留了键的顺序。这是对象长期以来一直期望的特性,现在在 Map
中也存在了。 这给了我们另一个非常酷的功能,就是我们可以直接从 Map 中按照它们的确切顺序解构键:
javascript
const [[firstKey, firstValue]] = myMap
这还能开辟一些有趣的用例,比如实现 O(1) LRU Cache(最少使用算法):
javascript
class LRUCache {
constructor(capacity) {
// capacity 是一个数字,表示缓存的最大容量
this.capacity = capacity
this.map = new Map()
}
get(key) {
if (this.map.has(key)) {
const value = this.map.get(key)
this.map.delete(key)
this.map.set(key, value)
return value
}
}
set(key, value) {
if (this.map.has(key)) {
this.map.delete(key)
} else if (this.map.size >= this.capacity) {
this.map.delete(this.map.keys().next().value)
}
this.map.set(key, value)
}
}
复制
现在你可能会说,噢,对象也有一些优点,比如它们很容易复制,例如,可以使用对象展开或 assign
方法。
javascript
const copied = {...myObject}
const copied = Object.assign({}, myObject)
但事实证明,Map 同样很容易复制:
javascript
const copied = new Map(myMap)
这个方法之所以有效,是因为 Map
的构造函数接受一个包含 [key
,value
] 元组的可迭代对象。而恰巧 Map
是可迭代的,产生的是其键和值的元组。Nice~ 。
类似地,你也可以像对对象一样对 Map 进行深拷贝,使用 structuredClone
方法:
javascript
const deepCopy = structuredClone(myMap)
将 Map 与 对象的相互转换
使用 Object.fromEntries
可以轻松地将 Map 转换为对象:
javascript
const myObj = Object.fromEntries(myMap)
而将对象转换为 Map 也同样简单,使用 Object.entries
:
javascript
const myMap = new Map(Object.entries(myObj))
而且,现在我们知道了这一点,我们再也不必使用元组构造 Map
了:
javascript
const myMap = new Map([['key', 'value'], ['keyTwo', 'valueTwo']])
相反,你可以像构造对象一样构造它们,这对我来说在视觉上更加舒适:
javascript
const myMap = new Map(Object.entries({
key: 'value',
keyTwo: 'valueTwo',
}))
或者你也可以创建一个便捷的小助手函数:
javascript
const makeMap = (obj) => new Map(Object.entries(obj))
const myMap = makeMap({ key: 'value' })
或者使用 TypeScript:
typescript
const makeMap = <V = unknown>(obj: Record<string, V>) =>
new Map<string, V>(Object.entries(obj))
const myMap = makeMap({ key: 'value' })
// => Map<string, string>
键类型
Map
不仅是在 JavaScript 中处理键值映射更符合使用体验且
性能更好的方式。它们甚至可以做一些纯对象无法完成的事情。
例如,Map
不仅限于只能使用字符串作为键 --- 你可以使用任何类型的对象作为 Map 的键。我是说,任何类型。
javascript
myMap.set({}, value)
myMap.set([], value)
myMap.set(document.body, value)
myMap.set(function() {}, value)
myMap.set(myDog, value)
这样有什么意义呢? 这种做法的一个有用的用例是将元数据与一个对象关联起来,而无需直接修改该对象。
javascript
const metadata = new Map()
metadata.set(myDomNode, {
internalId: '...'
})
metadata.get(myDomNode)
// => { internalId: '...' }
这在一些情况下非常有用,比如当你想将临时状态与从数据库中读取和写入的对象关联起来时。你可以添加与对象引用直接关联的临时数据,而不会有任何风险。
javascript
const metadata = new Map()
metadata.set(myTodo, {
focused: true
})
metadata.get(myTodo)
// => { focused: true }
现在,当我们将 myTodo 保存回数据库时,只有我们想要保存的值存在,我们的临时状态(存储在一个单独的 Map
中)不会被意外地包含进去。
但是,这确实存在一个问题。
通常情况下,垃圾回收器会收集这个对象并将其从内存中删除。然而,由于我们的 Map
保持了引用,它永远不会被垃圾回收,导致内存泄漏。
WeakMaps
在这里,我们可以使用 WeakMap
类型。WeakMap
完美解决了上述的内存泄漏问题,因为它们对对象持有弱引用。
因此,如果所有其他引用被移除,对象将自动被垃圾回收并从这个 WeakMap 中移除。
javascript
const metadata = new WeakMap()
// ✅ No memory leak, myTodo will be removed from the map
// automatically when there are no other references
metadata.set(myTodo, {
focused: true
})
更多关于 Map 的内容
在我们继续之前,还有几件关于 Map 的有用的事情需要知道:
javascript
map.clear() // Clear a map entirely
map.size // Get the size of the map
map.keys() // Iterator of all map keys
map.values() // Iterator of all map values
Map 有很好用的方法。让我们继续。
Set
如果我们在谈论 Map,我们也应该提到它们的表兄弟 Set,它们提供了一种性能更好的方法来创建一个独特的元素列表,在这个列表中我们可以轻松地添加、删除,并查找是否包含某个元素:
javascript
const set = new Set([1, 2, 3])
set.add(3)
set.delete(4)
set.has(5)
在某些情况下,与数组相比,使用集合可以获得显着更好的性能。
微基准测试并不完美,请在实际条件下测试你自己的代码,以验证你是否能从中受益。
同样,JavaScript 中的 WeakSet
类也能帮助我们避免内存泄露。
javascript
// No memory leaks here, captain 🫡
const checkedTodos = new WeakSet([todo1, todo2, todo3])
序列化
现在你可能会说,与Map
和Set
相比,普通对象和数组还有最后一个优势--序列化。
事实上,JSON.stringify()/ JSON.parse() 对对象和 Map
的支持非常方便。
但是,你有没有注意到,当你想漂亮地打印 JSON 时,总是要在第二个参数中添加一个空值?你知道这个参数有什么作用吗?
javascript
JSON.stringify(obj, null, 2)
事实证明,这个参数对我们非常有帮助。它被称为 replacer
,它允许我们定义任何自定义类型应该如何序列化。
我们可以利用这一点,轻松地将 Map 和 Set 转换为对象和数组进行序列化:
javascript
JSON.stringify(obj, (key, value) => {
// Convert maps to plain objects
if (value instanceof Map) {
return Object.fromEntries(value)
}
// Convert sets to arrays
if (value instanceof Set) {
return Array.from(value)
}
return value
})
现在我们可以将这个基本可重复使用的函数抽象出来,然后进行序列化。
javascript
const test = { set: new Set([1, 2, 3]), map: new Map([["key", "value"]]) }
JSON.stringify(test, replacer)
// => { set: [1, 2, 3], map: { key: value } }
对于反向转换,我们可以使用相同的技巧,使用 JSON.parse()
,但是通过使用它的 reviver
参数,来在解析时将数组转换回 Set
,将对象转换回 Map
:
javascript
JSON.parse(string, (key, value) => {
if (Array.isArray(value)) {
return new Set(value)
}
if (value && typeof value === 'object') {
return new Map(Object.entries(value))
}
return value
})
还要注意,replacer
和 reviver
都可以递归工作,因此它们能够在 JSON 树的任何位置序列化和反序列化 Map
和 Set
。
但是,我们上面的序列化实现还有一个小问题。
我们目前在解析时没有区分普通对象或数组与 Map
或 Set
,所以我们不能在我们的 JSON 中混合使用普通对象和 Map
,否则我们会得到这样的结果:
javascript
const obj = { hello: 'world' }
const str = JSON.stringify(obj, replacer)
const parsed = JSON.parse(obj, reviver)
// Map<string, string>
我们可以通过创建一个特殊的属性来解决这个问题;例如,称为 __type
,来表示何时应该是一个Map
或 Set
,而不是普通对象或数组,像这样:
javascript
function replacer(key, value) {
if (value instanceof Map) {
return { __type: 'Map', value: Object.fromEntries(value) }
}
if (value instanceof Set) {
return { __type: 'Set', value: Array.from(value) }
}
return value
}
function reviver(key, value) {
if (value?.__type === 'Set') {
return new Set(value.value)
}
if (value?.__type === 'Map') {
return new Map(Object.entries(value.value))
}
return value
}
const obj = { set: new Set([1, 2]), map: new Map([['key', 'value']]) }
const str = JSON.stringify(obj, replacer)
const newObj = JSON.parse(str, reviver)
// { set: new Set([1, 2]), map: new Map([['key', 'value']]) }
现在我们完全支持将集合和映射序列化和反序列化为 JSON。
何时使用
对于具有明确定义的一组键的结构化对象,例如每个事件都应该有一个标题和一个日期,通常你会使用对象。
javascript
// For structured objects, use Object
const event = {
title: 'Builder.io Conf',
date: new Date()
}
当你有一组固定的键时,对象非常优化,可以快速读取和写入。
当你可以有任意数量的键,并且可能需要频繁添加和删除键时,请考虑使用 Map
,以获得更好的性能和使用体验。
javascript
// For dynamic hashmaps, use Map
const eventsMap = new Map()
eventsMap.set(event.id, event)
eventsMap.delete(event.id)
当创建一个数组时,其中元素的顺序很重要,并且你可能有意想要在数组中保留重复的元素时,普通数组通常是一个很好的选择。
javascript
// For ordered lists, or those that may need duplicate items, use Array
const myArray = [1, 2, 3, 2, 1]
但是,当你知道你永远不想要重复的元素,并且项目的顺序不重要时,请考虑使用Set
。
javascript
// For unordered unique lists, use Set
const set = new Set([1, 2, 3])