JavaScript 中的对象非常棒,但这并不意味着你应该老是用对象来处理。
const mapOfThings = {}
mapOfThings[myThing.id] = myThing
delete mapOfThings[myThing.id]
例如,如果你在 JavaScript 中使用对象来存储任意的键值对,而你会经常添加和删除,
那么你应该考虑使用 Map 而不是普通的对象。
const mapOfThings = new Map()
mapOfThings.set(myThing.id, myThing)
mapOfThings.delete(myThing.id)
性能
Map 专门优化了频繁添加和删除键,相比之下,对象却没有。
注意这是基准测试(在 Core i7 MBP 上用 Chrome v109 运行),不一定准确。
内置键问题
经被内置的大量键所污染的对象
const myMap = {}
myMap.valueOf // => [Function: valueOf]
myMap.toString // => [Function: toString]
myMap.hasOwnProperty // => [Function: hasOwnProperty]
myMap.isPrototypeOf // => [Function: isPrototypeOf]
myMap.propertyIsEnumerable // => [Function: propertyIsEnumerable]
myMap.toLocaleString // => [Function: toLocaleString]
myMap.constructor // => [Function: Object]
如果你试图访问上述这些属性,它们都有值,但对象是空的。
尴尬的迭代
JavaScript 迭代对象很麻烦。
例如
for (const key in myObject) {
// 🚩 无意中得到了对象继承的 key
}
你应该要这样做 👇
for (const key in myObject) {
if (myObject.hasOwnProperty(key)) {
// 🚩
}
}
但这仍然是有问题的,因为 myObject.hasOwnProperty 可以很容易地被覆盖。
例如 myObject.hasOwnProperty = () => explode()
因此,你应该这样做 👇
for (const key in myObject) {
if (Object.prototype.hasOwnProperty.call(myObject, key) {
// 😕
}
}
如果你希望你的代码看起来不那么乱,也可以用最近增加的 Object.hasOwn。
for (const key in myObject) {
if (Object.hasOwn(myObject, key) {
// 😐
}
}
不过也可以完全放弃 for 循环,只使用 Object.key 和 forEach。
Object.keys(myObject).forEach(key => {
// 😬
})
而 Map,不存在这些问题。
你可以使用标准的 for 循环以及解构来获得键和值。
for (const [key, value] of myMap) {
// 😍
}
我们也可以用 Object.entry 方法对对象做类似的事情。
for (const [key, value] of Object.entries(myObject)) {
// 🙂
}
此外,也可以只对 Map 的键或值进行迭代。
for (const value of myMap.values()) {
// 🙂
}
for (const key of myMap.keys()) {
// 🙂
}
Key 的顺序
Map 保留了 Key 的顺序,但对象却不是的。
所以我们可以直接从 map 上按顺序进行解构。
const [[firstKey, firstValue]] = myMap
我们也可以用它简单地实现一个O(1) 的 LRU Cache。
深拷贝
你可能会说,对象很容易浅拷贝
const copied = {...myObject}
const copied = Object.assign({}, myObject)
或者深拷贝
const copied = JSON.parse(JSON.stringify(myObject))
const copied = structuredClone(myObject)
但 Map 其实也同样容易浅拷贝
const copied = new Map(myMap)
当然你也可以使用 structuredClone 进行深拷贝
const deepCopy = structuredClone(myMap)
转换
使用 Object.fromEntries 可以轻易地将 Map 转换为对象。
const myObj = Object.fromEntries(myMap)
反过来也一样,使用 Object.entries,可以轻易地将对象转换为 Map
const myMap = new Map(Object.entries(myObj))
超级简单!
而且,现在我们知道了这一点,我们不再需要这样来构建 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' })
如果有用 TypeScript,也可以这样 👇
const makeMap = <V = unknown>(obj: Record<string, V>) =>
new Map<string, V>(Object.entries(obj))
const myMap = makeMap({ key: 'value' })
// => Map<string, string>
Key 类型
Map 的 key 支持更多类型
myMap.set({}, value)
myMap.set([], value)
myMap.set(document.body, value)
myMap.set(function() {}, value)
myMap.set(myDog, value)
开发体验也会更好 👇
const metadata = new Map()
metadata.set(myDomNode, {
internalId: '...'
})
metadata.get(myDomNode)
// => { internalId: '...' }
不过这确有一个问题。
如果我们的 Map 如果持有一个对象的引用,该对象将永远不会被垃圾收集,这可能会导致内存泄漏,除非手动移除该对象的引用。
const metadata = new Map()
let myTodo = {}
metadata.set(myTodo, {
focused: true
})
myTodo = null // 当没有其他引用时,也不会被垃圾回收
metadata.delete(myTodo) // 除非手动移除
WeakMap
而使用 WeakMap 类型。则完美地解决了上述内存泄漏的问题,因为它们持有一个对象的弱引用。
当其他的引用都被移除时,该对象将自动被垃圾收集。
const metadata = new WeakMap()
let myTodo = {}
metadata.set(myTodo, {
focused: true
})
myTodo = null // 当没有其他引用时,自动删除,被垃圾回收
更多
Map 也有很多方便的方法
map.clear() // 清理所有元素
map.size // 获取元素数量
map.keys() // 获取所有键
map.values() // 获取所有值
Set
Set 给我们提供了性能更好的方式来创建元素集合,我们可以轻松地添加、删除和查询集合是否包含某个元素
const set = new Set([1, 2, 3])
set.add(3)
set.delete(4)
set.has(5)
在某些情况下,Set 比数组性能更好
这个测试可能是不准的。
同样,WeakSet 也会帮助我们避免内存泄漏。
// 这里没有内存泄漏,队长 🫡
const checkedTodos = new WeakSet([todo1, todo2, todo3])
序列化
你可能会说,普通对象和数组会比 Map 和 Set 更容易序列化。
但其实让 JSON.stringify() / JSON.parse() 支持 Map 是很简单的
不知道有没有注意到,当你想漂亮地打印 JSON 时,你总是要添加 null 作为第二个参数?
你知道这个参数是干嘛的吗?
JSON.stringify(obj, null, 2)
// ^^^^ 这个是干嘛用的
它被称为 replacer,允许我们定义如何序列化。
我们可以用它轻松地将 Map 和 Set 转换为对象和数组进行序列化。
JSON.stringify(obj, (key, value) => {
// 转换 Map 为 对象
if (value instanceof Map) {
return Object.fromEntries(value)
}
// 转换 Set 为 数组
if (value instanceof Set) {
return Array.from(value)
}
return value
})
现在我们可以把它抽象成基本的可重复使用的函数,然后进行序列化。
const test = { set: new Set([1, 2, 3]), map: new Map([["key", "value"]]) }
JSON.stringify(test, replacer)
// => { set: [1, 2, 3], map: { key: value } }
我们也可以使用 JSON.parse() 通过其 reviver 参数,在解析时将数组转为 Set,将对象转为 Map
JSON.parse(string, (key, value) => {
if (Array.isArray(value)) {
return new Set(value)
}
if (value && typeof value === 'object') {
return new Map(Object.entries(value))
}
return value
})
还要注意的是,replacers 和 revivers 都是递归的,所以它们能够在我们的 JSON 树中的任何地方序列化和反序列化 Map和 Set
但是,我们的上述序列化实现只有一个小问题。
目前我们没有在解析时区分普通对象,数组,Map 或 Set,如果我们在其中混入普通对象和 Map,就会出现这样的结果。
const obj = { hello: 'world' }
const str = JSON.stringify(obj, replacer)
const parsed = JSON.parse(obj, reviver)
// Map<string, string>
我们可以通过创建一个特殊的属性来解决这个问题;
例如,__type,用来标识 Map 或 Set,而不是普通的对象或数组 👇
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']]) }
现在我们对 Set 和 Map 有了完整的序列化和反序列化支持
你应该用什么
当你有一组固定的键时,使用对象,它对快速读写非常快
const event = {
title: 'Builder.io Conf',
date: new Date()
}
当你可能需要频繁地添加和删除键时,使用 Map
const eventsMap = new Map()
eventsMap.set(event.id, event)
eventsMap.delete(event.id)
元素的顺序很重要,并且可能有重复的元素,使用数组
const myArray = [1, 2, 3, 2, 1]
元素唯一,且顺序不重要,使用 Set
const set = new Set([1, 2, 3])