尽量使用 Map 代替 Object

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])