共计 6846 个字符,预计需要花费 18 分钟才能阅读完成。
本文首发于微信公众号:大迁世界, 我的微信: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
上调用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 https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试残缺考点、材料以及我的系列文章。