JS 中使用 Map 更多,使用 Object 更少 [意译]

436 阅读6分钟

本篇文章翻译自 Use Maps More and Objects Less (builder.io)

image.png

JavaScript 中的对象非常棒,但这并不意味着你应该老是用对象来处理。

const mapOfThings = {}

mapOfThings[myThing.id] = myThing 

delete mapOfThings[myThing.id]

例如,如果你在 JavaScript 中使用对象来存储任意的键值对,而你会经常添加和删除,

那么你应该考虑使用 Map 而不是普通的对象。

const mapOfThings = new Map()

mapOfThings.set(myThing.id, myThing)

mapOfThings.delete(myThing.id)

性能

Map 专门优化了频繁添加和删除键,相比之下,对象却没有。

image.png

image.png

注意这是基准测试(在 Core i7 MBP 上用 Chrome v109 运行),不一定准确。

内置键问题

经被内置的大量键所污染的对象

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 迭代对象很麻烦。

例如

for (const key in myObject) {
  // 🚩 无意中得到了对象继承的 key
}

你应该要这样做 👇

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.keyforEach

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

Map,不存在这些问题。

你可以使用标准的 for 循环以及解构来获得键和值。

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

我们也可以用 Object.entry 方法对对象做类似的事情。

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

此外,也可以只对 Map 的键或值进行迭代。

for (const value of myMap.values()) {
 // 🙂
}

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

Key 的顺序

Map 保留了 Key 的顺序,但对象却不是的。

所以我们可以直接从 map 上按顺序进行解构。

const [[firstKey, firstValue]] = myMap

我们也可以用它简单地实现一个O(1)LRU Cache

image.png

深拷贝

你可能会说,对象很容易浅拷贝

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

或者深拷贝

const copied = JSON.parse(JSON.stringify(myObject))

const copied = structuredClone(myObject)

Map 其实也同样容易浅拷贝

const copied = new Map(myMap)

当然你也可以使用 structuredClone 进行深拷贝

const deepCopy = structuredClone(myMap)

转换

使用 Object.fromEntries 可以轻易地将 Map 转换为对象。

const myObj = Object.fromEntries(myMap)

反过来也一样,使用 Object.entries,可以轻易地将对象转换为 Map

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 类型

Mapkey 支持更多类型

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: '...' }

不过这确有一个问题。

如果我们的 Map 如果持有一个对象的引用,该对象将永远不会被垃圾收集,这可能会导致内存泄漏,除非手动移除该对象的引用。

const metadata = new Map()

let myTodo = {}

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

myTodo = null // 当没有其他引用时,也不会被垃圾回收

metadata.delete(myTodo) // 除非手动移除

WeakMap

而使用 WeakMap 类型。则完美地解决了上述内存泄漏的问题,因为它们持有一个对象的弱引用。

当其他的引用都被移除时,该对象将自动被垃圾收集。

const metadata = new WeakMap()

let myTodo = {}

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

myTodo = null // 当没有其他引用时,自动删除,被垃圾回收

更多

Map 也有很多方便的方法

map.clear() // 清理所有元素
map.size // 获取元素数量
map.keys() // 获取所有键
map.values() // 获取所有值

Set

Set 给我们提供了性能更好的方式来创建元素集合,我们可以轻松地添加、删除和查询集合是否包含某个元素

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

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

在某些情况下,Set 比数组性能更好

image.png

这个测试可能是不准的。

同样,WeakSet 也会帮助我们避免内存泄漏。

// 这里没有内存泄漏,队长 🫡
const checkedTodos = new WeakSet([todo1, todo2, todo3])

序列化

你可能会说,普通对象和数组会比 MapSet 更容易序列化。

但其实让 JSON.stringify() / JSON.parse() 支持 Map 是很简单的

不知道有没有注意到,当你想漂亮地打印 JSON 时,你总是要添加 null 作为第二个参数?

你知道这个参数是干嘛的吗?

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

它被称为 replacer,允许我们定义如何序列化。

我们可以用它轻松地将 MapSet 转换为对象和数组进行序列化。

JSON.stringify(obj, (key, value) => {
  // 转换 Map 为 对象
  if (value instanceof Map) {
    return Object.fromEntries(value)
  }
  // 转换 Set 为 数组
  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 参数,在解析时将数组转为 Set,将对象转为 Map

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

还要注意的是,replacersrevivers 都是递归的,所以它们能够在我们的 JSON 树中的任何地方序列化和反序列化 MapSet

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

目前我们没有在解析时区分普通对象,数组,MapSet,如果我们在其中混入普通对象和 Map,就会出现这样的结果。

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

我们可以通过创建一个特殊的属性来解决这个问题;

例如,__type,用来标识 MapSet,而不是普通的对象或数组 👇

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

现在我们对 SetMap 有了完整的序列化和反序列化支持

你应该用什么

当你有一组固定的键时,使用对象,它对快速读写非常快

const event = {
  title: 'Builder.io Conf',
  date: new Date()
}

当你可能需要频繁地添加和删除键时,使用 Map

const eventsMap = new Map()
eventsMap.set(event.id, event)
eventsMap.delete(event.id)

元素的顺序很重要,并且可能有重复的元素,使用数组

const myArray = [1, 2, 3, 2, 1]

元素唯一,且顺序不重要,使用 Set

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