携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第15天,点击查看活动详情
原文地址:www.zhenghao.io/posts/objec…
在 JavaScript 中,对象使用起来很顺手,它使我们可以很方便把多个数据集合在一起。在 ES6 之后新增了一个数据结构:Map。它看起来像一个带有一些笨拙方法更有能力的 Object。可是,当开发者需要 hash map 时还是更习惯使用对象,直到他们意识一些场景下 key 不能是字符串才会想到使用 Map。因此,在现在的 JavaScript 社区中 Map 并没有被充分利用。
在这篇文章中,我将会逐一分解更应该考虑使用 Map 的原因以及它的性能优势。
在 JavaScript 中,对象是一个非常广泛的术语。几乎任何东西都可以作为对象,除了两种类型:
null和undefined。在这篇文章中,对象仅仅代表plain object,通过左右括号定义的。
TL;DR:
- 当你创建的对象有固定和有线数量的属性或字段时则使用
Object,例如配置对象,或任何通常只使用一次的对象。 - 当你的字典或哈希映射条数多变且更新频繁,或创建时关键字不确定,则使用
Map,例如event emitter。 - 更具我的测量标准,除了 key 为小整数字符串时,其他情况在插入、删除和迭代时, 同等大小的
Map确实比Object更高效而且消耗更小的内存。
为何 Object 不足以试用 hash map 的用例
当对象作为 hash map 时,最明显的缺点是它只允许 key 为 string 和 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 等方法,即使我们没有在它上面显示的定义这些方法。
因为原型继承,现在我们的对象上有两种属性混合在一起:1. 对象上直接定义的(自身的);2.原型链上的(继承的属性)。结果就是,我们需要另外一个检测方法(例如:hasOwnProperty)来保证属性确实是用户添加的而不是从原型继承来的。
最重要的是,由于 JavaScript 中属性的查找原理,运行时任何对 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 并没有提供趁手的方法来获取其大小,就是属性的个数。但是,获取对象大小的方法有一些细微的差别:
- 若你关心的是字符串、可枚举的属性,你可以通过
Object.keys()把属性的 key 转换为数组并获取其长度。 - 如果你更新不可枚举字符串的 key ,你可以使用
Object.getOwnPropertyNames来获取 key 的列表,然后得到其长度。 - 若你更关心 symbol 的 key,则需要使用
getOwnPropertySymbols来获取所有的 symbol 的 key 或通过Reflect.ownKeys来同时获得字符串和 symbol 的 key,而不管其是否可枚举。
上面所有的方法都是 O(n) 复杂度,因为它们首先都需要构建一个数组然后获取它的长度。
迭代
循环对象会遇到类似的复杂问题。
我们可以使用古老的 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.entries 来获取一个可枚举的列表,然后循环遍历它,但这会增加额外的步骤和开销。
最后,key 的顺兴也是臭名昭著不受待见的。在多数浏览器中,数字类型的 key 有更高的优先级,会排在字符串类型 key 之前,即使字符串类型的 key 先于数字类型的 key 添加。
const obj = {}
obj.foo = 'first'
obj[2] = 'second'
obj[1] = 'last'
console.log(obj) // {1: 'last', 2: 'second', foo: 'first'}
clear
从对象上删除所有的属性并不是一件容易的事,你必须通过 delete 操作符一个个的删除,这很早就知道是一个很慢的操作。但是,我的基准测试表明它的性能实际上并不比 Map.prototype.delete 慢一个数量级,稍后会详细说明。
检测属性是否存在
最后,我们不能依赖通过 . 和 [] 符号来检测属性是否存在,因为属性值可能被设置为 undefined。取而代之我们必须使用 Object.prototype.hasOwnProperty 或 Object.hasOwn 来检测:
const obj = {a: undefined}
Object.hasOwn(obj, 'a') // true