集合数据结构
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 的区别
| 特性 | Map | Object |
|---|---|---|
| 键的类型 | 任意类型 | 只能是字符串或 Symbol |
| 键的顺序 | 保持插入顺序 | ES6 后基本保持插入顺序 |
| 获取大小 | map.size | Object.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 的特点
- 键必须是对象:不能使用基本类型作为键
- 不可枚举:没有
keys()、values()、entries()方法 - 没有 size 属性:无法获取元素数量
- 不支持遍历:无法直接遍历所有键值对
- 弱引用键:当键对象没有其他引用时,可以被垃圾回收
基本操作
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 的特点
- 只能存储对象:不能存储基本类型
- 不可枚举:没有遍历方法
- 没有 size 属性:无法获取元素数量
- 弱引用值:当对象没有其他引用时,可以被垃圾回收
基本操作
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))WeakMap和WeakSet在内存管理方面更优
小结
本章介绍了 ES6 引入的四种集合数据结构:
Map:
- 键可以是任意类型
- 保持插入顺序
- 适合需要非字符串键或频繁增删的场景
Set:
- 存储唯一值
- 高效的成员检查
- 适合数组去重和集合运算
WeakMap:
- 键必须是对象
- 弱引用,支持垃圾回收
- 适合存储私有数据和缓存
WeakSet:
- 只能存储对象
- 弱引用,支持垃圾回收
- 适合标记对象状态
选择合适的集合类型可以提高代码的性能和可读性。在现代 JavaScript 开发中,理解这些数据结构的特点和使用场景至关重要。
练习
- 使用 Map 实现一个简单的缓存系统,支持设置过期时间
- 使用 Set 实现数组的交集、并集、差集运算
- 使用 WeakMap 实现对象的私有属性存储
- 比较在 10000 个元素中,使用 Set 和 Array 查找元素的性能差异
- 实现一个使用 WeakSet 检测循环引用的深拷贝函数