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

JavaScript 中的对象很棒。它们可以做任何事情!万事万物皆为对象。〔^ǒ^〕 但是,就像所有事物一样,仅仅因为你可以做某事,并不意味着你应该(一定)这样做。

javascript 复制代码
const mapOfThings = {}

mapOfThings[myThing.id] = myThing

delete mapOfThings[myThing.id]

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

javascript 复制代码
const mapOfThings = new Map()

mapOfThings.set(myThing.id, myThing)

mapOfThings.delete(myThing.id)

对象的性能问题

对象中的删除操作符以性能差而闻名,而 Map 则针对这种情况进行了优化,在某些情况下可以显著提高速度。

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

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

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

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

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

内置键问题

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

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

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

javascript 复制代码
for (const key in myObject) {
  // TODO
}

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

javascript 复制代码
for (const key in myObject) {
  if (myObject.hasOwnProperty(key)) {
    // TODO
  }
}

但这仍然有问题,因为 myObject.hasOwnProperty 可以很容易被任何其他值覆盖。没有任何东西能阻止任何人执行 myObject.hasOwnProperty = () => explode()

所以,你真的应该做这些乱七八糟的事情:

javascript 复制代码
for (const key in myObject) {
  if (Object.prototype.hasOwnProperty.call(myObject, key) {
    // 😕
  }
}

或者如果你不希望你的代码看起来很混乱,你可以使用较新添加的 Object.hasOwn 方法:

javascript 复制代码
for (const key in myObject) {
  if (Object.hasOwn(myObject, key) {
    // 😐
  }
}

或者你可以完全放弃使用 for 循环,而只是使用 Object.keys 结合 forEach 方法。

javascript 复制代码
Object.keys(myObject).forEach(key => {
  // 😬
})

然而,对于 Map,根本不存在这样的问题。你可以使用标准的 for 循环,配合标准的迭代器,以及一个非常好的解构模式,一次性获取键和值:

javascript 复制代码
for (const [key, value] of myMap) {
 // 😍
}

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

javascript 复制代码
for (const [key, value] of Object.entries(myObject)) {
 // 🙂
}

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

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

javascript 复制代码
for (const value of myMap.values()) {
 // 🙂
}

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

键的排序

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

javascript 复制代码
const [[firstKey, firstValue]] = myMap

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

javascript 复制代码
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 方法。

javascript 复制代码
const copied = {...myObject}
const copied = Object.assign({}, myObject)

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

javascript 复制代码
const copied = new Map(myMap)

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

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

javascript 复制代码
const deepCopy = structuredClone(myMap)

将 Map 与 对象的相互转换

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

javascript 复制代码
const myObj = Object.fromEntries(myMap)

而将对象转换为 Map 也同样简单,使用 Object.entries

javascript 复制代码
const myMap = new Map(Object.entries(myObj))

而且,现在我们知道了这一点,我们再也不必使用元组构造 Map 了:

javascript 复制代码
const myMap = new Map([['key', 'value'], ['keyTwo', 'valueTwo']])

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

javascript 复制代码
const myMap = new Map(Object.entries({
  key: 'value',
  keyTwo: 'valueTwo',
}))

或者你也可以创建一个便捷的小助手函数:

javascript 复制代码
const makeMap = (obj) => new Map(Object.entries(obj))

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

或者使用 TypeScript:

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 的键。我是说,任何类型。

javascript 复制代码
myMap.set({}, value)
myMap.set([], value)
myMap.set(document.body, value)
myMap.set(function() {}, value)
myMap.set(myDog, value)

这样有什么意义呢? 这种做法的一个有用的用例是将元数据与一个对象关联起来,而无需直接修改该对象。

javascript 复制代码
const metadata = new Map()

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

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

这在一些情况下非常有用,比如当你想将临时状态与从数据库中读取和写入的对象关联起来时。你可以添加与对象引用直接关联的临时数据,而不会有任何风险。

javascript 复制代码
const metadata = new Map()

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

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

现在,当我们将 myTodo 保存回数据库时,只有我们想要保存的值存在,我们的临时状态(存储在一个单独的 Map 中)不会被意外地包含进去。

但是,这确实存在一个问题。

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

WeakMaps

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

因此,如果所有其他引用被移除,对象将自动被垃圾回收并从这个 WeakMap 中移除。

javascript 复制代码
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 的有用的事情需要知道:

javascript 复制代码
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,它们提供了一种性能更好的方法来创建一个独特的元素列表,在这个列表中我们可以轻松地添加、删除,并查找是否包含某个元素:

javascript 复制代码
const set = new Set([1, 2, 3])

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

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

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

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

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

序列化

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

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

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

javascript 复制代码
JSON.stringify(obj, null, 2)

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

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

javascript 复制代码
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
})

现在我们可以将这个基本可重复使用的函数抽象出来,然后进行序列化。

javascript 复制代码
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

javascript 复制代码
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,否则我们会得到这样的结果:

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

我们可以通过创建一个特殊的属性来解决这个问题;例如,称为 __type,来表示何时应该是一个MapSet,而不是普通对象或数组,像这样:

javascript 复制代码
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。

何时使用

对于具有明确定义的一组键的结构化对象,例如每个事件都应该有一个标题和一个日期,通常你会使用对象。

javascript 复制代码
// For structured objects, use Object
const event = {
  title: 'Builder.io Conf',
  date: new Date()
}

当你有一组固定的键时,对象非常优化,可以快速读取和写入。

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

javascript 复制代码
// For dynamic hashmaps, use Map
const eventsMap = new Map()
eventsMap.set(event.id, event)
eventsMap.delete(event.id)

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

javascript 复制代码
// For ordered lists, or those that may need duplicate items, use Array
const myArray = [1, 2, 3, 2, 1]

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

javascript 复制代码
// For unordered unique lists, use Set
const set = new Set([1, 2, 3])

原文地址

相关推荐
空中海4 小时前
01 React Native 基础、核心组件与布局体系
javascript·react native·react.js
前端之虎陈随易6 小时前
2年没用Nodejs了,Bun很香
linux·前端·javascript·vue.js·typescript
好运的阿财7 小时前
OpenClaw工具拆解之host_workspace_write+host_workspace_edit
前端·javascript·人工智能·机器学习·ai编程·openclaw·openclaw工具
XiYang-DING7 小时前
JavaScript
开发语言·javascript·ecmascript
空中海8 小时前
02 React Native状态、导航、数据流与设备能力
javascript·react native·react.js
空中海9 小时前
02 状态、Hooks、副作用与数据流
开发语言·javascript·ecmascript
空中海9 小时前
04 React Native工程化、质量、发布与生态选型
javascript·react native·react.js
杨超凡10 小时前
豆包收费了?我特么自己用“意念”搓了一个!
javascript
threelab11 小时前
Three.js 咖啡杯烟雾效果 | 三维可视化 / AI 提示词
开发语言·javascript·人工智能