前端必看:JavaScript Map 基础入门,告别对象局限,拥抱更灵活的数据结构
在现代前端开发中,高效的数据结构是构建强大、可维护应用的基础。我们最常打交道的可能就是数组(Array)和对象(Object)了。它们功能强大,用途广泛。然而,随着前端应用的复杂度日益提升,特别是在处理非传统键值对、需要保证元素顺序或频繁增删数据时,原生对象(Plain Object)的一些固有局限性开始显现。
正是在这样的背景下,ES6(ECMAScript 2015)引入了一系列新的内置对象和数据结构,其中 Map
就是一个非常重要的补充。对于前端开发者而言,深入理解和掌握 Map
不仅能让你写出更优雅、更具鲁棒性的代码,还能在某些场景下提供比对象更优的性能和灵活性。
本文将带你从零开始,全面、详细地了解 JavaScript Map
。我们将探讨它是什么、为什么需要它、它的基本操作、如何迭代,以及最重要的,它与原生对象(Plain Object)之间有什么核心区别和各自适用的场景。
一、Map 是什么?为何我们需要 Map?
1. Map 的定义
简单来说,Map
是一种存储键值对的集合。听起来和 JavaScript 的原生对象(Plain Object)很像?没错,它们都是键值对的集合。但 Map
与对象有几个关键的区别,这些区别正是它存在的价值所在。
Map
对象存储的键值对是按照插入顺序排列的。最重要的是,Map
的键可以是任意类型的值(包括对象、函数、基本类型甚至 NaN
和 null
),而原生对象的键则会被强制转换为字符串(或者 Symbols)。
从技术上讲,Map
是一个继承自 Map
原型的内置对象,它提供了更结构化的方式来操作键值对,并且其内部实现针对键值对操作进行了优化。
2. 原生对象的局限性
在 Map
出现之前,我们常常使用原生对象作为哈希表或字典来存储键值对。例如:
“`javascript
const userConfig = {
userId: ‘123’,
theme: ‘dark’,
settings: {
// …
}
};
console.log(userConfig.userId); // ‘123’
console.log(userConfig[‘theme’]); // ‘dark’
“`
这种方式非常方便,但也存在一些问题:
- 键的类型限制: 原生对象的键只能是字符串或者 ES6 引入的 Symbol 类型。如果你尝试使用其他类型的值作为键,它们会被隐式地转换为字符串。例如,数字
1
作为键时会变成字符串"1"
,对象{}
作为键时会变成字符串"[object Object]"
。这导致无法使用对象引用本身作为键,也无法区分数字键1
和字符串键"1"
。 - 键的顺序不确定(历史遗留,现代JS已改进,但Map更明确): 在 ES6 之前,原生对象的属性遍历顺序是不确定的。尽管现代 JavaScript 引擎(ES2015 后)已经保证了字符串键的插入顺序,但这个保证不如
Map
那样清晰和全面(Map 对所有类型的键都保持插入顺序)。依靠对象键的顺序有时会带来不必要的风险。 - 原型链干扰: 原生对象有原型链。这意味着你可能会意外地访问到继承的属性(例如
toString
或hasOwnProperty
),除非你使用Object.create(null)
创建一个没有原型链的对象,或者在使用时加上hasOwnProperty
判断。这给纯粹的数据存储带来了一些干扰。 - 获取键值对数量不直观: 要获取原生对象有多少个属性,你需要使用
Object.keys(obj).length
、Object.values(obj).length
或Object.entries(obj).length
。没有一个直接的属性可以像数组的length
那样方便。 - 性能问题(特定场景下): 对于频繁增删键值对,或者键不是字符串的场景,
Map
的性能通常优于原生对象,因为它内部实现更适合这些操作,且不受原型链查找的影响。
3. Map 带来的优势
Map
正是为了解决原生对象的这些局限而诞生的。使用 Map
,你可以:
- 使用任意类型作为键: 这可能是
Map
最重要的特性。你可以用一个DOM元素、一个对象、一个函数、一个数字甚至null
或undefined
作为键来存储对应的值。这在构建需要以对象引用作为索引的缓存或配置时非常有用。 - 保证键值对的插入顺序:
Map
会记住键值对的插入顺序,并且在迭代时(例如使用for...of
循环)会按照这个顺序返回。 - 方便地获取键值对数量:
Map
有一个size
属性,可以直接获取键值对的数量,非常方便。 - 更纯粹的数据存储:
Map
的数据存储在内部结构中,不会受到原型链的干扰,所有数据都存储在Map
实例本身。 - 迭代友好:
Map
是可迭代的(Iterable),可以直接配合for...of
循环使用,也可以方便地获取键的迭代器、值的迭代器或键值对的迭代器。 - 更好的增删性能: 在某些场景下,尤其是有大量增删操作或者使用非字符串键时,
Map
的性能通常优于原生对象。
正是这些优势,使得 Map
在需要处理非字符串键、关心数据顺序或频繁修改键值对的场景下,成为比原生对象更合适的选择。
二、创建 Map
创建 Map
非常简单,主要有两种方式:
1. 创建一个空的 Map
使用 new Map()
构造函数即可创建一个空的 Map
实例:
javascript
const myMap = new Map();
console.log(myMap); // Map(0) {}
console.log(myMap.size); // 0
2. 使用一个可迭代对象初始化 Map
Map
的构造函数可以接受一个可迭代对象(例如数组),该可迭代对象的每个元素都必须是一个包含两个元素的数组,第一个元素是键,第二个元素是值。
“`javascript
// 使用一个二维数组初始化
const initialData = [
[‘name’, ‘张三’],
[‘age’, 30],
[true, ‘布尔键’],
[{ id: 1 }, ‘对象键’]
];
const initializedMap = new Map(initialData);
console.log(initializedMap);
// Map(4) {
// ‘name’ => ‘张三’,
// ‘age’ => 30,
// true => ‘布尔键’,
// { id: 1 } => ‘对象键’
// }
console.log(initializedMap.size); // 4
// 也可以使用其他 Map 初始化
const anotherMap = new Map(initializedMap);
console.log(anotherMap); // Map(4) { … } (内容同 initializedMap)
// 甚至可以使用 Object.entries 转换对象来初始化 Map (注意键的类型转换)
const objToConvert = { a: 1, b: 2, 3: 3 };
const mapFromObject = new Map(Object.entries(objToConvert));
console.log(mapFromObject);
// Map(3) { ‘a’ => 1, ‘b’ => 2, ‘3’ => 3 }
// 注意:数字键 3 在 Object.entries 中会被转换为字符串 ‘3’,然后进入 Map
“`
这种初始化方式非常灵活,可以方便地从现有数据结构(如数组或经过转换的对象)创建 Map。
三、Map 的基本操作方法与属性
Map
对象提供了一系列直观的方法来操作其中的键值对:
1. map.set(key, value)
:添加或更新元素
这是向 Map
中添加新元素或更新现有元素的方法。如果 key
不存在,则会添加一个新的键值对;如果 key
已经存在,则会更新对应的 value
。
set()
方法返回 Map
实例本身,这使得你可以链式调用多个 set
方法。
“`javascript
const myMap = new Map();
// 添加新元素
myMap.set(‘stringKey’, ‘这是一个字符串键’);
myMap.set(123, ‘这是一个数字键’);
const objKey = { name: ‘keyObject’ };
myMap.set(objKey, ‘这是一个对象键’);
myMap.set(true, ‘这是一个布尔键’);
myMap.set(NaN, ‘这是一个NaN键’); // NaN 可以作为键
console.log(myMap);
// Map(5) {
// ‘stringKey’ => ‘这是一个字符串键’,
// 123 => ‘这是一个数字键’,
// { name: ‘keyObject’ } => ‘这是一个对象键’,
// true => ‘这是一个布尔键’,
// NaN => ‘这是一个NaN键’
// }
// 注意:不同的对象引用作为键是不同的键
myMap.set({ name: ‘keyObject’ }, ‘这是另一个对象键’);
console.log(myMap.size); // 6 – 因为 { name: 'keyObject' }
是一个新的对象引用
// 更新现有元素
myMap.set(‘stringKey’, ‘更新后的字符串值’);
console.log(myMap.get(‘stringKey’)); // ‘更新后的字符串值’
// 链式调用
myMap.set(‘key1’, ‘value1’)
.set(‘key2’, ‘value2’)
.set(‘key3’, ‘value3’);
console.log(myMap.size); // 9
“`
2. map.get(key)
:获取元素的值
根据 key
获取对应的 value
。如果 key
在 Map
中不存在,则返回 undefined
。
需要注意的是,对于对象作为键的情况,必须传入同一个对象引用才能获取到对应的值。
“`javascript
const myMap = new Map();
myMap.set(‘name’, ‘张三’);
myMap.set(123, ‘数字值’);
const user = { id: 1 };
myMap.set(user, ‘用户信息’);
console.log(myMap.get(‘name’)); // ‘张三’
console.log(myMap.get(123)); // ‘数字值’
console.log(myMap.get(‘nonexistent’)); // undefined
console.log(myMap.get(user)); // ‘用户信息’
// 尝试使用一个新的对象引用获取值,会失败
const anotherUser = { id: 1 };
console.log(myMap.get(anotherUser)); // undefined – 因为 anotherUser 和 user 是不同的对象引用
“`
3. map.has(key)
:检查键是否存在
判断 Map
中是否存在指定的 key
。返回一个布尔值。
这比 map.get(key) !== undefined
更可靠,因为 Map
中可能存在值为 undefined
的元素。
“`javascript
const myMap = new Map();
myMap.set(‘name’, ‘张三’);
myMap.set(123, undefined); // 值为 undefined 的元素
const user = { id: 1 };
myMap.set(user, ‘用户信息’);
console.log(myMap.has(‘name’)); // true
console.log(myMap.has(123)); // true
console.log(myMap.has(‘nonexistent’)); // false
console.log(myMap.has(user)); // true
console.log(myMap.has({ id: 1 })); // false – 新的对象引用
“`
4. map.delete(key)
:删除元素
根据 key
删除对应的键值对。如果删除成功(即该 key
存在于 Map 中),返回 true
;如果 key
不存在,则返回 false
。
“`javascript
const myMap = new Map();
myMap.set(‘name’, ‘张三’);
myMap.set(‘age’, 30);
myMap.set(‘city’, ‘北京’);
console.log(myMap.size); // 3
console.log(myMap.delete(‘age’)); // true – 删除成功
console.log(myMap.size); // 2
console.log(myMap.has(‘age’)); // false
console.log(myMap.delete(‘nonexistent’)); // false – 键不存在,删除失败
console.log(myMap.size); // 2
“`
5. map.clear()
:清空 Map
移除 Map
中的所有键值对,使其变为空 Map。
“`javascript
const myMap = new Map([[‘a’, 1], [‘b’, 2]]);
console.log(myMap.size); // 2
myMap.clear();
console.log(myMap.size); // 0
console.log(myMap); // Map(0) {}
“`
6. map.size
:获取 Map 的大小
这是一个属性(不是方法),返回 Map
中键值对的数量。
“`javascript
const myMap = new Map();
console.log(myMap.size); // 0
myMap.set(‘x’, 1).set(‘y’, 2).set(‘z’, 3);
console.log(myMap.size); // 3
myMap.delete(‘x’);
console.log(myMap.size); // 2
myMap.clear();
console.log(myMap.size); // 0
“`
四、Map 的迭代
Map
是一个可迭代对象(Iterable),这意味着你可以使用多种方式遍历其包含的键值对。Map 的迭代顺序是按照元素插入的顺序进行的。
1. 使用 for...of
循环
for...of
循环是遍历可迭代对象的首选方式。默认情况下,for...of
直接用于 Map
实例时,会迭代其所有的键值对,每个键值对以一个 [key, value]
数组的形式返回。
“`javascript
const myMap = new Map([
[‘name’, ‘张三’],
[123, ‘数字键的值’],
[{ id: 1 }, ‘对象键的值’]
]);
console.log(‘— 遍历所有键值对 —‘);
for (const pair of myMap) {
console.log(pair);
}
// 输出:
// [ ‘name’, ‘张三’ ]
// [ 123, ‘数字键的值’ ]
// [ { id: 1 }, ‘对象键的值’ ]
// 使用数组解构直接获取键和值
console.log(‘— 遍历所有键和值 —‘);
for (const [key, value] of myMap) {
console.log(键: ${key}, 值: ${value}
);
}
// 输出:
// 键: name, 值: 张三
// 键: 123, 值: 数字键的值
// 键: [object Object], 值: 对象键的值 <– 注意对象的默认字符串表示
``
{ id: 1 }
(修正:上例中对象键在 console.log 中直接显示对象引用,但在字符串模板中会默认转换为
“[object Object]”。如果直接打印
key本身,会看到是对象引用
{ id: 1 }`)
javascript
// 正确显示对象键
console.log('--- 遍历所有键和值 (对象键示例) ---');
for (const [key, value] of myMap) {
if (typeof key === 'object') {
console.log(`键 (对象): ${JSON.stringify(key)}, 值: ${value}`);
} else {
console.log(`键: ${key}, 值: ${value}`);
}
}
// 输出:
// 键: name, 值: 张三
// 键: 123, 值: 数字键的值
// 键 (对象): {"id":1}, 值: 对象键的值
2. map.keys()
:获取键的迭代器
返回一个迭代器,可以按插入顺序遍历 Map 中所有的键。
“`javascript
const myMap = new Map([[‘a’, 1], [‘b’, 2], [‘c’, 3]]);
const keyIterator = myMap.keys();
console.log(keyIterator.next()); // { value: ‘a’, done: false }
console.log(keyIterator.next()); // { value: ‘b’, done: false }
console.log(keyIterator.next()); // { value: ‘c’, done: false }
console.log(keyIterator.next()); // { value: undefined, done: true }
// 通常配合 for…of 使用
console.log(‘— 遍历所有键 —‘);
for (const key of myMap.keys()) {
console.log(key);
}
// 输出: a, b, c
“`
3. map.values()
:获取值的迭代器
返回一个迭代器,可以按插入顺序遍历 Map 中所有的值。
“`javascript
const myMap = new Map([[‘a’, 1], [‘b’, 2], [‘c’, 3]]);
const valueIterator = myMap.values();
console.log(valueIterator.next()); // { value: 1, done: false }
console.log(valueIterator.next()); // { value: 2, done: false }
console.log(valueIterator.next()); // { value: 3, done: false }
console.log(valueIterator.next()); // { value: undefined, done: true }
// 通常配合 for…of 使用
console.log(‘— 遍历所有值 —‘);
for (const value of myMap.values()) {
console.log(value);
}
// 输出: 1, 2, 3
“`
4. map.entries()
:获取键值对的迭代器
返回一个迭代器,可以按插入顺序遍历 Map 中所有的键值对,每个键值对以 [key, value]
数组的形式返回。
注意:这是 for...of
循环直接用于 Map 实例时,默认调用的迭代器方法。
“`javascript
const myMap = new Map([[‘a’, 1], [‘b’, 2], [‘c’, 3]]);
const entryIterator = myMap.entries();
console.log(entryIterator.next()); // { value: [‘a’, 1], done: false }
console.log(entryIterator.next()); // { value: [‘b’, 2], done: false }
console.log(entryIterator.next()); // { value: [‘c’, 3], done: false }
console.log(entryIterator.next()); // { value: undefined, done: true }
// 配合 for…of 使用 (与直接 for…of Map 相同)
console.log(‘— 遍历所有键值对 (使用 entries) —‘);
for (const entry of myMap.entries()) {
console.log(entry);
}
// 输出: [‘a’, 1], [‘b’, 2], [‘c’, 3]
“`
5. map.forEach(callbackFn[, thisArg])
:使用回调函数遍历
forEach()
方法按照插入顺序为 Map 中的每个键值对执行一次 callbackFn
函数。
callbackFn
函数会被调用,传入三个参数:(value, key, map)
。注意,第一个参数是值,第二个参数是键,这与数组的 forEach
(value, index, array) 的顺序不同,但与 Map
的 entries()
迭代器返回的 [key, value]
顺序也不同,需要特别留意。
“`javascript
const myMap = new Map([[‘a’, 1], [‘b’, 2]]);
console.log(‘— 使用 forEach 遍历 —‘);
myMap.forEach((value, key, map) => {
console.log(键: ${key}, 值: ${value}
);
// console.log(map === myMap); // true
});
// 输出:
// 键: a, 值: 1
// 键: b, 值: 2
// forEach 不支持中途跳出循环 (break 或 continue),如果需要,应使用 for…of
“`
总结来说,对于简单的遍历,for...of
是最常用和推荐的方式,尤其是结合数组解构获取键和值。如果只需要遍历键或值,可以使用 map.keys()
或 map.values()
配合 for...of
。如果需要使用回调函数,或者在旧版环境中不方便使用 for...of
,可以使用 map.forEach()
。
五、Map 与原生对象(Plain Object)的核心区别深度对比
这是理解 Map 价值的关键部分。虽然它们都存储键值对,但内在机制和适用场景有着显著差异。
特性 | Map | 原生对象 (Plain Object) | 备注 |
---|---|---|---|
键的类型 | 任意类型 (基本类型、对象、函数等) | 字符串 或 Symbol (其他类型会被转为字符串) | Map 最核心优势,允许以对象引用等作为键。 |
键的顺序 | 保证插入顺序 | 字符串键保证插入顺序 (ES2015+),Symbol 键无特定顺序,非 Symbol 非字符串键顺序不确定 | Map 的顺序保证更全面和明确。 |
大小获取 | 直接通过 .size 属性获取 |
需要通过 Object.keys(obj).length 等方式获取 |
Map 更便捷。 |
迭代 | 直接可迭代 (for...of ),提供 keys() , values() , entries() 迭代器 |
需要通过 Object.keys() , Object.values() , Object.entries() 等方法获取键/值/条目数组,然后进行迭代 |
Map 的迭代更原生和直接。 |
添加/更新 | 使用 map.set(key, value) |
使用 obj[key] = value 或 obj.key = value |
语法差异,Map 方法名更明确。 |
获取 | 使用 map.get(key) |
使用 obj[key] 或 obj.key |
语法差异,Map 方法名更明确。 |
检查存在 | 使用 map.has(key) |
使用 key in obj 或 obj.hasOwnProperty(key) |
key in obj 会检查原型链,hasOwnProperty 更安全,但 Map 的 .has() 更简洁且不受原型链影响。 |
删除 | 使用 map.delete(key) 返回布尔值 |
使用 delete obj[key] 返回布尔值 |
功能类似,语法差异。Map 的 delete 语义更清晰。 |
清空 | 使用 map.clear() |
需要手动遍历或新建对象 | Map 的 clear 更便捷。 |
原型链影响 | 无原型链干扰 (数据存储在内部) | 受原型链影响 (除非 Object.create(null) ) |
Map 在纯粹数据存储方面更安全。 |
默认属性 | 仅有标准 Map 方法和属性 | 可能包含继承自 Object.prototype 的属性 |
避免意外访问到 toString , valueOf 等属性。 |
重点解释几个关键区别:
-
键的类型: 这是 Map 最根本的优势。
-
Object:
“`javascript
const obj = {};
obj[1] = ‘number 1’;
obj[‘1’] = ‘string 1’;
console.log(obj); // { ‘1’: ‘string 1’ } – 数字键 1 被转为字符串 ‘1’,覆盖了之前的 ‘1’ 键
console.log(obj[1]); // ‘string 1’
console.log(obj[‘1’]); // ‘string 1’const obj1 = {};
const key1 = { id: 1 };
const key2 = { id: 2 };
obj[key1] = ‘value 1’; // key1 被转为字符串 “[object Object]”
obj[key2] = ‘value 2’; // key2 也被转为字符串 “[object Object]”
console.log(obj); // { ‘[object Object]’: ‘value 2’ } – key2 覆盖了 key1
console.log(obj[key1]); // ‘value 2’
console.log(obj[key2]); // ‘value 2’
* **Map:**
javascript
const map = new Map();
map.set(1, ‘number 1’);
map.set(‘1’, ‘string 1’);
console.log(map); // Map(2) { 1 => ‘number 1’, ‘1’ => ‘string 1’ } – 数字 1 和字符串 ‘1’ 是不同的键
console.log(map.get(1)); // ‘number 1’
console.log(map.get(‘1’)); // ‘string 1’const map1 = new Map();
const key1 = { id: 1 };
const key2 = { id: 2 };
map1.set(key1, ‘value 1’); // key1 (对象引用) 作为键
map1.set(key2, ‘value 2’); // key2 (对象引用) 作为键
console.log(map1); // Map(2) { { id: 1 } => ‘value 1’, { id: 2 } => ‘value 2’ } – key1 和 key2 是不同的键
console.log(map1.get(key1)); // ‘value 1’
console.log(map1.get(key2)); // ‘value 2’
console.log(map1.get({ id: 1 })); // undefined – 这是另一个对象引用
“`
这个差异使得 Map 在需要以对象或函数等作为键来关联数据的场景下具有无可替代的优势,例如将 DOM 元素映射到其对应的事件处理器,或者将配置对象映射到处理逻辑。
-
-
原型链:
- Object: 当你访问
obj.key
或obj[key]
时,JavaScript 引擎会先在对象自身查找,如果找不到,会沿着原型链向上查找。这可能导致你意外地获取到原型链上的属性。例如:
javascript
const obj = {};
// 尽管 obj 自身没有 toString 属性
console.log(obj.toString); // [Function: toString] - 来自 Object.prototype
console.log('toString' in obj); // true
这在使用对象作为纯粹的数据字典时,需要额外使用obj.hasOwnProperty(key)
或Object.create(null)
来避免原型链的干扰。 - Map: Map 的数据存储在其内部,完全不受原型链影响。
map.get(key)
和map.has(key)
只会检查 Map 自身存储的键值对。
javascript
const map = new Map();
// map 自身没有 toString 方法作为数据
console.log(map.has('toString')); // false
这使得 Map 作为数据容器更加纯粹和安全。
- Object: 当你访问
-
迭代顺序:
- Object: 对于字符串键,ES2015 标准规定了
for...in
(非数字键)、Object.keys()
,Object.values()
,Object.entries()
,JSON.stringify()
的遍历顺序是按照创建时的插入顺序。但 Symbol 键的顺序没有强制规定,且for...in
会受到原型链影响。 - Map: Map 明确且可靠地保证所有类型的键都按照插入顺序进行迭代。这在需要维护数据顺序的场景下非常有用。
- Object: 对于字符串键,ES2015 标准规定了
-
性能:
虽然现代 JavaScript 引擎对原生对象进行了大量优化,但在某些特定场景下,Map 可能提供更好的性能:- 频繁地添加和删除键值对,尤其是大型集合。
- 使用非字符串键的场景。
- Map 的查找操作(
get
,has
)在某些实现中可能比对象的属性查找更稳定(不受原型链深度等因素影响)。
这并不是说 Map 总是比 Object 快,具体取决于引擎实现、数据规模和操作类型。但在 Map 设计的场景下,其性能优势更容易体现。
六、Map 的实际应用场景(前端视角)
基于上述优势,Map 在前端开发中有许多非常实用的场景:
-
以对象引用作为索引的缓存或配置:
-
DOM 元素关联数据: 需要将一些数据或状态关联到特定的 DOM 元素上,但又不想直接修改 DOM 元素的属性(例如使用
dataset
)。可以使用一个 Map,将 DOM 元素作为键,关联的数据作为值。
“`javascript
const elementDataMap = new Map();
const myButton = document.getElementById(‘myButton’);
const myDiv = document.getElementById(‘myDiv’);elementDataMap.set(myButton, { eventHandler: handleButtonClick, state: ‘active’ });
elementDataMap.set(myDiv, { config: { theme: ‘dark’ }, content: ‘…’ });// 获取关联数据
const buttonInfo = elementDataMap.get(myButton);
if (buttonInfo) {
console.log(buttonInfo.state); // ‘active’
}// 删除元素时可以方便地清理关联数据
// elementDataMap.delete(myButton);
这种方式比在 DOM 元素上添加自定义属性更规范,且不会引入内存泄漏风险(如果使用 WeakMap 则更能保证)。
javascript
* **组件实例关联配置:** 在一些库或框架中,可能需要将组件实例对象与它的内部状态、配置或 Promise 关联起来。
const componentStateMap = new Map();
const myComponent = new MyComponent();
componentStateMap.set(myComponent, { isLoading: false, data: null });
// … 可以在其他地方通过组件实例获取/修改状态
* **缓存计算结果:** 当需要缓存一个函数调用的结果,且函数的参数可能是对象时,Map 可以用参数对象本身作为键。
javascript
const cache = new Map();function calculateExpensive(params) {
if (cache.has(params)) {
console.log(‘从缓存获取结果’);
return cache.get(params);
}
console.log(‘正在计算…’);
// 假设这里是耗时计算
const result = params.value * 2 + params.offset;
cache.set(params, result); // 将结果存入缓存,键是 params 对象
return result;
}const p1 = { value: 10, offset: 5 };
console.log(calculateExpensive(p1)); // 正在计算… 25
console.log(calculateExpensive(p1)); // 从缓存获取结果 25const p2 = { value: 10, offset: 5 };
console.log(calculateExpensive(p2)); // 正在计算… 25 (因为 p2 是新的对象引用,与 p1 不同)
``
WeakMap`(如果键只需要被弱引用)。上面的例子说明了 Map 是基于引用相等判断键的。
*注意:* 如果需要以对象内容而不是引用作为键,需要自己实现哈希或序列化逻辑,或者使用
-
-
维护有序的键值对集合: 当你需要一个键值对集合,并且在迭代或处理时必须保持元素的插入顺序时,Map 是最佳选择。例如,按照添加顺序展示用户的配置项。
-
使用非字符串基本类型作为键: 当你需要使用数字、布尔值甚至
null
或NaN
作为键时,Map 可以直接支持,而原生对象会将它们转换为字符串。
“`javascript
const statusMap = new Map();
statusMap.set(0, ‘待处理’);
statusMap.set(1, ‘处理中’);
statusMap.set(2, ‘已完成’);
statusMap.set(true, ‘成功’);
statusMap.set(false, ‘失败’);console.log(statusMap.get(1)); // ‘处理中’
console.log(statusMap.get(true)); // ‘成功’
“` -
替代原生对象进行纯数据存储: 在某些场景下,如果一个对象仅仅用于存储数据,并且你担心原型链的干扰,或者需要方便地获取大小,Map 可以是一个更“安全”的替代方案,特别是当你使用
Object.create(null)
来创建“无原型对象”时,Map 的.size
和迭代能力会更方便。
七、相关概念:WeakMap 简介
与 Map
相似但有所不同的是 WeakMap
。WeakMap
也存储键值对,但有以下关键区别:
- 弱引用键:
WeakMap
的键只能是对象,并且这些对象是弱引用的。这意味着如果一个对象只被WeakMap
作为键引用,并且没有其他地方强引用它,那么垃圾回收机制就会回收这个对象,同时WeakMap
中对应的键值对也会被移除。这有助于防止内存泄漏。 - 不可迭代:
WeakMap
没有size
属性,也不能被迭代 (for...of
,keys()
,values()
,entries()
,forEach()
)。你只能通过已知的键去get
或has
对应的值。 - 只能使用对象作为键: 基本类型值不能作为
WeakMap
的键。
WeakMap
主要用于将一些私有数据或元数据关联到对象上,而又不想阻止这些对象被垃圾回收。例如,将一个DOM元素与其私有状态关联起来,当DOM元素从页面移除并被垃圾回收时,WeakMap 中对应的关联数据也会自动消失。
“`javascript
const elementState = new WeakMap();
const myElement = document.getElementById(‘someElement’);
elementState.set(myElement, { clicks: 0, active: false });
// 当 myElement 被移除且没有其他引用时,WeakMap 中的对应项也会被回收
// myElement = null; // 如果没有其他地方引用,下次垃圾回收时可能被回收
“`
因为 WeakMap
不可迭代,它不适合用于存储你需要遍历或获取所有项的数据集合,更适合用于存储对象的“附属信息”。
八、Map 与数组/对象之间的转换
在实际开发中,有时需要将 Map 转换为数组或对象,或者反过来。
1. Map 转换为数组
由于 Map 是可迭代的,你可以使用扩展运算符 (...
) 或 Array.from()
将其转换为一个包含 [key, value]
对的数组:
“`javascript
const myMap = new Map([[‘a’, 1], [‘b’, 2]]);
// 使用扩展运算符
const mapToArray1 = […myMap]; // [[‘a’, 1], [‘b’, 2]]
console.log(mapToArray1);
// 使用 Array.from
const mapToArray2 = Array.from(myMap); // [[‘a’, 1], [‘b’, 2]]
console.log(mapToArray2);
// 如果只需要键或值
const keysArray = […myMap.keys()]; // [‘a’, ‘b’]
const valuesArray = […myMap.values()]; // [1, 2]
console.log(keysArray);
console.log(valuesArray);
“`
2. Map 转换为对象
将 Map 转换为原生对象时需要注意,原生对象的键只能是字符串或 Symbol。如果 Map 的键包含非字符串或 Symbol 类型,这些键在转换为对象时会丢失它们的原始类型信息,通常会强制转换为字符串(默认为 "[object Object]"
),这可能导致信息丢失或键冲突。
ES2019 引入了 Object.fromEntries()
方法,可以将一个包含 [key, value]
对的数组转换为一个对象,这适用于 Map 转换:
“`javascript
const myMap = new Map([
[‘name’, ‘张三’],
[‘age’, 30],
[123, ‘数字键会变字符串’] // 这是一个非字符串键
]);
const mapToObject = Object.fromEntries(myMap);
console.log(mapToObject);
// 输出: { name: ‘张三’, age: 30, ‘123’: ‘数字键会变字符串’ }
// 注意:数字键 123 被强制转换为了字符串 ‘123’
“`
如果 Map 中有对象作为键,转换时会更复杂:
“`javascript
const objKey = { id: 1 };
const mapWithObjKey = new Map([
[‘a’, 1],
[objKey, ‘对象键的值’]
]);
const mapToObjectFail = Object.fromEntries(mapWithObjKey);
console.log(mapToObjectFail);
// 输出: { a: 1, ‘[object Object]’: ‘对象键的值’ }
// 对象键 { id: 1 } 被强制转换为了字符串 “[object Object]”,失去了其原始引用信息。
// 如果 Map 中有多个对象键,且它们的 toString() 结果都是 “[object Object]”,会发生冲突。
``
Object.fromEntries()` 将包含非字符串/Symbol 键的 Map 直接转换为原生对象。在这种情况下,原生对象无法准确表示 Map 的数据结构。
因此,**不推荐**使用
3. 数组转换为 Map
使用 Map 构造函数即可:
javascript
const arrayData = [['a', 1], ['b', 2], ['c', 3]];
const arrayToMap = new Map(arrayData);
console.log(arrayToMap); // Map(3) { 'a' => 1, 'b' => 2, 'c' => 3 }
4. 对象转换为 Map
可以使用 Object.entries()
将对象转换为一个 [key, value]
数组,然后再传递给 Map 构造函数。同样,这会遵循对象键的类型转换规则(非 Symbol 键会转为字符串)。
“`javascript
const myObject = { name: ‘张三’, age: 30, 123: ‘这是一个数字属性名’ };
const objectToMap = new Map(Object.entries(myObject));
console.log(objectToMap);
// Map(3) { ‘name’ => ‘张三’, ‘age’ => 30, ‘123’ => ‘这是一个数字属性名’ }
// 注意:对象属性名 123 被强制转换为了字符串 ‘123’ 在 Object.entries 阶段
“`
九、总结与最佳实践
通过以上介绍,我们看到了 Map
作为 JavaScript 新增数据结构的强大之处和独特价值。它不仅解决了原生对象在键类型、迭代顺序和原型链等方面的局限性,还提供了更简洁直观的 API (set
, get
, has
, delete
, size
) 和更好的迭代支持。
什么时候应该考虑使用 Map 而不是原生对象?
- 你需要使用非字符串或 Symbol 类型的值作为键(例如对象引用、DOM 元素、数字、布尔值)。
- 你需要保证键值对的插入顺序。
- 你需要方便地获取集合的大小(键值对的数量)。
- 你需要频繁地添加或删除键值对,尤其是在大型集合中。
- 你需要避免原型链的干扰,进行纯粹的数据存储。
- 你希望代码语义更清晰,使用
map.get()
、map.set()
等方法而不是obj[key]
。
什么时候原生对象可能仍然是更好的选择?
- 你的所有键都是字符串或 Symbols,且对顺序要求不严格(或只需要字符串键的插入顺序)。
- 你主要使用字面量语法
{}
创建对象,或者需要使用点语法.key
访问属性(Map 不支持点语法访问)。 - 你需要将数据通过
JSON.stringify()
轻松序列化(Map 不直接支持 JSON 序列化,需要手动转换)。 - 你需要利用对象的方法,或者需要模拟传统的面向对象结构。
- 性能不是关键考量,且数据量不大。
在实际开发中,Map 和原生对象各有优劣,并非谁完全取代谁。理解它们的区别和适用场景,能够帮助你选择最合适的数据结构,写出更健壮、更高效、更易维护的代码。
对于前端开发者而言,特别是随着应用复杂度的增加,遇到需要以对象或非字符串基本类型作为索引、需要关心数据顺序、或需要更纯粹的数据字典时,Map 无疑是你工具箱中一个不可或缺的利器。熟练掌握它的基本用法和特性,将让你的代码更上一层楼。
希望这篇文章能帮助你全面、深入地理解 JavaScript Map,并将其灵活运用到你的前端开发实践中!