JavaScript 的对象非常棒,它几乎可以做任何事。
但是,因为可以,不意味着必须。
const mapOfThings = {}
mapOfThings[myThing.id] = myThing
delete mapOfThings[myThing.id]
例如, 如果使用对象来存储经常增删的任意键值对,`Map` 是更好的选择。
const mapOfThings = new Map()
mapOfThings.set(myThing.id, myThing)
mapOfThings.delete(myThing.id)
## 对象的性能问题
而对于对象,删除操作符的性能很差,`Map` 针对这种情况进行了优化,在某些情况下甚至会更快。
如果你想知道为什么,这与JavaScript虚拟机如何通过假想JS对象的形状来优化它们有关,而 `map` 是专门为 `hashmap` 的用例构建的,其中键是动态的和不断变化的。
除了性能外,`Map` 同时也解决了对象存在的一些问题。
## 内置 `Key` 问题
最主要的问题是对象预设了大量的内置 Key。
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]
尽管是个空对象,你依然可以访问这些属性,每个还都有值。
仅凭这一点就可以清楚地说明不应该将对象用于任意键值的 `hashmap`,因为它可能会导致一些非常棘手的 `bug`。
## 迭代尴尬
说到 `JavaScript` 对象处理键的奇怪方式,遍历对象充满了陷阱。
例如,你应该已经知道不要这样做:
for (const key in myObject) {
// 你可能会无意中发现一些继承的键
}
然后,有人可能告诉你可以这样:
for (const key in myObject) {
if (myObject.hasOwnProperty(key)) {
// 🚩
}
}
但这仍然是一个问题, `myObject.hasOwnProperty` 可以轻松的被重置成任意值。
没有什么都防止谁去做 `myObject.hasOwnProperty = () => explode()`
所以你应该好好收拾一下:
for (const key in myObject) {
if (Object.prototype.hasOwnProperty.call(myObject, key) {
// 😕
}
}
或者你不想代码看起来太乱,你可以用最近心中的 `Object.hasOwn`:
for (const key in myObject) {
if (Object.hasOwn(myObject, key) {
// 😐
}
}
或者干脆放弃使用 `for` 循环,直接使用 `Object.keys` 和 `forEach`.
Object.keys(myObject).forEach(key => {
// 😬
})
然而,有了 `Map`,就没有这样的问题了。你可以使用一个标准的 `for` 循环来一次性获得键和值:
for (const [key, value] of myMap) {
// 😍
}
事实上,这样也是可以的, 只是多一个步骤。
for (const [key, value] of Object.entries(myObject)) {
// 🙂
}
但是使用 `Map`, 可以直接简单优雅的迭代。甚至单独的迭代 `Keys` 或者 `Values`.
for (const value of myMap.values()) {
// 🙂
}
for (const key of myMap.keys()) {
// 🙂
}
## Key 排序
`Map` 的另一个好处是它们保持了 `Key` 的顺序。
这给了我们另一个非常酷的功能,我们可以直接从 `Map` 中按键的确切顺序解构键:
const [[firstKey, firstValue]] = myMap
也可以来实现一些有趣的应用场景,比如,简单的 LRU Cache。
## 复制
你可能想,对象有一些高级功能,必须它容易实现复制,比如对象展开或者 assign.
const copied = {...myObject}
const copied = Object.assign({}, myObject)
`Map` 也一样容易复制:
const copied = new Map(myMap)
原因是 `Map` 的构造函数接受迭代的的 [key, value] 元组。而且方便的是,map是可迭代的,生成键和值的元组。
同样,`Map` 同意可以像对象一样使用 `structuredClone` 来复制。
const deepCopy = structuredClone(myMap)
## Map 和 对象相互转换
转换`Map` 成对象:
const myObj = Object.fromEntries(myMap)
相反的,可以使用`Object.entries`:
const myMap = new Map(Object.entries(myObj))
现在,我们知道不仅仅可以通过元组来构造 `Map`
const myMap = new Map([['key', 'value'], ['keyTwo', 'valueTwo']])
也可以使用对象来构造:
const myMap = new Map(Object.entries({
key: 'value',
keyTwo: 'valueTwo',
}))
或者可以做一个帮助函数:
const makeMap = (obj) => new Map(Object.entries(obj))
const myMap = makeMap({ key: 'value' })
结合 `TypeScript`:
const makeMap = <V = unknown>(obj: Record<string, V>) => new Map<string, V>(Object.entries(obj))
const myMap = makeMap({ key: 'value' })
// => Map<string, string>
## Key 的类型
`Map` 不仅仅是`JavaScript`中处理键值映射的一种更符合人体工程学和性能更好的方式。它们甚至可以做一些普通对象根本无法完成的事情。
例如,`Map` 并不局限于只使用字符串作为键, 你可以使用任何类型的对象作为 `Map` 的键。
myMap.set({}, value)
myMap.set([], value)
myMap.set(document.body, value)
myMap.set(function() {}, value)
myMap.set(myDog, value)
一个有用的用例是将元数据与对象关联,而无需直接修改该对象。
const metadata = new Map()
metadata.set(myDomNode, {
internalId: '...'
})
metadata.get(myDomNode)
// => { internalId: '...' }
例如,当您想将临时状态关联到从数据库中读写的对象时,这可能很有用。你可以添加尽可能多的与对象引用直接相关的临时数据,没有风险。
const metadata = new Map()
metadata.set(myTodo, {
focused: true
})
metadata.get(myTodo)
// => { focused: true }
现在,当我们将`myTodo`保存回数据库时,只有我们想保存的值在那里,并且我们的临时状态(在一个单独的`map`中)不会意外包含进来。
但这确实有一个问题。
通常情况下,垃圾收集器会收集这个对象并将其从内存中删除。然而,因为我们的`Map`持有一个引用,它永远不会被垃圾回收,从而导致内存泄漏。
## WeakMaps
这儿我们可以使用`WeakMap`类型,WeakMap 完美的解决了上面内存泄漏, 因为它们持有对对象的弱引用。
如果所有其他引用都被删除,该对象将自动被垃圾回收并从这个弱映射中删除。
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
})
## Moar map stuff
在我们继续之前,还有一些关于`Map`的有用知识需要了解:
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
## Sets
如果说到 `Map`,我们还应该提到它的表亲集合(Sets),集合为我们提供了一种性能更好的方法来创建一个唯一的元素列表,我们可以在其中轻松添加、删除以及查找集合中是否包含某个元素:
const set = new Set([1, 2, 3])
set.add(3)
set.delete(4)
set.has(5)
在某些情况下,使用集合(Sets)的性能比使用数组的性能要好得多。
类似地,我们使用 `WeakSet` 类来帮助我们避免内存泄漏。
// No memory leaks here, captain 🫡
const checkedTodos = new WeakSet([todo1, todo2, todo3])
## 序列化 Serialization
现在你可能会说,普通对象和数组相对于`map`和`set`还有最后一个优势——序列化。
是的, `JSON.stringify()` / `JSON.parse()` 支持对象和 `Map`.
但是,你有没有注意到,当你想漂亮地打印`JSON`时,总是必须添加一个`null`作为第二个参数?你知道这个参数有什么作用吗?
JSON.stringify(obj, null, 2)
// ^^^^ what dis do
这个参数对我们很有帮助。它被称为`replacer`,它允许我们定义如何序列化任何自定义类型。
我们可以轻松的转换 `Map` 和 `Set` 成对象和数组为序列话。
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
})
现在我们可以将其抽象为基本的可重用函数,并进行序列化。
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` 参数,
转换 `array` 或者 `object` 回 `Set`。
```JS
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
})
还要注意,`replacers` 和 `revivers` 都是递归工作的,因此它们能够序列化和反序列化`JSON`树中任何位置的`map`和`set`。
但是,我们上面的序列化实现有一个小问题。
在解析时,我们目前还没有区分普通对象或数组与`map`或`set`,所以我们不能在`JSON`中混合普通对象和`map`,否则我们将得到:
const obj = { hello: 'world' }
const str = JSON.stringify(obj, replacer)
const parsed = JSON.parse(obj, reviver)
// Map<string, string>
我们可以通过创建一个特殊的属性来解决这个问题;例如,`__type`表示什么时候应该是一个`map`或`set`,而不是一个普通的对象或数组,如下所示:
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']]) }
现在我们对`set`和`map`有了完整的`JSON`序列化和反序列化支持。
## 什么时间应该使用
对于具有定义良好的键集的结构化对象——例如每个事件都应该有标题和日期——通常需要一个对象。
// For structured objects, use Object
const event = {
title: 'Builder.io Conf',
date: new Date()
}
当你有一组固定的键时,它们对快速读写进行了优化。
当你可以有任意数量的键,并且可能需要频繁地添加和删除键时,考虑使用`map`以获得更好的性能。
// For dynamic hashmaps, use Map
const eventsMap = new Map()
eventsMap.set(event.id, event)
eventsMap.delete(event.id)
在创建数组时,如果元素的顺序很重要,并且你可能有意希望数组中有重复元素,那么普通数组通常是一个好主意。
// For ordered lists, or those that may need duplicate items, use Array
const myArray = [1, 2, 3, 2, 1]
但是,当你知道你绝对不想要重复的元素,而且元素的顺序也不重要时,可以考虑使用`Set`。
// For unordered unique lists, use Set
const set = new Set([1, 2, 3])