本文首发于微信公众号:大迁国际, 我的微信:qq449245884,我会第一时刻和你分享前端职业趋势,学习途径等等。 更多开源著作请看 GitHub github.com/qq449245884… ,包括一线大厂面试完好考点、材料以及我的系列文章。

在 JavaScript 中,方针是很方便的。它们允许咱们轻松地将多个数据块组合在一起。 在ES6之后,又出了一个新的言语弥补– Map。在许多方面,它看起来像是一个功能更强的方针,但接口却有些笨拙。

但是,大多数开发者在需求 hash map 的时分仍是会运用方针,只有当他们意识到键值不能只是字符串的时分才会转而运用 Map。因而,Map 在当今的 JavaScript 社区中仍然没有得到充分的运用。

在本文本中,我会罗列一些应该更多考虑运用 Map 的一些原因。

为什么方针不符合 Hash Map 的运用状况

在 Hash Map 中运用方针最显着的缺点是,方针只允许键是字符串和 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 等办法,虽然咱们从未在该方针上清晰界说这些办法。

由于原型承继,咱们现在有两种类型的特点被混杂了:存在于方针本身的特点,即它自己的特点,以及存在于原型链的特点,即承继的特点。

因而,咱们需求一个额定的查看(例如hasOwnProperty)来保证一个给定的特点确实是用户供给的,而不是从原型承继的。

除此之外,由于特点解析机制在 JavaScrip t中的工作方式,在运转时对 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 并没有供给方便的API来获取 size,即特点的数量。并且,关于什么是一个方针的 size ,还有一些纤细的不同:

  • 假如只关心字符串、可枚举的键,那么能够用 Object.keys() 将键转化为数组,并取得其length

  • 假如k只想要不可枚举的字符串键,那么必须得运用 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.valuesObject.entry 来取得一个可枚举的字符串键(或/和值)的列表,并经过该列表进行迭代,这引入了一个额定的开支过程。

还有一个是 刺进方针的键的次序并不是按咱们的次序来的,这是一个很蛋疼的地方。在大多数浏览器中,整数键是按升序排序的,并优先于字符串键,即使字符串键是在整数键之前刺进的:

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

clear

没有简略的办法来删去一个方针的一切特点,咱们必须用 delete 操作符一个一个地删去每个特点,这在历史上是众所周知的慢。

查看特点是否存在

最终,咱们不能依靠点/括号符号来查看一个特点的存在,由于值本身可能被设置为 undefined。相反,得运用 Object.prototype.hasOwnPropertyObject.hasOwn

const obj = {a: undefined}
Object.hasOwn(obj, 'a') // true

Map

ES6 为咱们带来了 Map,首要,与只允许键值为字符串和 symbols 的 Object 不同,Map 支撑任何数据类型的键。

但更重要的是,Map 在用户界说的和内置的程序数据之间供给了一个洁净的别离,代价是需求一个额定的 Map.prototype.get 来获取对应的项。

Map 也供给了更好的人机工程学。Map 默许是一个可迭代的方针。这说明能够用 for ... of 轻松地迭代一个 Map,并做一些事情,比方运用嵌套的解构来从 Map 中取出第一个项。

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 中的一切项,它比 delete 操作符快得多。

功能差异

在 JavaScript 社区中,好像有一个共同的信念,即在大多数状况下,Map 要比 Object 快。有些人宣称经过从 Object 切换到 Map 能够看到显着的功能提高。

我在 LeetCode 上也证明了这种想法,关于数据量大的 Object 会超时,但 Map 上则不会。

但是,说 “Map 比 Object 快” 可能是算一种归纳性的,这两者一定有一些纤细的不同,咱们能够经过一些比方,把它找出来。

测验

测验用例有一个表格,主要测验 Object 和 Map 在刺进、迭代和删去数据的速度。

刺进和迭代的功能是以每秒的操作来衡量的。这儿运用了一个实用函数 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);
}

至于删去,只是要丈量运用 delete 操作符从一个方针中删去一切特点所需的时刻,并与相同巨细的 Map 运用 Map.prototype.delete 的时刻进行比较。也能够运用Map.prototype.clear,但这有悖于基准测验的目的,由于我知道它肯定会快得多。

在这三种操作中,我更重视刺进操作,由于它往往是我在日常工作中最常履行的操作。关于迭代功能,很难有一个全面的基准,由于咱们能够对一个给定的方针履行许多不同的迭代变体。这儿我只丈量 for ... in 循环。

在这儿运用了三种类型的 key。

  • 字符串,例如:Yekwl7caqejth7aawelo4。
  • 整数字符串,例如:123
  • Math.random().toString() 生成的数字字符串,例如:0.4024025689756525。

一切的键都是随机生成的,所以咱们不会碰到V8完成的内联缓存。我还在将整数和数字键添加到方针之前,运用 toString 清晰地将其转化为字符串,以防止隐式转化的开支。

最终,在基准测验开端之前,还有一个至少100ms的热身阶段,在这个阶段,咱们反复创立新的方针和 Map,并当即丢弃。

假如你也想玩,代码已经放在 CodeSandbox 上。

我从巨细为 100 个特点/项的 ObjectMap 开端,一直到 5000000,并让每种类型的操作继续运转 10000ms,看看它们之间的表现如何。下面是测验成果:

string keys

一般来说,当键为(非数字)字符串时,Map 在一切操作上都优于 Object

在 JavaScript 中,什么时分运用 Map 或胜过 Object

但纤细之处在于,当数量并不真实多时(低于100000),Map 在刺进速度上 是Object 的两倍,但当规划超越 100000 时,功能距离开端缩小。

在 JavaScript 中,什么时分运用 Map 或胜过 Object

上图显现了跟着条目数的添加(x轴),刺进率如何下降(y轴)。但是,由于X轴扩展得太宽(从100 到 1000000),很难分辩这两条线之间的距离。

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

在 JavaScript 中,什么时分运用 Map 或胜过 Object

能够清楚地看出这两条线正在重合。

这儿又做了一张图,画出了在刺进速度上 Map 比 Object 快多少。你能够看到 Map 开端时比 Object 快 2 倍左右。然后跟着时刻的推移,功能距离开端缩小。最终,当巨细增长到 5000000时,Map 只快了 30%。

在 JavaScript 中,什么时分运用 Map 或胜过 Object

虽然咱们中的大多数人永远不会在一个 Object 或 Map 中具有超越1 00 万的条数据。关于几百或几千个数据的规划,Map 的功能至少是 Object 的两倍。因而,咱们是否应该就此打住,并开端重构咱们的代码库,悉数选用 Map?

这不太靠谱……或许至少不能期望咱们的应用程序变得快 2 倍。记住咱们还没有探究其他类型的键。下面咱们看一下整数键。

integer keys

我之所以特别想在有整数键的方针上运转基准,是由于V8在内部优化了整数索引的特点,并将它们存储在一个独自的数组中,能够线性和接连地访问。但我找不到任何资源来证明它对 Map 也选用了同样的优化方式。

咱们首要尝试在 [0, 1000] 范围内的整数键。

在 JavaScript 中,什么时分运用 Map 或胜过 Object

如我所料,Object 这次的表现超越了 Map。它们的刺进速度比 Map 快65%,迭代速度快16%

接着, 扩大范围,使键中的最大整数为 1200。

在 JavaScript 中,什么时分运用 Map 或胜过 Object

好像现在 Map 的刺进速度开端比 Object 快一点,迭代速度快 5 倍。

现在,咱们只添加了整数键的范围,而不是 Object 和 Map 的实际巨细。让咱们加大 size,看看这对功能有什么影响。

在 JavaScript 中,什么时分运用 Map 或胜过 Object

当特点 size 为 1000 时,Object 最终比 Map 的刺进速度快 70%,迭代速度慢2倍。

我玩了一堆 Object/Map size 和整数键范围的不同组合,但没有想出一个清晰的形式。但我看到的整体趋势是,跟着 size 的增长,以一些相对较小的整数作为键值,Object 在刺进方面比Map 更有功能,在删去方面总是大致相同,迭代速度慢4或5倍。

Object 在刺进时开端变慢的最大整数键的阈值会跟着 Object 的巨细而增长。例如,当方针只有100个条数据,阈值是1200;当它有 10000 个条目时,阈值好像是 24000 左右。

numeric keys

最终,让咱们来看看最终一种类型的按键–数字键。

从技术上讲,之前的整数键也是数字键。这儿的数字键特指由 Math.random().toString() 生成的数字字符串。

成果与那些字符串键的状况相似。Map 开端时比 Object 快得多(刺进和删去快2倍,迭代快4-5倍),但跟着咱们规划的添加,距离也越来越小。

内存运用状况

基准测验的另一个重要方面是内存利用率.

由于我无法控制浏览器环境中的废物收集器,这儿决定在 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 那样存储特点描述符,比方 writable/enumerable/configurable

总结

那么,咱们能从这一切中得到什么呢?

  • Map 比 Object 快,除非有小的整数、数组索引的键,并且它更节省内存。

  • 假如你需求一个频频更新的 hash map,请运用 Map;假如你想一个固定的键值调集(即记录),请运用Object,并注意原型承继带来的陷阱。

代码布置后可能存在的BUG无法实时知道,过后为了解决这些BUG,花了大量的时刻进行log 调试,这边顺便给我们引荐一个好用的BUG监控东西 Fundebug。

交流

有梦想,有干货,微信搜索 【大迁国际】 重视这个在清晨还在刷碗的刷碗智。

本文 GitHub github.com/qq449245884… 已收录,有一线大厂面试完好考点、材料以及我的系列文章。