语法的差异
也许在开始之前,我们可以先来看看这两者在 JavaScript 里语法上的差异:
// Object
// 初始化
const object = {}
// 添加属性
object.name = 'object'
// 删除属性
delete object.name
// Map
// 初始化
const map = new Map()
// 添加属性
map.set('name', 'map')
// 删除属性
map.delete('name')
性能测试
这两个达到的效果是差不多的,但他们在语法上却不一样,Map
的语法看上去更符合函数式开发的风格,而 Object
的语法看上去更像是命令式的代码。我们再来看看他们在相同操作下执行 100000
次的情况下的性能差异:
// Object 的执行代码
const object = {}
for(let i = 0; i < 100000; i++){
object.a = i;
delete object.a
}
// Map 的执行代码
const map = new Map()
for(let i = 0; i < 100000; i++){
map.set('a', i)
map.delete('a')
}
除了这种测试方式之外,我还找到了另一种测试方式(example benchmark
)。当然了,这种基准测试的结果是不可靠的,因为它们依赖于 JavaScript 引擎的实现,也同时和浏览器的版本有关系,客户端的机器配置也会影响到测试结果,所以我们只能用它们来做一个参考。也就是说,你完全可以不相信任何人的测试结果,因为在 MDN
上的 Map 一节中也有提到过 Map
在频繁增删键值对的场景下是有特殊优化的,而 Object
则没有。
Map | Object | |
---|---|---|
省略… | 省略… | 省略… |
Performance | Performs better in scenarios involving frequent additions and removals of key-value pairs.(在频繁增删键值对的场景下表现更好。) | Not optimized for frequent additions and removals of key-value pairs.(在频繁添加和删除键值对的场景下未作出优化。) |
当然如果是只有这一点微乎其微的性能,那可能很难说服你使用 Map
,但接下来的内容可能可以改变你的想法。
内置键值对属性的问题
const obj = { toString: 'hello' }
const map = new Map([['toString', 'hello']])
console.log(obj.toString) // ?
console.log(map.toString) // ?
这里的 obj
和 map
都有一个 toString
属性,但是它们的值是不一样的,obj
的 toString
属性是一个字符串,而 map
的 toString
属性是一个函数,对于 obj
来讲,自行设置的 toString
会覆盖原型链上的属性,而 map
中设置的 toString
键却不会。我认为仅此一点就足以说明 Map
的优势了,因为 Map
中的键值对是不会被覆盖的,而 Object
中的键值对则会被覆盖。
迭代陷阱
在 JS 中迭代对象的时候,我们通常会使用 for...in
循环,但是这种方式有一个陷阱,就是它会遍历原型链上的属性,而 Map
中的 for...of
循环则不会遍历原型链上的属性,所以 Map
更加安全。
// 正常情况下你可能会这么做
for (const key in obj) {
console.log(key)
}
// 经验丰富一点的你可能会这么做
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
console.log(key)
}
}
但这么做依然是有问题的,因为 hasOwnProperty
方法也是从原型链上继承来的,没人能保证 hasOwnProperty
方法不会被覆盖。所以你最终可能需要这么做:
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
console.log(key)
}
}
当然如果你不想这么麻烦,则可以完全放弃 for...in
循环,进而采用 Object.keys
和 forEach
:
Object.keys(obj).forEach(key => {
console.log(key)
})
但 Map
就不存在这些问题,因为它的 for...of
循环不会遍历原型链上的属性,所以你可以放心的使用 for...of
循环来遍历 Map
。
for (const [key, value] of map) {
console.log(key, value)
}
键值对的顺序
在 JS 中,对象的键值对是无序的,但 Map
中的键值对是有序的,这意味着你可以通过 Map
来实现一个有序的对象。
const [
[key1, value1],
[key2, value2],
[key3, value3]
] = new Map([
['a', 1],
['b', 2],
['c', 3]
])
console.log(key1, value1) // a 1
console.log(key2, value2) // b 2
console.log(key3, value3) // c 3
数据拷贝
在 JS 中,对象的拷贝是浅拷贝,而 Map
的拷贝是深拷贝,这意味着你可以通过 Map
来实现一个深拷贝的对象。
const copied = {...obj}
const copied = Object.assign({}, obj)
简简单单对吧,但其实 Map 也很容易实现拷贝功能:
const copied = new Map(map)
而之所以可以这么做,主要是因为 Map
的构造函数接收了一个可迭代的 [[key, value]]
元组,所以 Map
也可以接收一个 Map
实例,这样就可以实现深拷贝了。当然,Map
也是支持使用浏览器内置原生支持的 structuredClone
方法来实现深拷贝的,但因为兼容性的问题,所以这里就不展开了。语法如下:
const copied = structuredClone(map)
const copied = new Map(map, [structuredClone])
Map 和 Object 的相互转换
const obj = { a: 1, b: 2, c: 3 }
// Object to Map
const map = new Map(Object.entries(obj))
// Map to Object
const obj = Object.fromEntries(map)
在你理解了这种转换欢喜以后,你就可以用对象的形式来构造一个 Map
了:
// 原本你可能会这么做
const myMap = new Map([['key', 'value'], ['keyTwo', 'valueTwo']])
// 现在你可以这么做
const myMap = new Map(Object.entries({
key: 'value',
keyTwo: 'valueTwo'
}))
// 你还可以封装一个函数
const makeMap = obj => new Map(Object.entries(obj))
const myMap = makeMap({
key: 'value',
keyTwo: 'valueTwo'
})
如果你需要使用 TypeScript
,那你可以这么做:
const makeMap = <Value = unknown>(obj: Record<string, Value>) => new Map<string, Value>(Object.entries(obj))
const myMap = makeMap({ key: 'value' }) // => Map<string, string>
键名的类型
在 JS 中,对象的键名只能是字符串或者 Symbol,而 Map
的键名可以是任意值,这意味着你可以使用 Map
来实现一个类似于 WeakMap
的功能,但是 Map
可以使用任意值作为键名,而 WeakMap
只能使用对象作为键名。
const map = new Map()
const key = {}
map.set(key, 'value')
console.log(map.get(key)) // value
这适用于想将数据扁平化从而建立一种数据联结的场景,你可以以整个对象作为键名,同时将对象的某个属性作为键值,这样你就既可以使用对象来获取键值,也可以使用键值来获取对象了。
Map 的其他属性
当然,Map 上还有很多有用的属性:
size
:返回Map
中的键值对的数量。clear()
:移除Map
对象中的所有键值对。keys()
:返回一个新的Iterator
对象,它按插入Map
对象中的顺序包含Map
对象中每个元素的键。values()
:返回一个新的Iterator
对象,它按插入Map
对象中的顺序包含Map
对象中每个元素的值。entries()
:返回一个新的Iterator
对象,它按插入Map
对象中的顺序包含Map
对象中每个元素的[key, value]
数组。forEach()
:对Map
对象中的每个键值对执行指定的操作。
Set
当我们讨论 Map
时,我们也可以讨论一下 Set
,因为它们是一对好基友,它们的功能也是相似的,但是 Set
只有键名,没有键值,所以 Set
中的键名和键值是相同的。
const set = new Set([1, 2, 3])
set.add(3)
set.delete(4)
set.has(5)
在某些情况下,Set
可以完全替代 Array
做一些等效操作,且拥有 更好的性能。当然这种结果可能并不准确,所以你可以自己测试一下。同样的,我们可以使用 WeakSet
来帮我们解决内存泄漏的问题。
const checkedTodos = new Set([todo1, todo2, todo3])
序列化
Map
和 Set
都是可序列化的,这意味着你可以使用 JSON.stringify
来序列化它们,但是你需要注意的是,Map
和 Set
中的键名和键值都必须是可序列化的,否则就会抛出错误。但是你有没有注意到,如果你想要打印出带有缩进格式或者其他任意风格的 JSON 时,你总是需要添加一个 null
作为参数,而这个 null
被称之为替换器。如果你想要使用替换器,你可以使用 JSON.stringify
的第二个参数来指定替换器,或者使用第三个参数来指定缩进空格的数量。这两个参数都是可选的,但是如果你想要使用第三个参数,你就必须使用第二个参数来指定替换器,即使你不需要使用替换器。
JSON.stringify(obj, null, 2)
我们可以写一个转换器来解析 Map 和 Set,这样我们就可以使用 JSON.stringify
来序列化它们了,同时增加一个特殊属性来标识它们的类型。
function replacer(key, value) {
if (value instanceof Map) {
return { __type: 'Map', value: Object.fromEntries(value) }
}
if (value instanceof Set) {
return { __type: 'Set', value: Array.from(value) }
}
return value
}
function reviver(key, value) {
if (value?.__type === 'Set') {
return new Set(value.value)
}
if (value?.__type === 'Map') {
return new Map(Object.entries(value.value))
}
return value
}
const obj = { set: new Set([1, 2]), map: new Map([['key', 'value']]) }
const str = JSON.stringify(obj, replacer)
const newObj = JSON.parse(str, reviver)
// { set: new Set([1, 2]), map: new Map([['key', 'value']]) }
什么时候该用哪个
最后,我们再来讨论一下,在什么时候、什么场景该用哪一个。
- 如果有一组具有明确定义的结构化对象(你能很清楚的知道它有哪些键名,且只会有这些键名)的时候,你应该使用
Object
。 - 如果你需要一个键值对的集合,且键名是不可变的,那么你应该使用
Map
。 - 如果你需要一个值的集合,且值是不可变的,不能重复的,那么你应该使用
Set
。 - 如果你需要一个值的集合,且值是可变的,那么你应该使用
WeakSet
。 - 如果你需要一个键值对的集合,且键名是可变的,那么你应该使用
WeakMap
。 - 如果你需要一个值的集合,同时元素的顺序很重要且不再乎是否有重复的值,那么你应该使用
Array
。 - 如果你需要一个值的集合,且值是不可变的,不能重复的,同时元素的顺序很重要,那么你应该使用
Array
,因为Set
是无序的。
以上。
更多链接: