JavaScript 中什么时候使用 Map 更合适 (一)

244 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第15天,点击查看活动详情

原文地址:www.zhenghao.io/posts/objec…

在 JavaScript 中,对象使用起来很顺手,它使我们可以很方便把多个数据集合在一起。在 ES6 之后新增了一个数据结构:Map。它看起来像一个带有一些笨拙方法更有能力的 Object。可是,当开发者需要 hash map 时还是更习惯使用对象,直到他们意识一些场景下 key 不能是字符串才会想到使用 Map。因此,在现在的 JavaScript 社区中 Map 并没有被充分利用。

在这篇文章中,我将会逐一分解更应该考虑使用 Map 的原因以及它的性能优势。

在 JavaScript 中,对象是一个非常广泛的术语。几乎任何东西都可以作为对象,除了两种类型:nullundefined。在这篇文章中,对象仅仅代表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 上调用像 hasOwnPropertytoStringconstructor 等方法,即使我们没有在它上面显示的定义这些方法。

因为原型继承,现在我们的对象上有两种属性混合在一起: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.keysObject.valuesObject.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.hasOwnPropertyObject.hasOwn 来检测:

const obj = {a: undefined}

Object.hasOwn(obj, 'a') // true