本文首发于微信公众号:大迁国际, 我的微信: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
上调用hasOwnProperty
、toString
、constructor
等办法,虽然咱们从未在该方针上清晰界说这些办法。
由于原型承继,咱们现在有两种类型的特点被混杂了:存在于方针本身的特点,即它自己的特点,以及存在于原型链的特点,即承继的特点。
因而,咱们需求一个额定的查看(例如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.keys
、Object.values
和 Object.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.hasOwnProperty
或 Object.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 个特点/项的 Object
和 Map
开端,一直到 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 github.com/qq449245884… 已收录,有一线大厂面试完好考点、材料以及我的系列文章。