跳到主要内容

集合数据结构

ES6 引入了四种新的集合数据结构:Map、Set、WeakMap 和 WeakSet。它们为 JavaScript 提供了更丰富、更高效的数据存储和操作方式。理解这些数据结构的特点和使用场景,是现代 JavaScript 开发的必备技能。

为什么需要新的集合类型?

在 ES6 之前,JavaScript 开发者主要使用 Object 来存储键值对,使用 Array 来存储有序列表。但这些传统方式存在一些局限性:

Object 的问题

  • 键只能是字符串或 Symbol,其他类型会被自动转换为字符串
  • 无法直接获取键值对的数量
  • 原型链上的属性可能造成意外干扰
  • 键的遍历顺序不完全可控

Array 的问题

  • 查找元素需要遍历,时间复杂度为 O(n)
  • 无法保证值的唯一性,需要额外处理去重

ES6 的集合类型正是为了解决这些问题而设计的。

Map

Map 是一种键值对的集合,它的键可以是任意类型的值,包括对象、函数、基本类型等。

创建 Map

// 创建空的 Map
const map1 = new Map();

// 从二维数组创建
const map2 = new Map([
['name', '张三'],
['age', 20],
[true, '布尔值作为键'],
[{ id: 1 }, '对象作为键']
]);

console.log(map2.size); // 4

基本操作

const userMap = new Map();

// 添加键值对 - set()
userMap.set('name', '张三');
userMap.set('age', 25);
userMap.set('email', '[email protected]');

// 链式调用
userMap.set('city', '北京').set('job', '工程师');

// 获取值 - get()
console.log(userMap.get('name')); // 张三
console.log(userMap.get('age')); // 25
console.log(userMap.get('phone')); // undefined(不存在的键)

// 检查键是否存在 - has()
console.log(userMap.has('name')); // true
console.log(userMap.has('phone')); // false

// 删除键值对 - delete()
userMap.delete('email');
console.log(userMap.has('email')); // false

// 清空 Map - clear()
const tempMap = new Map([['a', 1], ['b', 2]]);
tempMap.clear();
console.log(tempMap.size); // 0

键的类型可以是任意值

这是 Map 与 Object 最大的区别:

const map = new Map();

// 基本类型作为键
map.set(1, '数字键');
map.set('1', '字符串键'); // 与数字 1 不同
map.set(true, '布尔值键');

// 对象作为键
const objKey = { id: 1 };
map.set(objKey, '对象作为键');

// 函数作为键
const fnKey = function() {};
map.set(fnKey, '函数作为键');

// NaN 作为键(Map 中 NaN 等于 NaN)
map.set(NaN, 'NaN 作为键');
console.log(map.get(NaN)); // NaN 作为键

console.log(map.size); // 6

键的相等性判断

Map 使用 SameValueZero 算法判断键的相等性:

const map = new Map();

// NaN 在普通比较中不等于自身,但在 Map 中视为相等
map.set(NaN, 'value1');
map.set(NaN, 'value2'); // 覆盖前一个
console.log(map.size); // 1
console.log(map.get(NaN)); // value2

// -0 和 +0 被视为相等
map.set(-0, '负零');
map.set(+0, '正零'); // 覆盖前一个
console.log(map.size); // 2(包括上面的 NaN)

// 对象通过引用比较,不是值比较
map.set({}, '第一个对象');
map.set({}, '第二个对象'); // 不同的对象引用
console.log(map.size); // 4

遍历 Map

Map 保持插入顺序,可以直接遍历:

const map = new Map([
['a', 1],
['b', 2],
['c', 3]
]);

// for...of 遍历
for (const [key, value] of map) {
console.log(`${key}: ${value}`);
}
// a: 1
// b: 2
// c: 3

// 遍历键
for (const key of map.keys()) {
console.log(key);
}

// 遍历值
for (const value of map.values()) {
console.log(value);
}

// 遍历键值对
for (const entry of map.entries()) {
console.log(entry); // [key, value] 数组
}

// forEach 方法
map.forEach((value, key) => {
console.log(`${key}: ${value}`);
});

Map 与 Object 的区别

特性MapObject
键的类型任意类型只能是字符串或 Symbol
键的顺序保持插入顺序ES6 后基本保持插入顺序
获取大小map.sizeObject.keys(obj).length
遍历可直接迭代需要转换
原型链无原型键干扰可能受原型属性影响
性能频繁增删时更好适合静态键值对
序列化无原生 JSON 支持原生 JSON 支持

Map 与数组互相转换

// Map 转数组
const map = new Map([
['name', '张三'],
['age', 20]
]);

// 使用展开运算符
const arr1 = [...map];
// [['name', '张三'], ['age', 20]]

// 使用 Array.from
const arr2 = Array.from(map);

// 数组转 Map
const arr = [['a', 1], ['b', 2]];
const newMap = new Map(arr);
console.log(newMap.get('a')); // 1

Map 与对象互相转换

// Map 转对象
const map = new Map([
['name', '张三'],
['age', 20]
]);

const obj = Object.fromEntries(map);
console.log(obj); // { name: '张三', age: 20 }

// 对象转 Map
const person = { name: '李四', age: 25 };
const personMap = new Map(Object.entries(person));
console.log(personMap.get('name')); // 李四

Map 的克隆与合并

// 克隆 Map
const original = new Map([['a', 1], ['b', 2]]);
const cloned = new Map(original);
console.log(cloned.get('a')); // 1

// 注意:这是浅拷贝
const deepMap = new Map([['obj', { name: '张三' }]]);
const shallowCopy = new Map(deepMap);
shallowCopy.get('obj').name = '李四';
console.log(deepMap.get('obj').name); // 李四(被修改了)

// 合并 Map
const map1 = new Map([['a', 1], ['b', 2]]);
const map2 = new Map([['b', 3], ['c', 4]]);
const merged = new Map([...map1, ...map2]);
// {'a' => 1, 'b' => 3, 'c' => 4}(后面的覆盖前面的)

Set

Set 是一种值的集合,其中的每个值都是唯一的,不会出现重复值。

创建 Set

// 创建空的 Set
const set1 = new Set();

// 从数组创建(自动去重)
const set2 = new Set([1, 2, 3, 2, 1]);
console.log(set2); // Set {1, 2, 3}

// 从字符串创建
const charSet = new Set('hello');
console.log(charSet); // Set {'h', 'e', 'l', 'o'}

基本操作

const numbers = new Set();

// 添加值 - add()
numbers.add(1);
numbers.add(2);
numbers.add(2); // 重复值不会被添加
numbers.add(3);

// 链式调用
numbers.add(4).add(5).add(6);

console.log(numbers.size); // 6

// 检查值是否存在 - has()
console.log(numbers.has(1)); // true
console.log(numbers.has(7)); // false

// 删除值 - delete()
numbers.delete(3);
console.log(numbers.has(3)); // false

// 清空 Set - clear()
const tempSet = new Set([1, 2, 3]);
tempSet.clear();
console.log(tempSet.size); // 0

值的唯一性

Set 使用 SameValueZero 算法判断值的相等性:

const set = new Set();

// 基本类型
set.add(1);
set.add('1'); // 字符串 '1' 与数字 1 不同
set.add(true);
set.add(NaN);
set.add(NaN); // NaN 只会添加一次

console.log(set.size); // 4

// 对象通过引用判断
set.add({});
set.add({}); // 两个不同的对象
console.log(set.size); // 6

// 相同引用只添加一次
const obj = { id: 1 };
set.add(obj);
set.add(obj); // 相同引用,不会重复添加
console.log(set.size); // 7

数组去重

这是 Set 最常见的用途之一:

// 基本数组去重
const arr = [1, 2, 2, 3, 3, 3, 4];
const unique = [...new Set(arr)];
console.log(unique); // [1, 2, 3, 4]

// 使用 Array.from
const unique2 = Array.from(new Set(arr));

// 字符串数组去重
const words = ['apple', 'banana', 'apple', 'orange', 'banana'];
const uniqueWords = [...new Set(words)];
console.log(uniqueWords); // ['apple', 'banana', 'orange']

// 对象数组去重(需要特殊处理)
const users = [
{ id: 1, name: '张三' },
{ id: 2, name: '李四' },
{ id: 1, name: '张三' } // 重复
];

// 按 id 去重
const uniqueUsers = [...new Map(users.map(u => [u.id, u])).values()];
console.log(uniqueUsers.length); // 2

遍历 Set

Set 保持插入顺序,可以直接遍历:

const set = new Set(['a', 'b', 'c']);

// for...of 遍历
for (const value of set) {
console.log(value);
}

// keys() 和 values() 行为相同
for (const value of set.values()) {
console.log(value);
}

// entries() 返回 [value, value]
for (const [v1, v2] of set.entries()) {
console.log(v1 === v2); // true
}

// forEach 方法
set.forEach((value, key, set) => {
console.log(value);
});

Set 与数组互相转换

// Set 转数组
const set = new Set([1, 2, 3]);

// 展开运算符
const arr1 = [...set];

// Array.from
const arr2 = Array.from(set);

// 数组转 Set
const arr = [1, 2, 2, 3];
const newSet = new Set(arr);
console.log(newSet.has(2)); // true

Set 集合运算

ES2024 新增了原生的 Set 集合运算方法,让数学集合操作变得简单:

const setA = new Set([1, 2, 3, 4]);
const setB = new Set([3, 4, 5, 6]);

// 并集 - union()
const union = setA.union(setB);
console.log([...union]); // [1, 2, 3, 4, 5, 6]

// 交集 - intersection()
const intersection = setA.intersection(setB);
console.log([...intersection]); // [3, 4]

// 差集 - difference()
const difference = setA.difference(setB);
console.log([...difference]); // [1, 2]

// 对称差集 - symmetricDifference()
const symDiff = setA.symmetricDifference(setB);
console.log([...symDiff]); // [1, 2, 5, 6]

// 子集判断 - isSubsetOf()
const setSmall = new Set([1, 2]);
console.log(setSmall.isSubsetOf(setA)); // true

// 超集判断 - isSupersetOf()
console.log(setA.isSupersetOf(setSmall)); // true

// 不相交判断 - isDisjointFrom()
const setC = new Set([7, 8]);
console.log(setA.isDisjointFrom(setC)); // true
console.log(setA.isDisjointFrom(setB)); // false

手动实现集合运算(兼容旧版本)

在 ES2024 之前,需要手动实现集合运算:

// 并集
function union(setA, setB) {
return new Set([...setA, ...setB]);
}

// 交集
function intersection(setA, setB) {
return new Set([...setA].filter(x => setB.has(x)));
}

// 差集(A 有但 B 没有的)
function difference(setA, setB) {
return new Set([...setA].filter(x => !setB.has(x)));
}

// 对称差集(A 或 B 有,但不同时存在)
function symmetricDifference(setA, setB) {
return new Set([
...[...setA].filter(x => !setB.has(x)),
...[...setB].filter(x => !setA.has(x))
]);
}

// 使用示例
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);

console.log([...union(a, b)]); // [1, 2, 3, 4]
console.log([...intersection(a, b)]); // [2, 3]
console.log([...difference(a, b)]); // [1]
console.log([...symmetricDifference(a, b)]); // [1, 4]

WeakMap

WeakMap 是 Map 的变体,它的键必须是对象,且对键的引用是"弱引用"。

什么是弱引用?

弱引用是指不会阻止垃圾回收器回收对象的引用。当一个对象只被弱引用时,垃圾回收器可以随时回收它。

// 强引用
let obj = { name: '张三' };
let map = new Map();
map.set(obj, 'value');
obj = null; // obj 设置为 null,但 Map 中仍持有引用,对象不会被回收

// 弱引用
let weakMap = new WeakMap();
let obj2 = { name: '李四' };
weakMap.set(obj2, 'value');
obj2 = null; // obj2 设置为 null,WeakMap 不阻止垃圾回收,对象可能被回收

WeakMap 的特点

  1. 键必须是对象:不能使用基本类型作为键
  2. 不可枚举:没有 keys()values()entries() 方法
  3. 没有 size 属性:无法获取元素数量
  4. 不支持遍历:无法直接遍历所有键值对
  5. 弱引用键:当键对象没有其他引用时,可以被垃圾回收

基本操作

const weakMap = new WeakMap();

// 键必须是对象
const key1 = { id: 1 };
const key2 = { id: 2 };

// 添加键值对
weakMap.set(key1, 'value1');
weakMap.set(key2, 'value2');

// 获取值
console.log(weakMap.get(key1)); // value1

// 检查键是否存在
console.log(weakMap.has(key1)); // true

// 删除键值对
weakMap.delete(key1);
console.log(weakMap.has(key1)); // false

WeakMap 的用途

1. 存储私有数据

WeakMap 可以用来存储对象的私有数据,不暴露给外部:

const privateData = new WeakMap();

class Person {
constructor(name, age) {
this.name = name; // 公有属性
privateData.set(this, { age }); // 私有数据
}

getAge() {
return privateData.get(this).age;
}

setAge(age) {
privateData.get(this).age = age;
}
}

const person = new Person('张三', 25);
console.log(person.name); // 张三
console.log(person.getAge()); // 25
console.log(person.age); // undefined(外部无法访问)

2. 缓存计算结果

WeakMap 可以用来缓存与对象相关的计算结果,当对象被回收时,缓存也会自动清理:

const cache = new WeakMap();

function computeHeavyValue(obj) {
// 检查缓存
if (cache.has(obj)) {
console.log('从缓存获取');
return cache.get(obj);
}

// 模拟耗时计算
console.log('执行计算');
const result = JSON.stringify(obj).length; // 示例计算

// 存入缓存
cache.set(obj, result);
return result;
}

const data = { items: [1, 2, 3, 4, 5] };

console.log(computeHeavyValue(data)); // 执行计算 -> 27
console.log(computeHeavyValue(data)); // 从缓存获取 -> 27

3. DOM 元素关联

WeakMap 可以用来存储与 DOM 元素相关的数据,当 DOM 元素被移除时,数据也会被自动清理:

const elementData = new WeakMap();

function bindElement(element, data) {
elementData.set(element, data);

element.addEventListener('click', function() {
const storedData = elementData.get(this);
console.log('Element data:', storedData);
});
}

const button = document.querySelector('#myButton');
bindElement(button, { clickCount: 0 });

// 当 button 被移除后,elementData 中的数据也会被自动清理

WeakSet

WeakSet 是 Set 的变体,它只能存储对象,且对对象的引用是弱引用。

WeakSet 的特点

  1. 只能存储对象:不能存储基本类型
  2. 不可枚举:没有遍历方法
  3. 没有 size 属性:无法获取元素数量
  4. 弱引用值:当对象没有其他引用时,可以被垃圾回收

基本操作

const weakSet = new WeakSet();

const obj1 = { name: '张三' };
const obj2 = { name: '李四' };

// 添加对象
weakSet.add(obj1);
weakSet.add(obj2);

// 检查是否存在
console.log(weakSet.has(obj1)); // true
console.log(weakSet.has({})); // false(新对象)

// 删除对象
weakSet.delete(obj1);
console.log(weakSet.has(obj1)); // false

WeakSet 的用途

1. 标记对象

WeakSet 可以用来标记某些对象,比如标记已处理的对象:

const processed = new WeakSet();

function processItem(item) {
// 检查是否已处理
if (processed.has(item)) {
console.log('已经处理过了');
return;
}

// 处理对象
console.log('处理中...', item);

// 标记为已处理
processed.add(item);
}

const task = { id: 1, name: '任务一' };

processItem(task); // 处理中...
processItem(task); // 已经处理过了

2. 防止循环引用

在处理对象关系时,WeakSet 可以帮助避免循环引用问题:

const visited = new WeakSet();

function traverse(obj, depth = 0) {
if (!obj || typeof obj !== 'object') return;

// 检查是否已访问
if (visited.has(obj)) {
console.log(' '.repeat(depth) + '(循环引用)');
return;
}

visited.add(obj);

for (const key in obj) {
if (obj.hasOwnProperty(key)) {
console.log(' '.repeat(depth) + key + ':');
traverse(obj[key], depth + 1);
}
}
}

const obj = { a: { b: {} } };
obj.a.b.parent = obj.a; // 创建循环引用

traverse(obj);
// a:
// b:
// parent:
// (循环引用)

使用场景总结

何时使用 Map

  • 需要使用非字符串类型作为键
  • 需要频繁添加和删除键值对
  • 需要保持键的插入顺序
  • 需要直接获取键值对数量
  • 键值对数量较大时
// 统计单词出现次数(任意类型作为键)
const wordCount = new Map();
const words = ['apple', 'banana', 'apple', 'orange', 'banana'];

words.forEach(word => {
wordCount.set(word, (wordCount.get(word) || 0) + 1);
});

console.log(wordCount); // Map { 'apple' => 2, 'banana' => 2, 'orange' => 1 }

何时使用 Set

  • 需要存储唯一值的集合
  • 需要高效的成员检查
  • 需要数组去重
  • 需要进行集合运算
// 数组去重并检查是否存在
const tags = new Set(['javascript', 'python', 'java', 'javascript']);

function hasTag(tag) {
return tags.has(tag);
}

console.log(hasTag('javascript')); // true
console.log(hasTag('rust')); // false

何时使用 WeakMap

  • 需要为对象添加私有数据
  • 需要缓存与对象相关的数据
  • 需要关联 DOM 元素数据
  • 需要避免内存泄漏
// 为对象添加元数据
const metadata = new WeakMap();

function addMetadata(obj, data) {
metadata.set(obj, data);
}

function getMetadata(obj) {
return metadata.get(obj);
}

const user = { name: '张三' };
addMetadata(user, { created: new Date(), modified: false });

何时使用 WeakSet

  • 需要标记对象状态
  • 需要检测循环引用
  • 需要临时存储对象引用
// 标记已处理的对象
const handled = new WeakSet();

function handleRequest(request) {
if (handled.has(request)) return;

// 处理请求...
handled.add(request);
}

何时继续使用 Object

  • 使用 JSON 序列化和反序列化
  • 键主要是字符串
  • 需要简单的键值存储
  • 与 API 数据交互
// JSON 数据交互
const response = await fetch('/api/user');
const userData = await response.json(); // 自动解析为 Object

// 简单配置
const config = {
apiKey: 'xxx',
timeout: 5000,
retries: 3
};

性能对比

不同的集合类型在不同操作上有不同的性能特点:

// 大量数据时的性能对比
const size = 100000;

// Map vs Object 添加元素
console.time('Map set');
const map = new Map();
for (let i = 0; i < size; i++) {
map.set(`key${i}`, i);
}
console.timeEnd('Map set');

console.time('Object set');
const obj = {};
for (let i = 0; i < size; i++) {
obj[`key${i}`] = i;
}
console.timeEnd('Object set');

// Set vs Array 查找
const arr = Array.from({ length: size }, (_, i) => i);
const set = new Set(arr);

console.time('Array includes');
for (let i = 0; i < 1000; i++) {
arr.includes(Math.floor(Math.random() * size * 2));
}
console.timeEnd('Array includes');

console.time('Set has');
for (let i = 0; i < 1000; i++) {
set.has(Math.floor(Math.random() * size * 2));
}
console.timeEnd('Set has');

一般情况下:

  • Map 在频繁增删键值对时比 Object 更快
  • Set.has()Array.includes() 快得多(O(1) vs O(n))
  • WeakMapWeakSet 在内存管理方面更优

小结

本章介绍了 ES6 引入的四种集合数据结构:

Map

  • 键可以是任意类型
  • 保持插入顺序
  • 适合需要非字符串键或频繁增删的场景

Set

  • 存储唯一值
  • 高效的成员检查
  • 适合数组去重和集合运算

WeakMap

  • 键必须是对象
  • 弱引用,支持垃圾回收
  • 适合存储私有数据和缓存

WeakSet

  • 只能存储对象
  • 弱引用,支持垃圾回收
  • 适合标记对象状态

选择合适的集合类型可以提高代码的性能和可读性。在现代 JavaScript 开发中,理解这些数据结构的特点和使用场景至关重要。

练习

  1. 使用 Map 实现一个简单的缓存系统,支持设置过期时间
  2. 使用 Set 实现数组的交集、并集、差集运算
  3. 使用 WeakMap 实现对象的私有属性存储
  4. 比较在 10000 个元素中,使用 Set 和 Array 查找元素的性能差异
  5. 实现一个使用 WeakSet 检测循环引用的深拷贝函数

参考资源