【译】在JavaScript中 应优先使用 Map 而不是 Object

【译】在JavaScript中 应优先使用 Map 而不是 Object

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 则针对这种情况进行了优化,在某些情况下可以显著提高速度。

【译】在JavaScript中 应优先使用 Map 而不是 Object

当然,这只是一个示例基准测试(在 Core i7 MBP 上使用 Chrome v109 运行)。像这样的微基准测试通常并不完美,所以要保持审慎。

话虽如此,你无需相信我的或任何其他人的基准测试结果,因为 MDN 自身澄清了,与对象相比,Map 是专门针对频繁添加和移除键的这种使用情况进行了优化,而对象则没有针对这种使用情况进行优化:

【译】在JavaScript中 应优先使用 Map 而不是 Object

如果你想知道为什么,这与 JavaScript 虚拟机如何通过假设对象的形状来优化 JS 对象有关,而 Map 则是专门为哈希映射的用例而构建的,其中键是动态的且不断变化的。

一篇很棒的文章是《单态化有什么问题》,它解释了 JavaScript 中对象的性能特性,以及为什么它们不太适用于频繁添加和移除键的哈希映射样式用例。

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

内置键问题

对象在类似哈希映射的使用情况下的一个主要问题是,对象内置了大量的键。

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 对象处理键的奇怪方式,遍历对象充满了坑。

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

for (const key in myObject) {
  // TODO
}

而你可能被告知应该这样做:

for (const key in myObject) {
  if (myObject.hasOwnProperty(key)) {
    // TODO
  }
}

但这仍然有问题,因为 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) {
 // 😍
}

事实上,这样做非常好,我们现在有了 Object.entries 方法,可以对对象进行类似操作。虽然多了一个步骤,感觉不是那么一流,但它还是能用的。

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

在 对象中的循环很丑陋,但对于 Map 来说,可以简单而优雅的内置方法直接迭代。

此外,你还可以只迭代键或值:

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

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

键的排序

Map 的另一个额外好处是它们保留了键的顺序。这是对象长期以来一直期望的特性,现在在 Map 中也存在了。
这给了我们另一个非常酷的功能,就是我们可以直接从 Map 中按照它们的确切顺序解构键:

const [[firstKey, firstValue]] = myMap

这还能开辟一些有趣的用例,比如实现 O(1) LRU Cache(最少使用算法):

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 方法。

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

但事实证明,Map 同样很容易复制:

const copied = new Map(myMap)

这个方法之所以有效,是因为 Map 的构造函数接受一个包含 [keyvalue] 元组的可迭代对象。而恰巧 Map 是可迭代的,产生的是其键和值的元组。Nice~ 。

类似地,你也可以像对对象一样对 Map 进行深拷贝,使用 structuredClone 方法:

const deepCopy = structuredClone(myMap)

将 Map 与 对象的相互转换

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

const myObj = Object.fromEntries(myMap)

而将对象转换为 Map 也同样简单,使用 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>

键类型

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 完美解决了上述的内存泄漏问题,因为它们对对象持有弱引用。

因此,如果所有其他引用被移除,对象将自动被垃圾回收并从这个 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
})

更多关于 Map 的内容

在我们继续之前,还有几件关于 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

Map 有很好用的方法。让我们继续。

Set

如果我们在谈论 Map,我们也应该提到它们的表兄弟 Set,它们提供了一种性能更好的方法来创建一个独特的元素列表,在这个列表中我们可以轻松地添加、删除,并查找是否包含某个元素:


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

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

在某些情况下,与数组相比,使用集合可以获得显着更好的性能。

【译】在JavaScript中 应优先使用 Map 而不是 Object

微基准测试并不完美,请在实际条件下测试你自己的代码,以验证你是否能从中受益。

同样,JavaScript 中的 WeakSet 类也能帮助我们避免内存泄露。

// No memory leaks here, captain 🫡
const checkedTodos = new WeakSet([todo1, todo2, todo3])

序列化

现在你可能会说,与MapSet相比,普通对象和数组还有最后一个优势–序列化。

事实上,JSON.stringify()/ JSON.parse() 对对象和 Map 的支持非常方便。

但是,你有没有注意到,当你想漂亮地打印 JSON 时,总是要在第二个参数中添加一个空值?你知道这个参数有什么作用吗?

JSON.stringify(obj, null, 2)

事实证明,这个参数对我们非常有帮助。它被称为 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 参数,来在解析时将数组转换回 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
})

还要注意,replacerreviver 都可以递归工作,因此它们能够在 JSON 树的任何位置序列化和反序列化 MapSet

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

我们目前在解析时没有区分普通对象或数组与 MapSet,所以我们不能在我们的 JSON 中混合使用普通对象和 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']]) }

现在我们完全支持将集合和映射序列化和反序列化为 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])

原文地址

原文链接:https://juejin.cn/post/7346245690009927720 作者:佳玮

(0)
上一篇 2024年3月15日 下午4:00
下一篇 2024年3月15日 下午4:10

相关推荐

发表评论

登录后才能评论