多用Map少用Object

JavaScript中的Object非常强大。它们可以做任何事情!确切地说……任何事情。

但是,就像所有事物一样,仅仅因为你可以做某事,并不意味着你(一定)应该这样做。

1
2
3
4
5
6
// 🚩
const mapOfThings = {}

mapOfThings[myThing.id] = myThing

delete mapOfThings[myThing.id]

例如,如果你在JavaScript中使用对象来存储任意键值对,并且你经常会添加和删除键,那么你真的应该考虑使用Map而不是普通对象。

1
2
3
4
5
6
// ✅
const mapOfThings = new Map()

mapOfThings.set(myThing.id, myThing)

mapOfThings.delete(myThing.id)

与对象相比,对象在性能上存在问题,其中删除操作符以性能低下而臭名昭著,而Map针对这种情况进行了优化,并且在某些情况下可以显著提高速度。

在此基准测试中,Map比对象快约5倍。

MDN本身澄清了Map针对频繁添加和删除键的此用例进行了专门优化,与对象相比,对象对于此用例并不那么优化:

MDN文档指出,与对象相比,Map在涉及频繁添加和删除键值对的情况下性能更好。

如果您好奇为什么,这与JavaScript虚拟机如何优化JS对象有关,它们假设了对象的形状,而Map是专门为哈希映射用例而构建的,其中键是动态的并且经常变化的。

但是除了性能外,Map还解决了对象存在的几个问题。

内置键问题

对象用于哈希映射类似用例的一个主要问题是,对象已经预先填充了大量键。什么?

1
2
3
4
5
6
7
8
9
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]

因此,如果您尝试访问这些属性中的任何一个,即使这个对象应该是空的,每个属性都已经有了值。

仅此就应该是不使用对象作为任意键哈希映射的明显理由之一,因为它可能导致一些你只能在后来发现的非常棘手的错误。

遍历的尴尬

说到JavaScript对象处理键的奇怪方式,遍历对象充满了陷阱。

例如,你可能已经知道不要这样做:

1
2
3
for (const key in myObject) {
// 🚩 你可能会碰到一些你不想要的继承的键
}

你可能被告知要这样做:

1
2
3
4
5
for (const key in myObject) {
if (myObject.hasOwnProperty(key)) {
// 🚩
}
}

但是这仍然有问题,因为myObject.hasOwnProperty可以很容易被覆盖为任何其他值。任何人都可以做myObject.hasOwnProperty = () => explode()

所以你真的应该做这个古怪的混乱:

1
2
3
4
5
for (const key in myObject) {
if (Object.prototype.hasOwnProperty.call(myObject, key)) {
// 😕
}
}

或者如果你喜欢你的代码不看起来像一团糟,你可以使用最近添加的Object.hasOwn

1
2
3
4
5
for (const key in myObject) {
if (Object.hasOwn(myObject, key)) {
// 😐
}
}

或者你可以完全放弃for循环,只使用Object.keysforEach

1
2
3
Object.keys(myObject).forEach(key => {
// 😬
})

然而,对于map,完全没有这样的问题。你可以使用标准的for循环,标准的迭代器,以及一个非常好的解构模式,一次性获取键和值:

1
2
3
for (const [key, value] of myMap) {
// 😍
}

我们现在有了一个Object.entries方法来使用对象做类似的事情。

1
2
3
for (const [key, value] of Object.entries(myObject)) {
// 🙂
}

将它添加到“对象中的循环很丑陋,所以请选择以下5个选项中的一个”的长列表中。

但对于Maps,很高兴知道有一种简单而优雅的方法可以直接迭代。

另外,您还可以仅遍历键或值:

1
2
3
4
5
6
7
for (const value of myMap.values()) {
// 🙂
}

for (const key of myMap.keys()) {
// 🙂
}

键排序

Maps的另一个额外好处是它们保留其键的顺序。这是长期以来要求对象的一个优质特性,现在Maps中也存在。

这给了我们另一个非常酷的功能,那就是我们可以直接从Maps中解构键,按照它们的确切顺序:

1
const [[firstKey, firstValue]] = myMap

这也可以打开一些有趣的用例,比如轻松实现O(1)LRU缓存:

Copying

现在你可能会说,哦,好吧,对象有一些优势,比如它们很容易复制,例如,使用对象展开或分配。

1
2
const copied = {...myObject}
const copied = Object.assign({}, myObject)

但事实证明,Maps也同样容易复制:

1
const copied = new Map(myMap)

这能够工作的原因是Map的构造函数接受一个[key, value]元组的可迭代对象。并且方便的是,Maps是可迭代的,产生它们的键和值的元组。很好。

同样地,您还可以做映射的深拷贝,就像您可以使用structuredClone做对象一样:

1
const deepCopy = structuredClone(myMap)

Maps转换为对象和对象转换为Maps

使用Object.fromEntries可以很容易地将Maps转换为对象:

1
const myObj = Object.fromEntries(myMap)

而反过来也很简单,使用Object.entries

1
const myMap = new Map(Object.entries(myObj))

现在我们知道这一点了,我们再也不需要使用元组构造映射:

1
const myMap = new Map([['key', 'value'], ['keyTwo', 'valueTwo']])

您可以像对象一样构造它们,这对我来说在视觉上更加舒服:

1
2
3
4
const myMap = new Map(Object.entries({
key: 'value',
keyTwo: 'valueTwo',
}))

或者您也可以制作一个方便的小助手:

1
2
3
const makeMap = (obj) => new Map(Object.entries(obj))

const myMap = makeMap({ key: 'value' })

或者使用TypeScript:

1
2
3
4
5
const makeMap = <V = unknown>(obj: Record<string, V>) => 
new Map<string, V>(Object.entries(obj))

const myMap = makeMap({ key: 'value' })
// => Map<string, string>

键类型

Maps不仅是在JavaScript中处理键值Maps的更符合人体工程学和性能更好的方式。它们甚至可以做一些只有使用普通对象根本无法完成的事情。

例如,Maps不限于只有字符串作为键 - 你可以使用**任何类型**的对象作为Maps的键。

1
2
3
4
5
6
7
myMap.set({}, value)
myMap.set([], value)
myMap.set(document.body, value)


myMap.set(function() {}, value)
myMap.set(myDog, value)

但是,为什么?

其中一个有用的用例是在不直接修改对象的情况下将元数据与对象关联起来。

1
2
3
4
5
6
7
8
const metadata = new Map()

metadata.set(myDomNode, {
internalId: '...'
})

metadata.get(myDomNode)
// => { internalId: '...' }

这可能很有用,例如,当您想要将临时状态与您从数据库中读取和写入的对象相关联时。您可以添加与对象引用直接关联的临时数据,而不会有风险。

1
2
3
4
5
6
7
8
const metadata = new Map()

metadata.set(myTodo, {
focused: true
})

metadata.get(myTodo)
// => { focused: true }

现在,当我们将myTodo保存回数据库时,只有我们想要保存的值存在,我们的临时状态(位于另一个映射中)不会被意外包含。

通常情况下,垃圾回收器会收集这个对象并将其从内存中删除。然而,由于我们的Maps持有一个引用,它将永远不会被垃圾回收,从而导致内存泄漏。

WeakMaps

这就是我们可以使用WeakMap类型的地方。WeakMap完美地解决了上述内存泄漏问题,因为它们对对象保持了弱引用。

因此,如果所有其他引用都被移除,对象将自动被垃圾回收并从该弱映射中删除。

1
2
3
4
5
6
const metadata = new WeakMap()

// ✅ 没有内存泄漏,当没有其他引用时,`myTodo`将自动从`Map`中移除
metadata.set(myTodo, {
focused: true
})

更多Map相关内容

在我们继续之前,还有一些有用的关于Map的事情需要知道:

1
2
3
4
map.clear() // 清空整个映射
map.size // 获取映射的大小
map.keys() // 所有映射键的迭代器
map.values() // 所有映射值的迭代器

Map有很好的方法。

集合

如果我们谈论Map,我们也应该提到它们的表兄弟,即集合(Sets),它们为我们提供了一种更高效的方法来创建唯一元素的列表,我们可以轻松地添加、删除和查找集合中是否包含一个项目:

1
2
3
4
5
const set = new Set([1, 2, 3])

set.add(3)
set.delete(4)
set.has(5)

在某些情况下,集合的性能比使用数组进行等效操作要好得多。

类似地,我们在JavaScript中还有一个WeakSet类,它也将帮助我们避免内存泄漏。

1
2
// 这里没有内存泄漏,上尉 🫡
const checkedTodos = new WeakSet([todo1, todo2, todo3])
序列化

现在你可能会说,普通对象和数组相对于映射和集合还有一个优势 —— 序列化。

JSON.stringify()/ JSON.parse() 对对象和映射的支持非常方便。

但是,你有没有注意到,当你想要漂亮地打印JSON时,你总是不得不添加一个null作为第二个参数?你知道这个参数到底是干什么的吗?

1
2
JSON.stringify(obj, null, 2)
// ^^^^ 这个参数是干嘛的

事实证明,这个参数对我们非常有帮助。它被称为replacer,它允许我们定义任何自定义类型应该如何被序列化。

我们可以利用这一点,轻松地将映射和集合转换为对象和数组进行序列化:

1
2
3
4
5
6
7
8
9
10
11
JSON.stringify(obj, (key, value) => {
// 将映射转换为普通对象
if (value instanceof Map) {
return Object.fromEntries(value)
}
// 将集合转换为数组
if (value instanceof Set) {
return Array.from(value)
}
return value
})

现在我们只需将此抽象为一个基本的可重用函数,并进行序列化即可。

1
2
3
4
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参数,当解析时将数组转换回集合,将对象转换回映射:

1
2
3
4
5
6
7
8
9
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树中的任何位置序列化和反序列化映射和集合。

但是,我们上面的序列化实现有一个小问题。

我们当前无法在解析时区分普通对象或数组与映射或集合,因此我们不能在我们的JSON中混合使用普通对象和映射,否则我们会得到这样的结果:

1
2
3
4
const obj = { hello: 'world' }
const str = JSON.stringify(obj, replacer)
const parsed = JSON.parse(obj, reviver)
// Map<string, string>

我们可以

通过创建一个特殊的属性来解决这个问题;例如,称为__type的属性,来表示何时应该是映射或集合,而不是普通对象或数组,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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序列化和反序列化。不错。

你应该在什么时候使用什么
对于具有明确定义键集的结构化对象 —— 比如每个事件都应该有一个标题和一个日期 —— 通常你会想要一个对象。

1
2
3
4
5
// 对于结构化对象,请使用Object
const event = {
title: 'Builder.io Conf',
date: new Date()
}

当你有任意数量的键,并且你可能需要频繁添加和删除键时,请考虑使用映射以获得更好的性能和人性化。

1
2
3
4
// 对于动态哈希映射,请使用Map
const eventsMap = new Map()
eventsMap.set(event.id, event)
eventsMap.delete(event.id)

当你创建一个数组时,其中元素的顺序很重要,并且你可能有意想要数组中的重复项时,一个普通的数组通常是一个很好的选择。

1
2
// 对于有序列表,或者可能需要重复项的列表,请使用Array
const myArray = [1, 2, 3, 2, 1]

但是,当你知道你永远不想要重复项,并且项目的顺序不重要时,请考虑使用集合。

1
2
// 对于无序唯一列表,请使用Set
const set = new Set([1, 2, 3])