《译》在JavaScript中,你更应该选择Map而不是Object

183 阅读16分钟

原文地址 www.zhenghao.io/posts/objec…

作者: Twitter Zhenghao He

JavaScript中的应该如何选择MapObject。您可以在Reddit看到更多的讨论

JavaScript中,Object很方便的让我们能够轻松的将多条数据组合在一起,在ES6之后,我们在语言中添加了一个新的方式Map。 在很多方面,它看起来像是一个功能更强的对象,但接口却有些笨拙。 大多数人在需要用到 hash map 时仍然会使用对象,只有当他们意识到 key ****不能只是用 string 时才会想到使用 Map,因此在今天的JavaScript社区中,Map 仍然没有得到充分的利用。

在本文章中,我将会分析为什么更应该使用Map的原因,以及它在基准测试中的性能特征。

JavaScript中,Object是一个非常广泛的术语,除了nullundefined这两种类型,几乎所有的东西都可以是一个对象,在这篇文章中Object仅仅是指普通的对象,它由左大括号{和右大括号}包裹。

TL,DR

  • Object用在已知的具有固定且有限长度的键值对的记录,例如配置对象。以及任何一次性使用的东西。
  • Map用在条目数量可变,且更新频繁的字典或者hash map中。其值在一开始是未知的,比如 event emitter
  • 根据我的基准测试,除非键是小整数字符串,在插入、删除和迭代速度上,Map确实比Object性能更好,而且它比相同大小的Object消耗更少的内存。

为什么对象缺少哈希映射用例

hash map中使用使用 Object最明显的缺点,可能是Object只允许stringsymbol作为键,任何其他类型的数据都将被通过toString方式隐式转换为字符串。

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

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

更严重的是,使用 Object 作为 hash map 会存在混乱和安全隐患

不必要的继承

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

const hashMap = {}

然而在创建时,该对象并不是为空的。尽管 hashMap在创建时使用了空对象,但是它会自动的从Object.prototype上进行继承。这就是为什么我们可以在hashMap上调用hasOwnPropertytoStringconstructor等方法,虽然我们从来没有显式的定义这些方法。

由于原型继承的原因,我们现在有一个两种类型的属性存在于对象本身。即它自己的属性以及存在于原型链上的属性(即继承的属性)。因此我们需要额外的检查(例如使用hasOweProperty)来确保属性确实是用户提供的,而不是从原型上继承来的。

最重要的是,由于属性解析机制在JavaScript中的工作方式,在运行时对 Object.ptototype上的更改,将会对所有的对象起连锁反应。这可能会是一个严重的安全问题。

幸运的是,我们可以通过使用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.hasOwnMDN

较低的工作效率

Object作为hash map使用时没用提供足够的提高效率的API

zise

Object 没有提供方便的API来获取对象的大小,即属性的数量。并且构成对象大小的因素也有细微的差别。

  • 如果你只关系字符串、可枚举的键,那么可以通过Object.keys将对象的键转换为数组并获取 length
  • 如果你想考虑不可枚举键,可以通过Object.getOwnPropertyNames将不可枚举的值作为数组并获取length
  • 如果你想要获取symbol类型的键,可以通过getOwnPropertySymbols来显式的获取所有的symbol类型的键。或者可以使用Reflect.ownKeys来一次性获取所有的字符串和symbol类型的键。无论它是否可枚举

上述所有选项的运行,时间复杂度都是O(n),因为我们必须先要构建一个含有所有键的数组,然后才能获取它的长度。

iterate

遍历一个对象也有同样的复杂性。我们可以使用 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.values以及Object.entries去获取可枚举字符串(或/和值)的列表,然后对其进行迭代,但是这将会引入额外的开销。

最后,臭名昭著的插入顺行没有得到充分的尊重。在很多浏览器中,整数作为键时将会被按升序排列,并且优先于字符串作为键,即使字符串的键在整数键之前插入。

const obj = {}

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

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

clear

没有简单的方法从对象中删除所有的属性,你必须使用 delete操作符来逐个的删除每一个属性,这在以往都是被认为是非常的缓慢的,stackoverflow更多讨论。然而,在我的基准测试中,它的性能实际上并不比Map.prototype.delete慢很多。稍后再谈。

检查属性是否存在

最后,我们不能依赖点/括号符号来检查属性是否存在,因为值本身可以设置为undefined。相反,我们必须使用Object.prototype.hasOwnProprety或者Object.hasOwn来进行判断。

const obj = {a: undefined}

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

Map 作为 Hash Map

ES6 带来了Map,它更适合hash map的用例场景。首先它与对象只允许stringsymbol作为键不同,Map可以支持任何数据类型的键。

但是,如果你使用Map来存储对象的元数据,那么你可能更需要使用WeakMap来避免内存泄漏。

更重要的是,Map以额外的Map.prototype.get检索为代价,提供了用户定义和内置程序之间的清晰分离。

Map 还提供了更高操作效率的API,默认情况下,Map是可迭代的。这意味着你可以轻松的使用for...of进行迭代,并执行诸如通过嵌套解构从映射中提取数据的操作。

const [[firstKey, firstValue]] = map

Object相比,Map为各种常见的任务提供了专用的API

  • Map.prototype.has检查对象是否含有给定的键,与必须在对象上使用Object.prototype.hasOwnProperty/Object.hasOwn相比不那么的尴尬。
  • Map.prototype.get返回给定键关联的值,人们可能觉得这比对象通过点或者括号的方式直接获取更加笨拙,但是它在用户数据和内置方法之间提供了清晰的分离。
  • Map.prototype.size返回Map中所有键的数量,很明显他比获取对象大小进行的操作要灵活的多。此外,它要快得多。
  • Map.prototype.clear删除Map中所有的键,它比对象的delet操作符要快得多。

性能测试

JavaScript社区似乎普遍任务Map在大多数情况下都要比Object更快,有人声称从Object切换到Map可以看到明显的性能提升。

我在使用Leetcode 的经历似乎也在验证这个信念,Leetcode将大量的数据作为测试用例来运行你的解决方法,如果你的解决方法花费的时间太长,它就会超时,想这样的问题只有你在使用Object时才会超时,使用Map时则不会。

但是,我相信只是说MapObject快是过于简单的。他们之间一定有一些细微的差距,我想要找出来,因此我构建了一个小的程序来运行一些基准测试

免责声明

尽管我多次尝试阅读博客文章并查看C++的源代码,但我永远不会声称我完全理解V8如何在后台优化Map。完全稳健的基准测试很难,我们大多数人从未接受过任何形式的基准测试和结果分析。我做的基准测试越多,越感觉像是一个盲人摸象的故事。因此,把我在这里所说的关于性能的一切当作是不可置信的,你需要在生产环境使用你的程序来测试此类更改,以此确定Map替换Object是否有性能的提升。

基准测试的细节

这个程序有一个表格,用来显示ObjectMap上测量插入、迭代以及删除的速度。

插入和迭代的性能以每秒的操作数来衡量,我编写了一个实用的函数measureFor来重复运行目标函数,直到达到指定的最小时间阈值,之后返回每秒执行此类函数的平均次数。

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

至于删除,我只是测量使用delete操作符从对象中删除属性所需要的时间,并将其与相同大小的Map中调用Map.prototype.delete删除来进行测试。我可以使用 Map.prototype.clear,但是这样就违背了基准测试的目的,因为我知道它肯定会快得多。

在这三个操作中,我更关注插入,因为它往往是我们在日常工作中执行的最常见的操作。至于迭代性能,很难提出一个包含所有情况的基准,因为我们可以在给定的对象上执行许多不同的迭代变体。在这里我只测试 for...in循环。

在这里我使用了三种不同类型的key

  • string例如 asdfghjkl
  • 整数string。例如 123
  • 通过Math.random().toString()生成的数字字符串

所有key都是通过随机生成的,所以我们不会碰到V8实现的内联缓存。我还使用了toString将他们显式转换为字符串,以避免隐式转换的开销。

最后,在基准测试开始之前,还要至少100ms 的预热时间,我们会反复的创建ObjectMap,并立即丢弃它们。

我把代码放在了 codesanbox 上,你可以随时查看。

我从大小为100个键值对的ObjectMap开始创建一直到5000000。并让每个类型的操作持续运行 10000ms。以下是我的发现。

为什么我将键值对的最大值设置为5000000

因为这是一个对象在JavaScript中能够得到的最大尺寸了。根据一位活跃在Stackoverflow上的v8工程师@jmrk 的说法。如果键是字符串,则常规对象在约为8300000个元素之后变得慢的无法使用。

字符串的key

一般来说,当 key为字符串时,Map上的所有操作都优于Object

但是其中的细微差别是,当键值对的数量不是很大(小于100000)时。Map的插入速度时Object的两倍,当键值对的数量大于100000 是,性能的差距开始缩小。

我做了一些图表来更好地说明我的发现。

上图显示了插入率(x轴)随着键值对数量(y轴)的增加而下降。但是由于x轴的扩张太快(从1001000000),很难区分两条线之间的差距。

然后我使用对数来处理数据,并制作了下图。

你可以清楚的看到两条线正在汇合。

我制作了另一个图表,绘制了 Map 相对于 Object 在插入速度上要快多少。 你可以看到Map开始时比Object快2倍。随着时间的推移,性能的差距开始缩小。当数量的增长为5000000时,Map仅仅只比Object30%

然而,我们大多数人在一个Object或者Map中永远不会有超过100万个键值对,在具有数百个或数千个键值对的场景下,Map的性能至少是Object的两倍。因此我们应该抛弃Object ,然后开始重构我们的代码库,把所有的代码都处理为Map

当然不是,或者说至少不会像我们期望的那样,我们的应用速度可以提升2倍。我们还没有讨论其他类型的key,让我们来看一下整数字符串作为key

整数字符串作为key

我特别想在具有整数字符串作为key的对象上运行基准测试的原因是,V8在内部优化了整数索引属性,并且将它们存储在一个单独的数组中,从而保证可以进行线性连续的访问。但是我并没有找到任何的资料证实在Map中采用了同样的优化

让我们首先尝试 [0, 1000]范围内的整数key

正如我所料,这一次虽然 Object的表现优于Map,它的插入速度比Map快了65%。迭代速度比Map快了16%。

让我们扩大范围 ,将key的范围改为1200。

现在看起来,似乎Map的插入比Object快了一点,迭代速度比Object快了5倍。

现在我们只增加了整数key的范围,没有增加MapObject的大小。现在我们来增加它的大小,看看它如何影响性能。

当属性的大小为1000时,Object的插入速度比Map快了70%,迭代速度比Map慢了2倍。

我使用了一系列不同的Object/Map大小和整数键范围的组合并没有得到一个清晰的模式。 但我看到的总体趋势是,随着大小的增长,使用一些相对较小的整数作为键, Object在插入方面的性能可以比Map更好,总是与删除大致相同,但是迭代速度比Map慢4到5倍。最大整数键的阈值,即对象在插入时开始变慢的阈值,将随着对象的大小而增长。例如,当该对象只有100个key时,key的大小阈值为1200,当它有10000个key时,阈值似乎在24000左右。

随机生成的数字作为key

最后我们来看随机生成的数字作为key。从技术上来讲,以前的整数的key也是数字。这里的数字是指通过Math.random().toString()来生成的数字字符串。

结果与字符串作为key的表现一致:一开始,Map在插入时比Object快得多(插入和删除速度快2倍,迭代速度快4-5倍)。但是随着大小的增加,增量变得越来越小。

嵌套的Object/Map呢?

你可能已经注意到我们一直在谈论深度为1的Object/Map。 我确实添加了一些深度,但我发现只要条目总数相同,性能特征基本上保持不变,无论我们有多少层嵌套。

例如,宽度为100,深度为3,我们总共有100万个条目(100 * 100 * 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
    }
  }
}

很明显,Map 消耗的内存比 Object 少 20% 到 50%,这并不奇怪,因为 Map 不像 Object 那样存储诸如enumerable、writeable、cnfigurable之类的属性描述符

总结

那么我们从中得到了什么?

  • MapObject快,除非你的key是小整数。并且Map更节省内存。
  • 如果你需要频繁的更新hash map,请使用Map,如果你想要一个固定的键值对集合,请使用Object,并注意原型继承带来的陷阱

浏览器兼容性

Map是ES6的新特性,到目前为止,大多数人并不单心它的兼容性,除非你的目标用户是一些小众的旧浏览器。“旧”是指比 IE11 更早的浏览器,因为即使是IE11 也支持Map,而此时IE11 也已经没有了。 我们不应该盲目地转译到ES5并添加polyfill ,因为它不仅会增加包的大小,而且与现代JavaScript相比,它的运行速度很慢。最重要的是它伤害了99.99%的使用现代浏览器的用户。

另外,我们不需要完全放弃对旧浏览器的支持——通过提供回退包的方式兼容,通过nommodule来提供旧代码,这样我们就可以避免降低现代浏览器对访问者的体验。如果您需要更有说服力可以查看这里

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