Set 和 Map
问题
JavaScript 中的 Set 和 Map 是什么?它们与数组和对象有什么区别?
答案
Set 和 Map 是 ES6 引入的两种新的数据结构,用于解决数组和对象在某些场景下的不足。
Set
什么是 Set?
Set 是一个值的集合,其中的值是唯一的,不会重复。
const set = new Set<number>();
set.add(1);
set.add(2);
set.add(2); // 重复,不会添加
console.log(set); // Set(2) { 1, 2 }
console.log(set.size); // 2
Set 的常用方法
| 方法 | 说明 | 返回值 |
|---|---|---|
add(value) | 添加值 | Set 本身(可链式调用) |
delete(value) | 删除值 | boolean |
has(value) | 检查是否存在 | boolean |
clear() | 清空所有值 | void |
size | 获取元素数量 | number |
const set = new Set<string>();
// 链式调用
set.add('a').add('b').add('c');
// 检查存在
console.log(set.has('a')); // true
console.log(set.has('d')); // false
// 删除
set.delete('b');
console.log(set); // Set(2) { 'a', 'c' }
// 清空
set.clear();
console.log(set.size); // 0
Set 的遍历
const set = new Set(['a', 'b', 'c']);
// for...of
for (const value of set) {
console.log(value);
}
// forEach
set.forEach((value) => {
console.log(value);
});
// 转换为数组
const arr = [...set];
// 或
const arr2 = Array.from(set);
Set vs Array
| 对比维度 | Set | Array |
|---|---|---|
| 值唯一性 | 自动保证唯一 | 允许重复 |
查找 has / includes | (哈希查找) | (线性扫描) |
| 删除指定值 | delete(value), | splice / filter, |
| 添加 | add(value), | push, |
| 索引访问 | ❌ 不支持 set[0] | ✅ arr[0] |
| 获取大小 | .size | .length |
| 高阶方法 | ❌ 无 map / filter / reduce | ✅ 全部支持 |
| 有序性 | 按插入顺序 | 按插入顺序 |
| JSON 序列化 | ❌ 需先转数组 | ✅ 原生支持 |
核心差异:查找和删除性能
Set 基于哈希表实现,has 和 delete 是 ;Array 的 includes 和 splice(按值删除)是 。当数据量大且需要频繁查找/删除时,Set 的性能优势非常明显。
性能对比:查找 10 万个元素
const size = 100_000;
const arr = Array.from({ length: size }, (_, i) => i);
const set = new Set(arr);
// Array.includes → O(n),查找末尾元素需遍历整个数组
console.time('Array.includes');
arr.includes(99_999); // 遍历 10 万次
console.timeEnd('Array.includes'); // ~1-2ms
// Set.has → O(1),哈希查找,直接命中
console.time('Set.has');
set.has(99_999); // 1 次哈希计算
console.timeEnd('Set.has'); // ~0.001ms
按值删除的对比
// Array:删除值为 3 的元素 → O(n)
const arr2 = [1, 2, 3, 4, 5];
const index = arr2.indexOf(3); // O(n) 查找
if (index !== -1) arr2.splice(index, 1); // O(n) 移动元素
// Set:删除值为 3 的元素 → O(1)
const set2 = new Set([1, 2, 3, 4, 5]);
set2.delete(3); // O(1) 直接删除
什么时候用 Set 代替 Array?
// ✅ 1. 需要频繁判断"某个值存不存在"
const visited = new Set<string>();
function visit(url: string): void {
if (visited.has(url)) return; // O(1)
visited.add(url);
// 处理...
}
// ✅ 2. 需要保证唯一性(如收集不重复的标签)
const tags = new Set<string>();
tags.add('JavaScript');
tags.add('TypeScript');
tags.add('JavaScript'); // 自动忽略
console.log([...tags]); // ['JavaScript', 'TypeScript']
// ✅ 3. 需要高效的按值删除
const onlineUsers = new Set<string>();
onlineUsers.add('user1');
onlineUsers.add('user2');
onlineUsers.delete('user1'); // O(1)
// ❌ 不适合用 Set 的场景:需要索引、排序、map/filter/reduce
const numbers = [3, 1, 4, 1, 5];
numbers.sort(); // Set 不支持
numbers.map(n => n * 2); // Set 不支持
console.log(numbers[0]); // Set 不支持索引访问
Set 的实际应用
1. 数组去重
const arr = [1, 2, 2, 3, 3, 3, 4];
const unique = [...new Set(arr)];
console.log(unique); // [1, 2, 3, 4]
// 封装为函数
function dedupe<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
2. 集合运算
const setA = new Set([1, 2, 3, 4]);
const setB = new Set([3, 4, 5, 6]);
// 并集
const union = new Set([...setA, ...setB]);
console.log(union); // Set(6) { 1, 2, 3, 4, 5, 6 }
// 交集
const intersection = new Set([...setA].filter(x => setB.has(x)));
console.log(intersection); // Set(2) { 3, 4 }
// 差集(A - B)
const difference = new Set([...setA].filter(x => !setB.has(x)));
console.log(difference); // Set(2) { 1, 2 }
3. 检查重复
function hasDuplicates<T>(arr: T[]): boolean {
return new Set(arr).size !== arr.length;
}
console.log(hasDuplicates([1, 2, 3])); // false
console.log(hasDuplicates([1, 2, 2, 3])); // true
Map
什么是 Map?
Map 是一个键值对的集合,与普通对象不同,Map 的键可以是任意类型。
const map = new Map<string, number>();
map.set('a', 1);
map.set('b', 2);
console.log(map.get('a')); // 1
console.log(map.size); // 2
Map 的常用方法
| 方法 | 说明 | 返回值 |
|---|---|---|
set(key, value) | 设置键值对 | Map 本身(可链式调用) |
get(key) | 获取值 | value | undefined |
delete(key) | 删除键值对 | boolean |
has(key) | 检查键是否存在 | boolean |
clear() | 清空所有键值对 | void |
keys() | 返回所有键的迭代器 | MapIterator |
values() | 返回所有值的迭代器 | MapIterator |
entries() | 返回所有 [key, value] 的迭代器 | MapIterator |
forEach(cb) | 遍历每个键值对 | void |
size | 获取键值对数量 | number |
const map = new Map<string, number>();
// 链式调用
map.set('x', 10).set('y', 20).set('z', 30);
// 获取值
console.log(map.get('x')); // 10
console.log(map.get('w')); // undefined
// 检查存在
console.log(map.has('x')); // true
// 删除
map.delete('y');
console.log(map.size); // 2
Map 的遍历
const map = new Map([
['name', 'Alice'],
['age', '25'],
]);
// for...of(默认遍历 entries)
for (const [key, value] of map) {
console.log(`${key}: ${value}`);
}
// 遍历键
for (const key of map.keys()) {
console.log(key);
}
// 遍历值
for (const value of map.values()) {
console.log(value);
}
// forEach
map.forEach((value, key) => {
console.log(`${key}: ${value}`);
});
Map vs Object
| 特性 | Map | Object |
|---|---|---|
| 键的类型 | 任意类型 | 字符串或 Symbol |
| 键的顺序 | 按插入顺序 | 不保证(数字键会排序) |
| 获取大小 | map.size | Object.keys(obj).length |
| 遍历 | 直接可迭代 | 需要 Object.keys() |
| 性能 | 频繁增删更优 | 读取固定键更快 |
| JSON 序列化 | 不支持 | 原生支持 |
// Map 的键可以是任意类型
const map = new Map<object, string>();
const objKey = { id: 1 };
map.set(objKey, 'value');
console.log(map.get(objKey)); // 'value'
// Object 的键只能是字符串
const obj: Record<string, string> = {};
obj[objKey.toString()] = 'value'; // 键变成 "[object Object]"
Map 的实际应用
1. 缓存函数结果(Memoization)
function memoize<T extends (...args: any[]) => any>(fn: T): T {
const cache = new Map<string, ReturnType<T>>();
return ((...args: Parameters<T>) => {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key)!;
}
const result = fn(...args);
cache.set(key, result);
return result;
}) as T;
}
// 使用
const expensiveCalculation = memoize((n: number) => {
console.log('计算中...');
return n * n;
});
expensiveCalculation(5); // 计算中... 25
expensiveCalculation(5); // 25(从缓存获取,不打印)
2. 统计频率
function countFrequency<T>(arr: T[]): Map<T, number> {
const map = new Map<T, number>();
for (const item of arr) {
map.set(item, (map.get(item) ?? 0) + 1);
}
return map;
}
const arr = ['a', 'b', 'a', 'c', 'a', 'b'];
console.log(countFrequency(arr));
// Map(3) { 'a' => 3, 'b' => 2, 'c' => 1 }
3. 两数之和(经典算法题)
function twoSum(nums: number[], target: number): number[] {
const map = new Map<number, number>(); // 值 -> 索引
for (let i = 0; i < nums.length; i++) {
const complement = target - nums[i];
if (map.has(complement)) {
return [map.get(complement)!, i];
}
map.set(nums[i], i);
}
return [];
}
WeakSet 和 WeakMap
WeakSet
WeakSet 只能存储对象引用,且是弱引用(不阻止垃圾回收)。
const weakSet = new WeakSet<object>();
let obj = { name: 'test' };
weakSet.add(obj);
console.log(weakSet.has(obj)); // true
obj = null; // 对象可能被垃圾回收
// weakSet 中的引用也会自动消失
特点:
- 只能存储对象
- 不可遍历
- 没有
size属性 - 不阻止垃圾回收
使用场景:标记对象是否被处理过
const processed = new WeakSet<object>();
function process(obj: object): void {
if (processed.has(obj)) {
console.log('已处理过');
return;
}
// 处理对象...
processed.add(obj);
}
WeakMap
WeakMap 的键必须是对象,且是弱引用。
const weakMap = new WeakMap<object, string>();
let key = { id: 1 };
weakMap.set(key, 'value');
console.log(weakMap.get(key)); // 'value'
key = null; // 键对象可能被垃圾回收
// weakMap 中的键值对也会自动消失
使用场景:存储对象的私有数据
const privateData = new WeakMap<object, { secret: string }>();
class User {
constructor(name: string) {
privateData.set(this, { secret: 'hidden' });
}
getSecret(): string {
return privateData.get(this)!.secret;
}
}
const user = new User('Alice');
console.log(user.getSecret()); // 'hidden'
// 无法从外部访问 privateData
对比总结
| 特性 | Array | Object | Set | Map |
|---|---|---|---|---|
| 存储方式 | 有序列表 | 键值对 | 唯一值集合 | 键值对集合 |
| 键类型 | 数字索引 | 字符串/Symbol | - | 任意类型 |
| 值唯一性 | ❌ | - | ✅ | - |
| 有序性 | ✅ | 部分 | ✅ | ✅ |
| 获取大小 | .length | 需计算 | .size | .size |
常见面试问题
Q1: Set 如何判断两个值相等?
Set 使用 SameValueZero 算法判断相等,类似于 ===,但认为 NaN === NaN。
const set = new Set();
set.add(NaN);
set.add(NaN);
console.log(set.size); // 1(NaN 被认为相等)
set.add(0);
set.add(-0);
console.log(set.size); // 2(0 和 -0 被认为相等)
Q2: Map 和 Object 该如何选择?
| 场景 | 推荐 |
|---|---|
| 键是字符串且固定 | Object |
| 键是动态的或非字符串 | Map |
| 需要频繁增删 | Map |
| 需要 JSON 序列化 | Object |
| 需要保持插入顺序 | Map |
Q3: 如何将 Map 转为 Object,Object 转为 Map?
// Map -> Object
const map = new Map([['a', 1], ['b', 2]]);
const obj = Object.fromEntries(map);
console.log(obj); // { a: 1, b: 2 }
// Object -> Map
const obj2 = { x: 10, y: 20 };
const map2 = new Map(Object.entries(obj2));
console.log(map2); // Map(2) { 'x' => 10, 'y' => 20 }
Q4: WeakMap 和 Map 的区别?
| 特性 | Map | WeakMap |
|---|---|---|
| 键类型 | 任意 | 仅对象 |
| 垃圾回收 | 阻止 | 不阻止 |
| 可遍历 | ✅ | ❌ |
| size 属性 | ✅ | ❌ |
Q5: WeakMap 和 WeakSet 的使用场景有哪些?
答案:
WeakMap 和 WeakSet 的键(或值)是弱引用,不会阻止垃圾回收,因此特别适合以下场景:
1. DOM 元素关联数据
// 为 DOM 元素附加额外数据,元素移除后数据自动回收
const elementData = new WeakMap<HTMLElement, { clickCount: number; lastClick: number }>();
function trackElement(el: HTMLElement): void {
elementData.set(el, { clickCount: 0, lastClick: 0 });
el.addEventListener('click', () => {
const data = elementData.get(el);
if (data) {
data.clickCount++;
data.lastClick = Date.now();
}
});
}
// 当 el 被从 DOM 移除且没有其他引用时,
// WeakMap 中的条目会自动被 GC 清理,不会内存泄漏
2. 私有数据存储
// 使用 WeakMap 模拟私有属性
const privateProps = new WeakMap<object, { _password: string }>();
class User {
name: string;
constructor(name: string, password: string) {
this.name = name;
privateProps.set(this, { _password: password });
}
validatePassword(input: string): boolean {
return privateProps.get(this)?._password === input;
}
}
const user = new User('Alice', 'secret123');
console.log(user.name); // 'Alice'
console.log(user.validatePassword('secret123')); // true
// 无法从外部直接访问 _password
3. 缓存计算结果(防止内存泄漏)
// 对象参数的计算结果缓存
const computeCache = new WeakMap<object, number>();
function expensiveCompute(obj: { values: number[] }): number {
if (computeCache.has(obj)) {
return computeCache.get(obj)!;
}
const result = obj.values.reduce((sum, v) => sum + v * v, 0);
computeCache.set(obj, result);
return result;
}
let data = { values: [1, 2, 3, 4, 5] };
expensiveCompute(data); // 计算并缓存
expensiveCompute(data); // 直接返回缓存
data = null as any; // data 不再使用后,缓存自动被 GC 清理
4. WeakSet 标记已处理对象
// 防止重复处理
const processed = new WeakSet<object>();
function processOnce(obj: object): void {
if (processed.has(obj)) {
console.log('已处理过,跳过');
return;
}
// 处理逻辑...
console.log('正在处理...');
processed.add(obj);
}
// 防止循环引用导致无限递归(如深拷贝)
function deepClone<T>(obj: T, seen = new WeakSet<object>()): T {
if (obj === null || typeof obj !== 'object') return obj;
if (seen.has(obj as object)) return obj; // 检测循环引用
seen.add(obj as object);
const clone = (Array.isArray(obj) ? [] : {}) as T;
for (const key of Object.keys(obj as object)) {
(clone as any)[key] = deepClone((obj as any)[key], seen);
}
return clone;
}
不可遍历的原因
WeakMap 和 WeakSet 不支持遍历(没有 keys()、values()、entries()、forEach、size),原因是:
- 弱引用的对象随时可能被 GC 回收,遍历结果不确定
- 如果支持遍历,引擎需要维护完整的键列表,这与"弱引用"的语义矛盾
- 设计上刻意限制,确保不会意外阻止垃圾回收
Q6: Map 和 Object 作为键值对容器有什么区别?什么时候用 Map?
答案:
| 对比维度 | Map | Object |
|---|---|---|
| 键类型 | 任意类型(对象、函数、基础类型) | 仅 string 和 Symbol |
| 键的顺序 | 严格按插入顺序 | 整数键升序 → 字符串键按插入顺序 → Symbol |
| 获取大小 | map.size, | Object.keys(obj).length, |
| 迭代方式 | 原生可迭代(for...of) | 需 Object.keys/entries 转换 |
| 频繁增删性能 | 优化过,更快 | 未专门优化 |
| 原型链 | 无原型干扰 | 继承 Object.prototype,可能键冲突 |
| JSON 序列化 | 不支持,需手动转换 | 原生 JSON.stringify 支持 |
| 默认键 | 无 | 有(toString、constructor 等原型属性) |
键类型差异示例:
// Object:非字符串键会被强制转换
const obj: Record<string, string> = {};
const key1 = { id: 1 };
const key2 = { id: 2 };
obj[key1 as any] = 'value1';
obj[key2 as any] = 'value2';
// 两个不同对象变成同一个键 "[object Object]"
console.log(Object.keys(obj)); // ['[object Object]']
console.log(obj[key1 as any]); // 'value2'(被覆盖了!)
// Map:任意类型都可以作为独立的键
const map = new Map<object, string>();
map.set(key1, 'value1');
map.set(key2, 'value2');
console.log(map.size); // 2(两个独立的键)
console.log(map.get(key1)); // 'value1'
console.log(map.get(key2)); // 'value2'
键顺序差异示例:
// Object:整数键会被自动排序
const obj2: Record<string, string> = {};
obj2['b'] = '2';
obj2['1'] = '1';
obj2['a'] = '3';
obj2['0'] = '0';
console.log(Object.keys(obj2)); // ['0', '1', 'b', 'a'] ← 整数键排前面
// Map:严格保持插入顺序
const map2 = new Map<string, string>();
map2.set('b', '2');
map2.set('1', '1');
map2.set('a', '3');
map2.set('0', '0');
console.log([...map2.keys()]); // ['b', '1', 'a', '0'] ← 插入顺序
什么时候用 Map:
// 1. 键不是字符串时(如用对象做键)
const nodeDepths = new Map<HTMLElement, number>();
document.querySelectorAll('*').forEach((el, i) => {
nodeDepths.set(el as HTMLElement, i);
});
// 2. 需要频繁增删时(Map 对此有优化)
const sessions = new Map<string, { token: string; expiresAt: number }>();
function addSession(id: string, token: string): void {
sessions.set(id, { token, expiresAt: Date.now() + 3600000 });
}
function removeExpired(): void {
const now = Date.now();
for (const [id, session] of sessions) {
if (session.expiresAt < now) {
sessions.delete(id);
}
}
}
// 3. 需要直接获取大小时
console.log(`当前活跃会话: ${sessions.size}`);
// 4. 需要保证键顺序时(如实现 LRU 缓存)
class SimpleLRU<K, V> {
private cache = new Map<K, V>();
constructor(private maxSize: number) {}
get(key: K): V | undefined {
if (!this.cache.has(key)) return undefined;
const value = this.cache.get(key)!;
// 移到末尾(最近使用)
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) this.cache.delete(key);
this.cache.set(key, value);
// 超出容量,删除最早插入的(第一个)
if (this.cache.size > this.maxSize) {
const firstKey = this.cache.keys().next().value!;
this.cache.delete(firstKey);
}
}
}
选择建议
- 用 Object:键是固定的字符串、需要 JSON 序列化、作为函数参数/返回值的简单数据结构
- 用 Map:键是动态的或非字符串类型、需要频繁增删、需要保证顺序、需要高效获取
size
Q7: Set 和 Array 有什么区别?什么时候用 Set?
答案:
核心区别有三点:
- 唯一性:Set 自动去重,Array 允许重复
- 查找性能:
Set.has()是 (哈希查找),Array.includes()是 (线性扫描) - 删除性能:
Set.delete()是 ,Array 按值删除需要indexOf+splice,是
选择指南:
| 场景 | 推荐 |
|---|---|
| 需要频繁查找"某个值是否存在" | Set( vs ) |
| 需要保证值唯一 | Set |
| 需要按值高效删除 | Set |
需要索引访问(arr[i]) | Array |
需要 map / filter / reduce | Array |
| 需要排序 | Array |
| 需要 JSON 序列化 | Array |
实际开发中常见的模式是 Set 和 Array 配合使用:用 Set 做查找和去重,最终用展开运算符 [...set] 转回 Array 做后续处理。
// 典型组合用法:用 Set 去重 + 交集,最终转回 Array
function intersect<T>(a: T[], b: T[]): T[] {
const setB = new Set(b); // 把 b 放入 Set
return [...new Set(a)].filter( // a 去重
item => setB.has(item) // O(1) 查找交集
);
}
intersect([1, 2, 2, 3], [2, 3, 4]); // [2, 3]