Libon

Object vs Map and Array vs Set

#JavaScript
也许我们在日常开发里更应该用 Map/Set 来替代 Object/Array

ToC

语法的差异

也许在开始之前,我们可以先来看看这两者在 JavaScript 里语法上的差异:

// Object

// 初始化
const object = {}
// 添加属性
object.name = 'object'
// 删除属性
delete object.name
// Map

// 初始化
const map = new Map()
// 添加属性
map.set('name', 'map')
// 删除属性
map.delete('name')

性能测试

这两个达到的效果是差不多的,但他们在语法上却不一样,Map 的语法看上去更符合函数式开发的风格,而 Object 的语法看上去更像是命令式的代码。我们再来看看他们在相同操作下执行 100000 次的情况下的性能差异:

// Object 的执行代码
const object = {}
for(let i = 0; i < 100000; i++){
  object.a = i;
  delete object.a
}

// Map 的执行代码
const map = new Map()
for(let i = 0; i < 100000; i++){
  map.set('a', i)
  map.delete('a')
}

除了这种测试方式之外,我还找到了另一种测试方式(example benchmark)。当然了,这种基准测试的结果是不可靠的,因为它们依赖于 JavaScript 引擎的实现,也同时和浏览器的版本有关系,客户端的机器配置也会影响到测试结果,所以我们只能用它们来做一个参考。也就是说,你完全可以不相信任何人的测试结果,因为在 MDN 上的 Map 一节中也有提到过 Map 在频繁增删键值对的场景下是有特殊优化的,而 Object 则没有。

MapObject
省略…省略…省略…
PerformancePerforms better in scenarios involving frequent additions and removals of key-value pairs.(在频繁增删键值对的场景下表现更好。)Not optimized for frequent additions and removals of key-value pairs.(在频繁添加和删除键值对的场景下未作出优化。)

当然如果是只有这一点微乎其微的性能,那可能很难说服你使用 Map,但接下来的内容可能可以改变你的想法。

内置键值对属性的问题

const obj = { toString: 'hello' }

const map = new Map([['toString', 'hello']])

console.log(obj.toString) // ?
console.log(map.toString) // ?

这里的 objmap 都有一个 toString 属性,但是它们的值是不一样的,objtoString 属性是一个字符串,而 maptoString 属性是一个函数,对于 obj 来讲,自行设置的 toString 会覆盖原型链上的属性,而 map 中设置的 toString 键却不会。我认为仅此一点就足以说明 Map 的优势了,因为 Map 中的键值对是不会被覆盖的,而 Object 中的键值对则会被覆盖。

迭代陷阱

在 JS 中迭代对象的时候,我们通常会使用 for...in 循环,但是这种方式有一个陷阱,就是它会遍历原型链上的属性,而 Map 中的 for...of 循环则不会遍历原型链上的属性,所以 Map 更加安全。

// 正常情况下你可能会这么做
for (const key in obj) {
  console.log(key)
}

// 经验丰富一点的你可能会这么做
for (const key in obj) {
  if (obj.hasOwnProperty(key)) {
    console.log(key)
  }
}

但这么做依然是有问题的,因为 hasOwnProperty 方法也是从原型链上继承来的,没人能保证 hasOwnProperty 方法不会被覆盖。所以你最终可能需要这么做:

for (const key in obj) {
  if (Object.prototype.hasOwnProperty.call(obj, key)) {
    console.log(key)
  }
}

当然如果你不想这么麻烦,则可以完全放弃 for...in 循环,进而采用 Object.keysforEach

Object.keys(obj).forEach(key => {
  console.log(key)
})

Map 就不存在这些问题,因为它的 for...of 循环不会遍历原型链上的属性,所以你可以放心的使用 for...of 循环来遍历 Map

for (const [key, value] of map) {
  console.log(key, value)
}

键值对的顺序

在 JS 中,对象的键值对是无序的,但 Map 中的键值对是有序的,这意味着你可以通过 Map 来实现一个有序的对象。

const [
  [key1, value1],
  [key2, value2],
  [key3, value3]
] = new Map([
  ['a', 1],
  ['b', 2],
  ['c', 3]
])

console.log(key1, value1) // a 1
console.log(key2, value2) // b 2
console.log(key3, value3) // c 3

数据拷贝

在 JS 中,对象的拷贝是浅拷贝,而 Map 的拷贝是深拷贝,这意味着你可以通过 Map 来实现一个深拷贝的对象。

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

简简单单对吧,但其实 Map 也很容易实现拷贝功能:

const copied = new Map(map)

而之所以可以这么做,主要是因为 Map 的构造函数接收了一个可迭代的 [[key, value]] 元组,所以 Map 也可以接收一个 Map 实例,这样就可以实现深拷贝了。当然,Map 也是支持使用浏览器内置原生支持的 structuredClone 方法来实现深拷贝的,但因为兼容性的问题,所以这里就不展开了。语法如下:

const copied = structuredClone(map)
const copied = new Map(map, [structuredClone])

Map 和 Object 的相互转换

const obj = { a: 1, b: 2, c: 3 }

// Object to Map
const map = new Map(Object.entries(obj))

// Map to Object
const obj = Object.fromEntries(map)

在你理解了这种转换欢喜以后,你就可以用对象的形式来构造一个 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',
  keyTwo: 'valueTwo'
})

如果你需要使用 TypeScript,那你可以这么做:

const makeMap = <Value = unknown>(obj: Record<string, Value>) => new Map<string, Value>(Object.entries(obj))

const myMap = makeMap({ key: 'value' }) // => Map<string, string>

键名的类型

在 JS 中,对象的键名只能是字符串或者 Symbol,而 Map 的键名可以是任意值,这意味着你可以使用 Map 来实现一个类似于 WeakMap 的功能,但是 Map 可以使用任意值作为键名,而 WeakMap 只能使用对象作为键名。

const map = new Map()
const key = {}
map.set(key, 'value')
console.log(map.get(key)) // value

这适用于想将数据扁平化从而建立一种数据联结的场景,你可以以整个对象作为键名,同时将对象的某个属性作为键值,这样你就既可以使用对象来获取键值,也可以使用键值来获取对象了。

Map 的其他属性

当然,Map 上还有很多有用的属性:

Set

当我们讨论 Map 时,我们也可以讨论一下 Set,因为它们是一对好基友,它们的功能也是相似的,但是 Set 只有键名,没有键值,所以 Set 中的键名和键值是相同的。

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

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

在某些情况下,Set 可以完全替代 Array 做一些等效操作,且拥有 更好的性能。当然这种结果可能并不准确,所以你可以自己测试一下。同样的,我们可以使用 WeakSet 来帮我们解决内存泄漏的问题。

const checkedTodos = new Set([todo1, todo2, todo3])

序列化

MapSet 都是可序列化的,这意味着你可以使用 JSON.stringify 来序列化它们,但是你需要注意的是,MapSet 中的键名和键值都必须是可序列化的,否则就会抛出错误。但是你有没有注意到,如果你想要打印出带有缩进格式或者其他任意风格的 JSON 时,你总是需要添加一个 null 作为参数,而这个 null 被称之为替换器。如果你想要使用替换器,你可以使用 JSON.stringify 的第二个参数来指定替换器,或者使用第三个参数来指定缩进空格的数量。这两个参数都是可选的,但是如果你想要使用第三个参数,你就必须使用第二个参数来指定替换器,即使你不需要使用替换器。

JSON.stringify(obj, null, 2)

我们可以写一个转换器来解析 Map 和 Set,这样我们就可以使用 JSON.stringify 来序列化它们了,同时增加一个特殊属性来标识它们的类型。

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

什么时候该用哪个

最后,我们再来讨论一下,在什么时候、什么场景该用哪一个。

以上。


  1. When You Should Prefer Map Over Object In JavaScript
  2. What’s up with monomorphism?

CD ..
回顾上一篇
JSON.stringify