全部 / 前端 / 技术 · 2022年7月26日 0

JavaScript 中什么时候使用 Map 更合适

这篇文章真的很不错很好公众号首图__2022-07-29+09_07_42

https://www.zhenghao.io/posts/object-vs-map

在 JavaScript 中,对象使用起来很顺手,它使我们可以很方便把多个数据集合在一起。在 ES6 之后新增了一个数据结构:Map。它看起来像一个带有一些笨拙方法更有能力的 Object。可是,当开发者需要 hash map 时还是更习惯使用对象,直到他们意识一些场景下 key 不能是字符串才会想到使用 Map。因此,在现在的 JavaScript 社区中 Map 并没有被充分利用。

在这篇文章中,我将会逐一分解更应该考虑使用 Map 的原因以及它的性能优势。

在 JavaScript 中,对象是一个非常广泛的术语。几乎任何东西都可以作为对象,除了两种类型:nullundefined。在这篇文章中,对象仅仅代表plain object,通过左右括号定义的。

TL;DR:

  • 当你创建的对象有固定和有线数量的属性或字段时则使用 Object,例如配置对象,或任何通常只使用一次的对象。
  • 当你的字典或哈希映射条数多变且更新频繁,或创建时关键字不确定,则使用 Map,例如 event emitter
  • 更具我的测量标准,除了 key 为小整数字符串时,其他情况在插入、删除和迭代时, 同等大小的Map 确实比 Object 更高效而且消耗更小的内存。

为何 Object 不足以试用 hash map 的用例

当对象作为 hash map 时,最明显的缺点是它只允许 key 为 string 和 symbol 类型,其它类型则会通过 toString 隐式转换为字符串。

const foo = []
const bar = {}
const obj = {[foo]: 'foo', [bar]: 'bar'}

console.log(obj) // {"": 'foo', [object Object]: 'bar'}

更重要的是,把对象作为 hash map 使用会引起困惑和安全隐患。

不需要的继承

在 ES6 之前,获取 hash map 的唯一方式是通过创建一个空对象。

const hashMap = {}

可是,上面语句创建的对象并不是一个空的。即使 hashMap 是通过一个空的对象字面量创建的,但它是自动继承了 Object.prototype 上的一些方法。这就是为何我们可以在 hashMap 上调用像 hasOwnPropertytoStringconstructor 等方法,即使我们没有在它上面显示的定义这些方法。

因为原型继承,现在我们的对象上有两种属性混合在一起:1. 对象上直接定义的(自身的);2.原型链上的(继承的属性)。结果就是,我们需要另外一个检测方法(例如:hasOwnProperty)来保证属性确实是用户添加的而不是从原型继承来的。

最重要的是,由于 JavaScript 中属性的查找原理,运行时任何对 Object.prototype 的变动都会影响所有的对象。这就为原型污染攻击开了一扇门,对于大型 JavaScript 应用会带来严重的安全问题。

幸运的是,我们可以通过 Object.create(null) 来解决此问题,它会创建一个从 Object.prototype 没有继承任何内容的对象。

名字冲突

当对象上自己的属性名和原型上冲突时,它会引发意外的结果以及使你程序崩溃。

例如,我们有一个接受对象的 foo 方法:

function foo(obj) {
	//...
	for (const key in obj) {
		if (obj.hasOwnProperty(key)) {
			
		}
	}
}

obj.hasOwnProperty(key) 有一个明显危害,根据 JavaScript 中属性查找机制,若 obj 对象自身上包含 hasOwnProperty,则会隐藏原型链上的 Object.prototype.hasOwnProperty。结果就是,代码运行时我们不知道使用了哪里的方法。

一些防御式变成可以避免发生类似的事情,例如:我们可以从 Object.prototype 借用真正的 hasOwnProperty 方法。

function foo(obj) {
	//...
	for (const key in obj) {
		if (Object.prototype.hasOwnProperty.call(obj, key)) {
			// ...
		}
	}
}

一个简短的方法可以使用对象字面量,就像这样:{}.hasOwnProperty.call(key),可是这样依旧显得很复杂。这就是为何新增了一个静态方法:Object.hasOwn

不够友好的方法

Object 没有提供足够友好的方法用作 hash map,一些任务并不能凭直觉完成。

size

Object 并没有提供趁手的方法来获取其大小,就是属性的个数。但是,获取对象大小的方法有一些细微的差别:

  • 若你关心的是字符串、可枚举的属性,你可以通过 Object.keys() 把属性的 key 转换为数组并获取其长度。
  • 如果你更新不可枚举字符串的 key ,你可以使用 Object.getOwnPropertyNames 来获取 key 的列表,然后得到其长度。
  • 若你更关心 symbol 的 key,则需要使用 getOwnPropertySymbols 来获取所有的 symbol 的 key 或通过 Reflect.ownKeys 来同时获得字符串和 symbol 的 key,而不管其是否可枚举。

上面所有的方法都是 O(n) 复杂度,因为它们首先都需要构建一个数组然后获取它的长度。

迭代

循环对象会遇到类似的复杂问题。

我们可以使用古老的 for ... in 循环,但是它会把继承的可枚举属性展示出来。

Object.prototype.foo = 'bar'

const obj = {id: 1} 

for (const key in obj) {
	console.log(key) // 'id', 'foo'
}

由于对象默认不是可迭代的,所以我们不可以把对象和 for ... of 一起使用,除非我们显式的在对象上定义了 Symbol.iterator 方法。

我们可以通过 Object.keysObject.valuesObject.entries 来获取一个可枚举的列表,然后循环遍历它,但这会增加额外的步骤和开销。

最后,key 的顺兴也是臭名昭著不受待见的。在多数浏览器中,数字类型的 key 有更高的优先级,会排在字符串类型 key 之前,即使字符串类型的 key 先于数字类型的 key 添加。

const obj = {}

obj.foo = 'first'
obj[2] = 'second'
obj[1] = 'last'

console.log(obj) // {1: 'last', 2: 'second', foo: 'first'}

clear

从对象上删除所有的属性并不是一件容易的事,你必须通过 delete 操作符一个个的删除,这很早就知道是一个很慢的操作。但是,我的基准测试表明它的性能实际上并不比 Map.prototype.delete 慢一个数量级,稍后会详细说明。

检测属性是否存在

最后,我们不能依赖通过 .[] 符号来检测属性是否存在,因为属性值可能被设置为 undefined。取而代之我们必须使用 Object.prototype.hasOwnPropertyObject.hasOwn 来检测:

const obj = {a: undefined}

Object.hasOwn(obj, 'a') // true

Map 用作 Hash Map

ES6 给我们带来了 Map,它更适合当做 hash map 的用例。

首先,它并不像 Object 那样只允许 key 为 string 和 symobol,Map 的 key 支持任何数据类型。

可是如果你使用 Map 为对象存储元数据,应该使用 WeakMap 取而代之以此避免内存泄漏。

更重要的是,Map 为用户自定义和内置对象属性提供了清晰的界限,使用一个额外的方法 Map.prototype.get 获取条目。

Map 同样也提供了更人性化的方法,Map 默认就可迭代,意味着你可以轻松的与 for...of 一起使用,同样使用嵌套的解构获取第一个条目。

const [[firstKey, firstValue]] = map

Object 对比,Map 为各种常见任务提供了具体的方法:

  • Map.prototype.has 检查一个给定条目的存在,与对象上的 Object.prototype.hasOwnProperty / Object.hasOwn 对比还是方便许多。
  • Map.prototype.get 返回与提供的 key 相关的值。可能会有人觉得比对象上的 .[] 稍显笨重。然后它为用户自定义的数据和内置的方法提供了清晰的界限。
  • Map.prototype.clear 可以清除 Map 上的所有条目且比 delete 操作更快。

性能

在 JavaScript 社区中似乎有一种共同的认知:MapObject 快,在多数情况下,有些人在从 Object 切换到 Map 时看到了性能的提升。

从我在磨人的 LeetCode 中刷题的经验中似乎更加确认了这个观点:LeetCode 使用了大量的数据来对你的解决方案做测试用例,若你的答案花费了太长时间则会超时。像这种问题一般在你使用 Object 时出现几次,而 Map 则没有。

可是,我相信只简单的说 MapObject 快很笼统,肯定有一些细微的差别需要我去发现。因此,我创建了一个小应用来运行一些基准测试。

基准测试的实现细节

这个应用有一个表格用来展示分别对 ObjectMap 作用插入、迭代和删除的速度。

插入和迭代的性能是以每秒来测量的,我写了一个工具方法 measureFor 用来重复执行目标函数,直到设定的最小阈值(就是传入的 duration 值)。它返回的是每秒函数执行的平均次数。

function measureFor(f, duration) {
  let iterations = 0;
  const now = performance.now();
  let elapsed = 0;
  while (elapsed < duration) {
    f();
    elapsed = performance.now() - now;
    iterations++;
  }

  return ((iterations / elapsed) * 1000).toFixed(4);
}

对于删除,我打算简单的对比一下从长度相同的 ObjectMap 分别使用 deleteMap.protoype.delete 移除所有属性所花费时间的对比。我知道有 Map.protype.clear 但据我所知它非常的快,这有悖于设置基础测试的目的。

在这三种操作中,因为每天的工作中经常使用到插入操作,所以我更关心它。对于迭代的性能,因为有很多方法用于对象的迭代,所以很难包含所有的基准测试。我在这里只测试了 for ... in 循环。

我这里使用了三种类型的 key:

  • 字符串,例如:'yekwl7caqejth7aawelo4'
  • 整数字符串,例如:123
  • 通过 Math.random().toString() 生产的数字字符串,例如:'0.6514674912156457'

所有的 key 都是随机生成的,所以不会触发 V8 内部实现的缓存机制。在把属性添加到对象上之前,我还在显性的把整数和数值类型的 key 通过 toString 转换为字符串以此避免隐式开销。

最后,在基准测试开始之前,还有一个 100ms 的热身阶段,就是重复创建新的 object 和 map 并立即丢弃掉所耗费的时间。

我已经把代码放到了 CodeSandbox 如果你想试玩一下。

我从 100 个属性/条目大小的 Object 和 Map 开始,一直到5000000,每种类型的操作都执行了 10000ms 来对比它们之间的表现,下面是我发现的:

为何把条目数达到 5000000时才停止?
这是 JavaScript 中能得到的最大对象,根据 StackOverflow 上一名活跃的 V8 工程师 @jmrk 所说:"如果 key 为字符串,当一个普通的对象元素达到 8.3M 时会各种对它的操作会变得非常慢(这是有技术原因的:某个位域有23位宽,当超过时采取非常缓慢的回退路径。)"

字符串 key

通常来说,当 key 为字符串(非数值型),在各种操作中 Map 的表现胜过 Object

但是当条目数并没有特别大的时候(100000 以下的时候),在插入操作的速度上 Map 基本是 Object 的两倍,但当大小超过 100000 时,性能差距会开始缩小。

我制作了一个图表来说明我的发现:

上面的图表演示了插入速率随着条目数(x-axis)的增加是如何下降的(y-axis)。可是,因为 x-axis 扩展的越来越大(从 100 到 1000000),很难区分出两条线之间的间隔差距。

然后我用对数比例来处理数据,做出了下面的图表:

你会清楚的分不出两条线渐渐地重叠。

我又用另一个图表来展示当插入操纵时 MapObject 快多少。你可以看到期初 MapObject 快 2倍,随着时间的推移性能差距开始缩小。最终,当大小达到 5000000 时,Map 只快了 30%。

我们的 object 和 map 的条目永远不可能多余 1百万。成百上千的条目,Map 至少比 Object 快 2 倍。因此,我们是否应该开始使用 Map 来重构我们的代码?

当然不是,至少不能期望我们的程序变得比之前快 2倍。记住我们还没有探索其它类型的 key,接下来让我们一起看看整数类型的 key。

整数类型的键

我特别想运行对象上键为整数类型的原因是 V8 内部为整数索引的属性做了优化以及把它们存储在一个分开的数组中,然后可以线性和连续的获取。可是我没有找到任何资料来证明 V8 对 Map 做了同样的优化。

我们先在 [0,1000] 之间尝试整数类型的 key。

就像我预期的一样,这次 ObjectMap 做得好,在插入方面快 65%以及循环方面快 16%。

让我们把范围调整到最大 1200。

现在看起来 Map 在插入方面快一些以及循环方便快 5 倍。

现在我们仅仅增加了键的范围,而不是 ObjectMap 的实际大小。让我们来增加它们的大小看看如何影响性能。

当大小为 1000 时,插入方面 ObjectMap 快 70% 以及循环方面快 2 倍。

我尝试了很多种 Object / Map 大小与整数键范围的组合但没有得到一个清晰的模式。但我看到的一般趋势是,随着大小的增长,以一些相对较小的整数为键,对象在插入方面的性能比 Map 更强,在删除方面总是大致相同,迭代速度要慢 4 或 5 倍。对象在插入时开始变慢的最大整数键的阈值会随着对象的大小而增长。例如,当对象只有 100 个条目时,阈值是 1200 ;当它有 10000 个条目时,阈值似乎是 24000 左右。

数值类型的键

最后,我们一起看一看最后一种类型的键–数值型。

从技术上来说,前面的整数类型的键也是数值型,不过这里的数值型特指通过 Math.random().toString() 生成的数值字符串。

结果有点类似字符串类型的键:Map 开始时比 Object 快得多(插入和删除快2倍,迭代快4-5倍),但随着我们规模的增加,这个差距也越来越小。

嵌套的 Object / Map 呢?
你可能已经发现了我只讲了深度只有一层的 ObjectMap。我确实增加了一些嵌套深度但是我发现只要总条目数相同性能特性大体一致,不管嵌套了多少层。

例如,我们有一个宽度为 100 和深度为 3 总数为 100 万的条目,结果与宽度为 1000000 深度为 1 的性能几乎相同。

内存使用

基准测试的另一个重要因素为内存利用。

由于我无法控制浏览器环境中的垃圾收集器,我决定在Node中运行基准测试。

我创建了一个小脚本来测量在每个测试中通过手动触发完全垃圾回收时各自的内存使用情况。与 node --expose-gc 一起运行将会得到如下结果:

{
  object: {
    'string-key': {
      '10000': 3.390625,
      '50000': 19.765625,
      '100000': 16.265625,
      '500000': 71.265625,
      '1000000': 142.015625
    },
    'numeric-key': {
      '10000': 1.65625,
      '50000': 8.265625,
      '100000': 16.765625,
      '500000': 72.265625,
      '1000000': 143.515625
    },
    'integer-key': {
      '10000': 0.25,
      '50000': 2.828125,
      '100000': 4.90625,
      '500000': 25.734375,
      '1000000': 59.203125
    }
  },
  map: {
    'string-key': {
      '10000': 1.703125,
      '50000': 6.765625,
      '100000': 14.015625,
      '500000': 61.765625,
      '1000000': 122.015625
    },
    'numeric-key': {
      '10000': 0.703125,
      '50000': 3.765625,
      '100000': 7.265625,
      '500000': 33.265625,
      '1000000': 67.015625
    },
    'integer-key': {
      '10000': 0.484375,
      '50000': 1.890625,
      '100000': 3.765625,
      '500000': 22.515625,
      '1000000': 43.515625
    }
  }
}

很明显,MapObject 消耗的内存少20%到50%不等,结果并不意外因为 Map 不存储属性的描述类似 Object 上的 writableenumerableconfigurable

总结

那么,我们从其中能得到什么?

  • MapObject 更快,除非你有小的整数、数组索引为键,而且它更节省内存。
  • 若 Hash Map 需要经常更新你应该使用 Map;若你的集合为固定的键值(例如:记录)则使用 Object,但是请注意原型继承带来的陷阱。

如果你知道 V8 优化 Map 的细节,或者只是想指出我的基准测试中的缺陷,请与我联系。我很乐意根据你的信息来更新这个帖子。

浏览器兼容性笔记

Map 是 ES6 引入的特性,目前为止我们不需要担心其兼容性除非你的客户使用的旧浏览器。旧指比 IE11 版本低,即使 IE11 支持 Map 但它已经 了。默认情况下,我们不需要费尽心思的添加转换器和垫片来支持 ES5 ,因为那样会增加你的打包体积,同样与现代浏览器比更慢。最重要的是,那样会对 99.999% 使用现代浏览器的用户带来负担。

另外,我们不必放弃对传统浏览器的支持–通过 nomodule 提供回退包来服务传统代码,这样我们就可以避免降低使用现代浏览器的访问者的体验。如果你需要更多的说服力,请参考《过渡到现代JavaScript》。

JavaScript语言在不断发展,平台在优化现代JavaScript方面也不断完善。我们不应该以浏览器兼容性为借口,忽视所有已经取得的改进。