乐趣区

关于javascript:在-JavaScript-中什么时候使用-Map-或胜过-Object

本文首发于微信公众号:大迁世界, 我的微信:qq449245884,我会第一工夫和你分享前端行业趋势,学习路径等等。
更多开源作品请看 GitHub https://github.com/qq449245884/xiaozhi,蕴含一线大厂面试残缺考点、材料以及我的系列文章。

在 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

但轻微之处在于,当数量并不真正多时(低于100000),Map 在插入速度上 是 Object 的两倍,但当规模超过 100000 时,性能差距开始放大。

上图显示了随着条目数的减少(x 轴),插入率如何降落(y 轴)。然而,因为 X 轴扩大得太宽(从 100 到 1000000),很难分辨这两条线之间的差距。

而后用对数比例来解决数据,做出了上面的图表。

能够分明地看出这两条线正在重合。

这里又做了一张图,画出了在插入速度上 Map 比 Object 快多少。你能够看到 Map 开始时比 Object 快 2 倍左右。而后随着工夫的推移,性能差距开始放大。最终,当大小增长到 5000000 时,Map 只快了 30%。

尽管咱们中的大多数人永远不会在一个 Object 或 Map 中领有超过 1 00 万的条数据。对于几百或几千个数据的规模,Map 的性能至多是 Object 的两倍。因而,咱们是否应该就此打住,并开始重构咱们的代码库,全副采纳 Map?

这不太靠谱 …… 或者至多不能冀望咱们的应用程序变得快 2 倍。记住咱们还没有摸索其余类型的键。上面咱们看一下整数键。

integer keys

我之所以特地想在有整数键的对象上运行基准,是因为 V8 在外部优化了整数索引的属性,并将它们存储在一个独自的数组中,能够线性和间断地拜访。但我找不到任何资源来证实它对 Map 也采纳了同样的优化形式。

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

如我所料,Object 这次的体现超过了 Map。它们的插入速度比 Map 快65%,迭代速度快16%

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

仿佛当初 Map 的插入速度开始比 Object 快一点,迭代速度快 5 倍。

当初,咱们只减少了整数键的范畴,而不是 Object 和 Map 的理论大小。让咱们加大 size,看看这对性能有什么影响。

当属性 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 https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试残缺考点、材料以及我的系列文章。

退出移动版