TypeScript Map 对象详解 – wiki基地


TypeScript 中的 Map 对象:深入解析现代键值对存储方案

在构建现代 Web 应用或 Node.js 服务时,我们经常需要处理键值对形式的数据。长期以来,JavaScript 开发者主要依赖普通对象(Object)来实现这一需求。然而,随着语言的演进,ECMAScript 6(ES6)引入了一个新的内置对象 Map,专为存储键值对而设计,并在许多方面提供了优于普通对象的特性。

在 TypeScript 这个强大的 JavaScript 超集环境中,我们不仅可以使用 Map,还能利用其类型系统为 Map 添加严格的类型定义,从而提升代码的可读性、可维护性和健壮性。

本文将深入探讨 TypeScript 中的 Map 对象,包括其基本用法、核心特性、与普通对象的详细比较、高级应用场景以及如何在 TypeScript 中充分利用其类型优势。

1. 认识 Map:什么是 Map 对象?

Map 对象是 ECMAScript 2015 (ES6) 中引入的一种集合类型,它存储键值对。与普通对象不同的是,Map 的键可以是任意值(包括对象或原始值),而普通对象的键通常只能是字符串或 Symbol(在对象被用作哈希映射时,非字符串/Symbol 键会被强制转换为字符串)。

Map 维护其元素的插入顺序,并且提供了方便的方法来操作这些键值对,例如设置(set)、获取(get)、检查是否存在(has)、删除(delete)以及获取大小(size)。

核心特点概览:

  • 任意类型键: 键可以是任意数据类型(原始值、对象、函数等)。
  • 有序: 元素按照插入顺序排列。
  • .size 属性: 直接获取键值对的数量。
  • 高性能操作: 对于频繁的添加和删除操作,Map 通常表现更好。
  • 可迭代: 可以直接使用 for...of 循环或 forEach 方法遍历键、值或键值对。
  • 与原型链分离: Map 的数据存储在其内部结构中,不会受到原型链上属性的影响。

2. Map 的基本用法

创建 Map 对象非常简单,只需要使用 new Map() 构造函数。构造函数还可以接受一个可选的参数:一个可迭代对象(如数组),其元素是 [key, value] 对的数组。

2.1 创建 Map

“`typescript
// 创建一个空的 Map
const myMap = new Map();

// 使用数组初始化 Map
const initialData: [string, number][] = [
[‘apple’, 1],
[‘banana’, 2],
[‘cherry’, 3]
];
const fruitMap = new Map(initialData);

console.log(fruitMap); // Map { ‘apple’ => 1, ‘banana’ => 2, ‘cherry’ => 3 }
“`

在 TypeScript 中,我们通常会指定 Map 的键和值的类型,使用泛型语法 Map<KeyType, ValueType>。这提供了强大的类型检查能力。

2.2 添加和设置元素 (.set())

使用 .set(key, value) 方法向 Map 中添加或更新元素。如果键已经存在,.set() 会更新该键对应的值;如果键不存在,则添加新的键值对。.set() 方法返回 Map 实例本身,这允许链式调用。

“`typescript
const settings = new Map();

settings.set(‘theme’, ‘dark’);
settings.set(‘fontSize’, 16);
settings.set(‘notificationsEnabled’, true);

console.log(settings);
// Map { ‘theme’ => ‘dark’, ‘fontSize’ => 16, ‘notificationsEnabled’ => true }

// 更新现有键的值
settings.set(‘theme’, ‘light’);
console.log(settings.get(‘theme’)); // light

// 链式调用
settings.set(‘language’, ‘en’).set(‘country’, ‘US’);
console.log(settings.get(‘language’)); // en
“`

2.3 获取元素 (.get())

使用 .get(key) 方法获取指定键的值。如果键不存在,返回 undefined

“`typescript
const userMap = new Map();

const user1 = { name: ‘Alice’, age: 30 };
const user2 = { name: ‘Bob’, age: 25 };

userMap.set(101, user1);
userMap.set(102, user2);

console.log(userMap.get(101)); // { name: ‘Alice’, age: 30 }
console.log(userMap.get(103)); // undefined
“`

2.4 检查元素是否存在 (.has())

使用 .has(key) 方法检查 Map 中是否存在指定的键。返回布尔值 truefalse

“`typescript
const config = new Map();
config.set(‘env’, ‘production’);

console.log(config.has(‘env’)); // true
console.log(config.has(‘debug’)); // false
“`

2.5 删除元素 (.delete())

使用 .delete(key) 方法删除指定键及其对应的值。如果删除成功(即该键存在),返回 true;如果键不存在,返回 false

“`typescript
const taskStatus = new Map();
taskStatus.set(‘taskA’, ‘pending’);
taskStatus.set(‘taskB’, ‘completed’);

console.log(taskStatus.size); // 2
console.log(taskStatus.delete(‘taskA’)); // true
console.log(taskStatus.size); // 1
console.log(taskStatus.has(‘taskA’)); // false
console.log(taskStatus.delete(‘taskC’)); // false
“`

2.6 清空 Map (.clear())

使用 .clear() 方法移除 Map 中的所有键值对。

“`typescript
const tempData = new Map();
tempData.set(1, ‘data1’);
tempData.set(2, ‘data2’);

console.log(tempData.size); // 2
tempData.clear();
console.log(tempData.size); // 0
console.log(tempData.has(1)); // false
“`

2.7 获取 Map 的大小 (.size)

.size 属性返回 Map 中键值对的数量。

“`typescript
const emptyMap = new Map();
console.log(emptyMap.size); // 0

const filledMap = new Map([[‘a’, 1], [‘b’, 2]]);
console.log(filledMap.size); // 2
“`

3. Map 的核心特性:键的灵活性

Map 最显著的优势之一是其键可以是任意数据类型。这包括:

  • 原始值: 字符串、数字、布尔值、nullundefinedSymbol
  • 对象: 普通对象、数组、函数、日期、DOM 元素、以及自定义类的实例等。

3.1 原始值作为键

“`typescript
const keyTypes = new Map(); // 使用 any 类型演示不同键类型

keyTypes.set(‘a string’, ‘string key’);
keyTypes.set(123, ‘number key’);
keyTypes.set(true, ‘boolean key’);
keyTypes.set(null, ‘null key’);
keyTypes.set(undefined, ‘undefined key’);

const mySymbol = Symbol(‘unique’);
keyTypes.set(mySymbol, ‘symbol key’);

console.log(keyTypes.get(‘a string’)); // string key
console.log(keyTypes.get(123)); // number key
console.log(keyTypes.get(true)); // boolean key
console.log(keyTypes.get(null)); // null key
console.log(keyTypes.get(undefined)); // undefined key
console.log(keyTypes.get(mySymbol)); // symbol key
console.log(keyTypes.size); // 6
“`

3.2 对象作为键

这是 Map 与普通对象最大的区别之一。在普通对象中,对象键会被强制转换为字符串 "[object Object]",导致冲突。但在 Map 中,对象键使用的是对象的引用进行比较。这意味着两个不同的对象实例,即使它们具有相同的属性,也会被视为不同的键。

“`typescript
const objectKeyMap = new Map();

const obj1 = { id: 1 };
const obj2 = { id: 2 };
const obj3 = { id: 1 }; // 内容与 obj1 相似,但它是不同的对象实例

objectKeyMap.set(obj1, ‘Value for obj1’);
objectKeyMap.set(obj2, ‘Value for obj2’);

console.log(objectKeyMap.get(obj1)); // Value for obj1
console.log(objectKeyMap.get(obj2)); // Value for obj2
console.log(objectKeyMap.get(obj3)); // undefined (obj3 是不同的对象引用)

console.log(objectKeyMap.has(obj1)); // true
console.log(objectKeyMap.has({ id: 1 })); // false (这是一个新的对象引用)

const arrKey = [1, 2, 3];
objectKeyMap.set(arrKey, ‘Value for array’);
console.log(objectKeyMap.get(arrKey)); // Value for array
console.log(objectKeyMap.get([1, 2, 3])); // undefined (这是一个新的数组引用)

// 函数也可以作为键
const funcKey = () => {};
objectKeyMap.set(funcKey, ‘Value for function’);
console.log(objectKeyMap.get(funcKey)); // Value for function
“`

使用对象作为键在需要将数据与特定对象实例关联时非常有用,例如:

  • 将配置或状态与 DOM 元素关联。
  • 将缓存结果与函数参数对象关联。
  • 在图结构中,将节点对象与其邻居列表关联。

注意: 当使用对象作为 Map 的键时,请记住键的查找和删除依赖于对象的引用。如果你丢失了对象的引用,将无法通过该对象获取或删除 Map 中的相应条目。

4. 迭代 Map

Map 对象是可迭代的,提供了多种遍历其元素的方式。迭代顺序是元素插入的顺序。

4.1 使用 for...of 遍历键值对、键或值

Map 的默认迭代器返回 [key, value] 对。

“`typescript
const myMap = new Map([[‘a’, 1], [‘b’, 2], [‘c’, 3]]);

// 遍历键值对
console.log(‘Iterating entries:’);
for (const entry of myMap) {
console.log(entry); // [ ‘a’, 1 ], [ ‘b’, 2 ], [ ‘c’, 3 ]
}

// 使用解构遍历键值对
console.log(‘\nIterating entries with destructuring:’);
for (const [key, value] of myMap) {
console.log(Key: ${key}, Value: ${value});
}
// Key: a, Value: 1
// Key: b, Value: 2
// Key: c, Value: 3

// 遍历键
console.log(‘\nIterating keys:’);
for (const key of myMap.keys()) {
console.log(key); // a, b, c
}

// 遍历值
console.log(‘\nIterating values:’);
for (const value of myMap.values()) {
console.log(value); // 1, 2, 3
}
“`

.keys().values().entries() 方法返回 MapIterator 对象,这也是可迭代的。

4.2 使用 .forEach() 方法

Map.forEach() 方法提供了一种类似数组 .forEach() 的迭代方式,它接受一个回调函数,该函数对 Map 中的每个键值对执行一次。回调函数的参数顺序是 (value, key, map)

“`typescript
const productPrices = new Map();
productPrices.set(‘Laptop’, 1200);
productPrices.set(‘Keyboard’, 75);
productPrices.set(‘Mouse’, 25);

console.log(‘\nUsing forEach:’);
productPrices.forEach((price, product, map) => {
console.log(${product}: $${price});
// console.log(map === productPrices); // true
});
// Laptop: $1200
// Keyboard: $75
// Mouse: $25
“`

5. Map 与普通对象的比较

理解 Map 的最佳方式之一是将其与传统的普通对象进行比较。虽然两者都可以存储键值对,但它们在设计、特性和用途上存在显著差异。

特性 Object (作为键值对) Map 优势方
键类型 字符串或 Symbol。非字符串/Symbol 键会被强制转换。 任意类型(原始值、对象、函数等)。基于引用比较。 Map
迭代顺序 整数键按数字顺序,其他键(字符串/Symbol)顺序不保证(ES6+ 对字符串键有一定保证,但非完全按插入顺序)。 保证按照元素的插入顺序迭代。 Map
获取大小 需要手动计算,如 Object.keys(obj).length 直接使用 .size 属性。 Map
原型链 继承自 Object.prototype,可能包含默认属性。需要使用 hasOwnPropertyObject.create(null) 避免原型链干扰。 不受原型链影响,只包含自身元素。 Map
删除元素 delete obj.key; 可能在某些 JavaScript 引擎中性能不稳定,或影响后续优化。 .delete(key) 方法,性能通常更稳定。 Map
添加元素 obj.key = value; .set(key, value); 基本相当
获取元素 obj.key;obj[key]; .get(key); 基本相当
检查存在 obj.hasOwnProperty(key);key in obj; .has(key); 基本相当
序列化 可以使用 JSON.stringify() 直接序列化(仅限字符串键和可序列化值)。 不能直接使用 JSON.stringify() 序列化。需要手动转换成数组或对象。 Object
语法 提供了方便的对象字面量语法 { key: value } 需要使用 new Map().set() 方法。 Object
内存开销 对于大量小规模的键值对,可能略低于 Map 对于大量元素,可能会有额外的内存开销用于维护插入顺序等。 视情况而定
性能 查找/插入/删除通常是 O(1) 平均。但对于非字符串键或频繁删除,Map 可能更优。 查找/插入/删除通常是 O(1) 平均。提供更一致的性能。 视情况而定,Map 在某些场景下更稳定。

总结何时使用 Map vs. Object

优先使用 Map 当:

  • 你需要使用非字符串或 Symbol 作为键(尤其是对象或数组)。
  • 你需要元素的插入顺序得到保证。
  • 你需要频繁地添加和删除键值对。
  • 你需要方便地获取键值对的数量。
  • 你需要避免原型链带来的潜在干扰。

优先使用 Object 当:

  • 你只需要字符串或 Symbol 作为键。
  • 你主要关心数据的结构化(对象字面量更直观)。
  • 你需要使用 JSON 进行数据的序列化和反序列化。
  • 你在处理静态或配置数据,不涉及大量动态的键操作。
  • 你需要利用对象的便利语法糖(如属性访问器)。

6. 高级概念和相关类型

6.1 使用对象引用作为键的注意事项

再次强调,当对象被用作 Map 的键时,比较的是对象的内存引用。

“`typescript
const referenceMap = new Map();

let user = { name: ‘Alice’ };
referenceMap.set(user, ‘User object’);

console.log(referenceMap.get(user)); // User object

user = { name: ‘Bob’ }; // 创建了一个新的对象,虽然变量名相同
console.log(referenceMap.has(user)); // false (新的引用)
console.log(referenceMap.get({ name: ‘Alice’ })); // false (新的引用)

// 要获取 ‘User object’,你必须持有最初设置时使用的那个对象的引用。
// 假设你保留了第一个对象的引用:
const originalUser = { name: ‘Alice’ };
const userMapWithOriginal = new Map();
userMapWithOriginal.set(originalUser, ‘Original user object’);
console.log(userMapWithOriginal.get(originalUser)); // Original user object
“`

这意味着如果你使用匿名对象 {} 或数组 [] 作为键,并且没有保留其引用,你将无法通过新的 {}[] 来访问对应的值。

6.2 WeakMap:处理对象键与垃圾回收

Map 密切相关的是 WeakMapWeakMap 也存储键值对,但它有两个关键区别:

  1. 键必须是对象: WeakMap 的键只能是非 null 的对象。
  2. 弱引用: WeakMap 对键是弱引用的。这意味着如果一个对象的唯一引用是 WeakMap 中的键,那么垃圾回收机制可以回收这个对象以及 WeakMap 中对应的键值对。这可以防止内存泄漏。

WeakMap 的局限性:

  • 不可迭代: WeakMap 不能被迭代 (for...of, forEach),也没有 .keys(), .values(), .entries() 方法。
  • 没有 .size 属性: 无法知道 WeakMap 中有多少个元素,因为元素可能随时被垃圾回收。

使用场景:

WeakMap 非常适合用于存储与特定对象实例相关联的私有数据或元数据,而又不想阻止这些对象被垃圾回收的场景。例如:

  • 缓存计算结果,键是参数对象。当参数对象不再被引用时,缓存条目也会被清理。
  • 存储 DOM 元素的私有数据,当 DOM 元素被从文档中移除并被回收时,相关数据也会被清理。

“`typescript
// WeakMap 示例
let element = document.getElementById(‘my-div’);
const elementData = new WeakMap();

if (element) {
elementData.set(element, { clickCount: 0 });
console.log(elementData.has(element)); // true
}

// 如果 element 被移除且没有其他引用,它最终会被垃圾回收,
// 相应的 WeakMap 条目也会被自动清理。
// console.log(elementData.get(element)); // 在 element 被回收后可能返回 undefined
“`

由于 WeakMap 不可迭代且没有 .size,它不能用来替代 Map 进行通用的键值对存储和管理。它是一个更特殊的工具,用于特定的内存管理场景。

7. TypeScript 中的 Map

TypeScript 在 Map 的基础上增加了类型检查的能力,使得使用 Map 更加安全和可靠。

7.1 为 Map 指定类型

创建 Map 时,可以使用泛型 <K, V> 来指定键 K 和值 V 的类型:

“`typescript
// Map 表示键是 string 类型,值是 number 类型
const typedMap = new Map();

typedMap.set(‘age’, 30); // Ok
typedMap.set(‘height’, 175.5); // Ok

// typedMap.set(123, ‘hello’); // Error: Argument of type ‘number’ is not assignable to parameter of type ‘string’.
// typedMap.set(‘weight’, ’70kg’); // Error: Argument of type ‘string’ is not assignable to parameter of type ‘number’.

const retrievedAge = typedMap.get(‘age’); // retrievedAge 的类型是 number | undefined
if (retrievedAge !== undefined) {
console.log(retrievedAge.toFixed(2)); // Ok, number 方法
}

// 指定对象作为键的类型
interface UserConfig {
theme: string;
language: string;
}

const userSpecificConfig = new Map();
userSpecificConfig.set(‘user1’, { theme: ‘dark’, language: ‘en’ });

const config = userSpecificConfig.get(‘user1’); // config 的类型是 UserConfig | undefined
if (config) {
console.log(config.theme); // Ok, access property
// console.log(config.fontSize); // Error: Property ‘fontSize’ does not exist on type ‘UserConfig’.
}

// 使用自定义类实例作为键的类型
class Point {
constructor(public x: number, public y: number) {}
}

const pointData = new Map();
const p1 = new Point(1, 1);
const p2 = new Point(2, 2);

pointData.set(p1, ‘Data for point (1,1)’);

console.log(pointData.get(p1)); // Data for point (1,1)
console.log(pointData.get(p2)); // undefined
// console.log(pointData.get({ x: 1, y: 1 })); // Error or undefined at runtime (new object reference)
“`

通过指定类型,TypeScript 能够在编译时捕获许多潜在的类型错误,例如使用错误类型的键或值,或者尝试访问不存在的属性(在使用具有明确类型的对象值时)。

7.2 MapIterator 的类型

.keys(), .values(), .entries() 方法返回的迭代器类型也会根据 Map 的类型参数进行推断:

“`typescript
const typedMap = new Map();
typedMap.set(‘a’, 1);
typedMap.set(‘b’, 2);

const keys: IterableIterator = typedMap.keys(); // keys 是 string 类型的迭代器
const values: IterableIterator = typedMap.values(); // values 是 number 类型的迭代器
const entries: IterableIterator<[string, number]> = typedMap.entries(); // entries 是 [string, number] 类型的迭代器

for (const key of keys) {
console.log(key.toUpperCase()); // Ok, key is string
}

for (const value of values) {
console.log(value.toFixed(2)); // Ok, value is number
}

for (const [key, value] of entries) {
console.log(${key}: ${value}); // Ok, key is string, value is number
}
“`

这种类型推断使得在迭代 Map 时能够安全地使用键和值,享受完整的类型检查和编辑器智能提示。

7.3 处理 undefined 返回值

.get() 方法的返回值类型总是 ValueType | undefined,因为键可能不存在。TypeScript 会强制你处理 undefined 的可能性,例如使用非空断言 !(如果确定键存在)或进行条件检查:

“`typescript
const myMap = new Map();
myMap.set(‘score’, 95);

const score = myMap.get(‘score’); // score: number | undefined

if (score !== undefined) {
console.log(Score is: ${score}); // Inside this block, score is number
} else {
console.log(‘Score not found’);
}

// 如果确定存在,可以使用非空断言,但需谨慎
const name = ‘score’;
if (myMap.has(name)) {
const definiteScore = myMap.get(name)!; // definiteScore: number
console.log(definiteScore.toFixed(1));
}
“`

8. Map 的常见使用场景

  • 缓存: 使用 Map 存储计算结果,键是输入参数(特别是当参数是对象时),避免重复计算。
  • 配置存储: 存储具有非标准键名(如对象、函数等)或需要保证顺序的配置信息。
  • 数据索引: 创建一个索引,将复杂的对象实例映射到唯一的 ID 或其他数据。
  • 元素关联数据: 在前端开发中,将数据直接与 DOM 元素对象关联起来(相比于在元素上设置 data-* 属性更灵活)。
  • 状态管理: 存储不同模块或组件的状态信息,键可以是模块标识符或其他唯一对象。
  • 图/树结构: 存储节点与其邻居或子节点的映射关系,其中节点本身是对象。
  • 集合去重 (简易): 虽然 Set 更适合简单的值去重,但 Map 可以用来根据对象的某个属性进行去重,将属性值作为键。

9. 性能考量(简述)

在现代 JavaScript 引擎中,Map 和普通对象在查找、插入、删除操作上都经过了高度优化,平均情况下性能都是 O(1)(常数时间)。

然而,Map 在以下方面可能具有优势:

  • 非字符串键: 对于使用非字符串/Symbol 作为键的场景,Map 的性能通常比强制转换键的普通对象要好且更稳定。
  • 频繁增删:Map 频繁地进行添加和删除操作时,其内部结构可能比经过大量属性增删的普通对象更易于引擎优化。
  • 迭代性能: Map 的迭代器是专门设计的,提供了保证的顺序,其迭代性能通常也很好。

普通对象在创建(对象字面量)和访问静态、已知字符串键的属性时可能语法更简洁且启动开销略低。

总的来说,在选择 Map 还是 Object 时,应更多地基于语义和特性需求(如键类型、迭代顺序、.size 等),而不是微小的性能差异,除非在特定性能敏感场景经过基准测试后发现明显差异。

10. 总结

TypeScript 中的 Map 对象是处理键值对数据的现代、强大且灵活的工具。它通过允许任意类型的键、保证插入顺序、提供方便的 .size 属性和清晰的迭代方法,弥补了普通对象在作为通用哈希映射使用时的不足。

结合 TypeScript 的类型系统,我们可以为 Map 定义严格的键和值类型,极大地提高了代码的类型安全性,减少了运行时错误的可能性,并增强了代码的可读性和可维护性。

理解 Map 与普通对象的区别,并根据具体的应用场景选择合适的工具,是编写高效、健壮 TypeScript 代码的关键一步。在需要灵活键类型、有序遍历或方便获取大小的场景下,毫不犹豫地拥抱 Map 吧!


发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部