在 ES5 时代,JavaScript 的数据结构主要依赖于两种类型:数组和对象。然而,随着应用规模的增长和复杂性上升,传统的数据结构越来越难以满足开发需求。比如,需要一个能自动去重的集合、一个支持任意类型键名的字典、一个不会造成内存泄漏的弱引用映射......
为了解决这些问题,ECMAScript 2015(也称 ES6 )正式引入了四个全新的内建数据结构:Set、Map、WeakSet 和 WeakMap。这些结构不是对旧有数据结构的简单补充,而是为现代 JavaScript 编程量身定制的"加强型武器"。
一、Set
1. 定义与特点
Set 是一种集合类型的数据结构,用于存储唯一的值。它类似于数组,但成员的值都是唯一的,没有重复的值。Set 中的值可以是各种类型的值,包括原始值和对象引用。
2. 基本操作
javascript
const mySet = new Set();
// 添加元素
mySet.add(1);
mySet.add(5);
mySet.add(5); // 重复的值不会被添加
// 检查元素是否存在
console.log(mySet.has(1)); // true
console.log(mySet.has(3)); // false
// 删除元素
mySet.delete(5);
// 获取集合大小
console.log(mySet.size); // 1
// 清空集合
mySet.clear();
3. 遍历方法
Set 提供了多种遍历方法:
- mySet.keys():返回一个包含集合中所有键的迭代器。
- mySet.values():返回一个包含集合中所有值的迭代器。
- mySet.entries():返回一个包含集合中所有键值对的迭代器。
- mySet.forEach(callbackFn, thisArg):对集合中的每个元素执行一次给定的函数。
javascript
const mySet = new Set([1, 2, 3]);
for (let item of mySet) console.log(item); // 1 2 3
mySet.forEach((value) => {
console.log(value);
});
4. 使用场景
1、数组去重
javascript
const numbers = [1, 2, 2, 3, 4, 4, 5];
const uniqueNumbers = [...new Set(numbers)];
console.log(uniqueNumbers); // [1, 2, 3, 4, 5]
2、集合操作:交集、并集、差集等。
javascript
const setA = new Set([1, 2, 3]);
const setB = new Set([3, 4, 5]);
// 并集
const union = new Set([...setA, ...setB]);
// 交集
const intersection = new Set([...setA].filter(x => setB.has(x)));
// 差集
const difference = new Set([...setA].filter(x => !setB.has(x)));
5. 底层原理
1、底层实现结构
JavaScript 是高级语言,无法直接查看内部源码,但根据 ECMAScript 规范与主流 JS 引擎实现(如 V8),可以推断出 Set 的底层结构大致如下:内部使用类似哈希表的数据结构存储数据。
- Set 使用了一种哈希集合(HashSet)的数据结构实现。
- 它为每个加入的元素计算哈希值,用这个哈希值来判断是否为重复项。
- 通过哈希定位数据插入/查询的位置,从而实现 插入、查找、删除的高性能操作。
2、Set 的去重原理:SameValueZero 算法
Set 判定两个值是否相同,是使用了 ECMAScript 中定义的 SameValueZero 算法,而不是 ===
或 ==
。
SameValueZero 的特点:
- NaN === NaN 是 false,但在 Set 中 NaN 和 NaN 被认为是相等的。
- +0 和 -0 被认为是相等的。
- 基本上和 === 一样,只是 NaN 也等于自己。
🌰
javascript
const s = new Set();
s.add(NaN);
s.add(NaN);
s.add(+0);
s.add(-0);
console.log(s); // Set(2) { NaN, 0 }
3、Set 的插入逻辑(伪代码)
javascript
class MySet {
constructor(iterable) {
this._storage = {}; // 哈希桶
if (iterable) {
for (const item of iterable) {
this.add(item);
}
}
}
add(value) {
const hash = this._hash(value); // 简化哈希逻辑
if (!this._storage.hasOwnProperty(hash)) {
this._storage[hash] = value;
}
return this;
}
has(value) {
const hash = this._hash(value);
return this._storage.hasOwnProperty(hash);
}
delete(value) {
const hash = this._hash(value);
if (this._storage.hasOwnProperty(hash)) {
delete this._storage[hash];
return true;
}
return false;
}
_hash(value) {
// 简化处理:真实引擎内部使用复杂引用检查和哈希函数
if (typeof value === 'object') {
return JSON.stringify(value); // 仅示意,不可用于真实应用
}
return String(value);
}
}
6. Set 的实际性能如何?
- 插入(add)复杂度:O(1)
- 查找(has)复杂度:O(1)
- 删除(delete)复杂度:O(1)
相比数组的 O(n) 查找,Set 在处理大量数据的去重或快速查找时效率更高。
二、Map
1. 定义与特点
Map 是一种键值对的集合,类似于对象(Object),但键的范围不限于字符串,任何值(包括对象)都可以作为键。Map 中的键值对是有序的,按插入的顺序进行迭代。
2. 基本操作
javascript
const myMap = new Map();
// 设置键值对
myMap.set('name', 'Alice');
myMap.set(1, 'number one');
myMap.set(true, 'boolean true');
// 获取值
console.log(myMap.get('name')); // Alice
// 检查键是否存在
console.log(myMap.has(1)); // true
// 删除键值对
myMap.delete(true);
// 获取Map大小
console.log(myMap.size); // 2
// 清空Map
myMap.clear();
3. 遍历方法
Map 提供了多种遍历方法:
- myMap.keys():返回一个包含Map中所有键的迭代器。
- myMap.values():返回一个包含Map中所有值的迭代器。
- myMap.entries():返回一个包含Map中所有键值对的迭代器。
- myMap.forEach(callbackFn, thisArg):对Map中的每个键值对执行一次给定的函数。
javascript
const myMap = new Map([
['name', 'Bob'],
['age', 25],
]);
for (let [key, value] of myMap) {
console.log(`${key}: ${value}`);
}
4. 应用场景
1、存储关联数据
当需要将键值对关联在一起,并且键可以是对象时,Map 是更好的选择。
javascript
const user1 = { name: 'Alice' };
const user2 = { name: 'Bob' };
const userRoles = new Map();
userRoles.set(user1, 'admin');
userRoles.set(user2, 'editor');
console.log(userRoles); // Map(2) { { name: 'Alice' } => 'admin', { name: 'Bob' } => 'editor' }
2、缓存数据
javascript
const cache = new Map();
function fetchData(id) {
if (cache.has(id)) return cache.get(id);
const data = loadFromServer(id);
cache.set(id, data);
return data;
}
3、频繁添加和删除键值对:Map 在这方面性能优于对象。
5. 为什么要引入 Map?
传统的 Object 有如下局限:
- 键只能是字符串或 Symbol
- 无法保证键值对顺序
- 原型污染风险存在
- 获取长度需要额外操作(Object.keys(obj).length)
因此,ES6 引入了 Map,更适合用作结构化数据的容器。
6. 底层实现原理
Map 是基于一种**哈希表(Hash Table)+ 双向链表(Linked List)**组合的数据结构实现的。哈希表存储键值映射,双向链表记录插入顺序。
伪代码来模拟内部结构(简化版,仅作理解用)
javascript
class SimpleMap {
constructor() {
this._buckets = {}; // 哈希桶,用于键值映射
this._order = []; // 保证插入顺序
}
_hash(key) {
// 简化处理:真实 JS 引擎会对对象引用地址做处理
return typeof key === 'object' ? JSON.stringify(key) : String(key);
}
set(key, value) {
const hash = this._hash(key);
if (!(hash in this._buckets)) {
this._order.push(hash); // 插入顺序
}
this._buckets[hash] = { key, value };
}
get(key) {
const hash = this._hash(key);
return this._buckets[hash]?.value;
}
has(key) {
return this._hash(key) in this._buckets;
}
delete(key) {
const hash = this._hash(key);
const exists = hash in this._buckets;
if (exists) {
delete this._buckets[hash];
this._order = this._order.filter(h => h !== hash);
}
return exists;
}
keys() {
return this._order.map(hash => this._buckets[hash].key);
}
values() {
return this._order.map(hash => this._buckets[hash].value);
}
entries() {
return this._order.map(hash => [this._buckets[hash].key, this._buckets[hash].value]);
}
}
⚠️ 真正引擎会对对象键使用内部标识管理,而不会 JSON 序列化对象。
与 Set 类似,Map 也使用 SameValueZero 来比较键是否相等:
javascript
const map = new Map();
map.set(NaN, 'a');
map.set(NaN, 'b'); // 覆盖上面的值
console.log(map.size); // 1
console.log(map.get(NaN)); // 'b'
内部存储示意图
javascript
Map 实例
↓
哈希表(键 -> 值)
"name" → "Alice"
objId123 → "UserData"
funcId456 → "Func"
↓
插入顺序链表
["name", objId123, funcId456]
7. Map 和 Object 的关键区别
对比点 | Map | Object |
---|---|---|
键类型支持 | 任意类型(对象、函数等) | 仅字符串和 Symbol |
键值对有序 | 有序(插入顺序) | 无序(规范不保证) |
内存泄漏风险 | 高(强引用键) | 低(仅原始键) |
原型污染风险 | 无(无默认属性) | 有(需注意 proto) |
获取长度性能 | 常数 .size | 需手动计算长度 |
是否可迭代 | 是(可 for...of, .entries()) | 否(需手动提取) |
8. Map 的实际性能如何?
- set 时间复杂度:O(1)
- get 时间复杂度:O(1)
- has 时间复杂度:O(1)
- delete 时间复杂度:O(1)
- 遍历 时间复杂度:O(n)
适合场景:大数据量的键值管理,尤其是非字符串键。
三、有了 Map 和 Set,为什么还需要 WeakMap 和 WeakSet?
1. 背景导入:为什么会用 Map 和 Set?
在日常开发中,Map 和 Set 是 ES6 提供的强大数据结构:
- Map:可以用对象作为键,解决了传统对象只能用字符串作为键的局限。
- Set:可以存储唯一值(包括对象、原始类型等),用于去重、快速查找等场景。
但它们的一个问题是------强引用(Strong Reference)。
2. 强引用 vs 弱引用
Strong Reference vs Weak Reference
什么是"强引用"?
在 JavaScript 中,默认的引用都是强引用:
只要某个对象被一个变量强引用着,垃圾回收器(GC)就不会回收这个对象的内存。
比如:
javascript
const obj = { name: 'Tom' };
const map = new Map();
map.set(obj, 'hello');
这里,map 对 obj 的引用是强引用,就算 obj 原始变量被设为 null,obj 也依然不会被 GC,因为 map 还引用着它。
什么是"弱引用"?
弱引用是一种不会阻止垃圾回收器回收对象的引用。
javascript
const obj = { name: 'Tom' };
const weakMap = new WeakMap();
weakMap.set(obj, 'hello');
当 obj = null 且没有其他引用指向这个对象时,这个对象会被 GC 自动清理掉,哪怕它还作为 weakMap 的键。
本质差别总结
对比项 | 强引用 (Map, Set) | 弱引用 (WeakMap, WeakSet) |
---|---|---|
是否阻止 GC | 是 | 否 |
可被枚举 | 是(可遍历) | 否(不可遍历) |
键支持类型 | 任意值(Map) | 只能是对象(WeakMap) |
使用场景 | 普通缓存/字典结构 | 私有数据/临时缓存/监听器等 |
3. 为什么 WeakMap 和 WeakSet 是必要的?
1、防止内存泄露
在一些临时数据或缓存管理场景中,如果使用 Map 或 Set 来存储对象,一旦忘记清除,内存就泄漏了。
javascript
let obj = {};
let map = new Map();
map.set(obj, 'data');
obj = null; // 虽然变量 obj 已被置空,但 map 还在引用它,这个对象永远不会被回收!
但如果使用 WeakMap:
javascript
let obj = {};
let weakMap = new WeakMap();
weakMap.set(obj, 'data');
obj = null; // 此时没有强引用,GC 会自动回收 obj 对应的内存
这样可以 自动释放内存,防止内存泄漏。
2、封装私有属性(隐藏实现)
WeakMap 被广泛用于 JS 类或组件内部存储私有数据,不暴露给外部。
javascript
const _privateData = new WeakMap();
class Person {
constructor(name) {
_privateData.set(this, { name });
}
getName() {
return _privateData.get(this).name;
}
}
const p = new Person('Alice');
console.log(p.getName()); // Alice
3、事件监听管理、DOM 元素缓存
比如在做页面事件绑定时,经常会给 DOM 元素绑定元数据,并确保在 DOM 被删除后自动清理内存,防止内存泄漏。
javascript
const elementMeta = new WeakMap();
function bindMeta(el, meta) {
elementMeta.set(el, meta);
}
function getMeta(el) {
return elementMeta.get(el);
}
这里的键(key)只能是对象类型,通常是 DOM 元素。值(value)是想要绑定的元数据,可以是任何内容(对象、字符串、状态等)。
🌰
javascript
const btn = document.querySelector('#submit');
bindMeta(btn, { clicked: false });
一旦 DOM 被移除并置空,相关元数据也会被自动清理,无需手动解绑,非常适合短生命周期对象管理。
4. 底层实现原理简述
Map 的实现:
- 底层是哈希表(Hash Table),键和值存储在内部分开的结构。
- 键是强引用,对象不会被 GC。
WeakMap 的实现:
- 同样是哈希结构,但键是弱引用对象。
- 键必须是对象(不能是原始值),这样才能与对象生命周期绑定。
- 键不可遍历:无法使用 forEach 或 keys(),因为这样就可能阻止 GC。
为什么不能遍历 WeakMap?
如果允许遍历 WeakMap,GC 就必须保留所有键的引用,违背了"弱引用"的设计初衷。 所以为了确保安全,WeakMap 是不可枚举的。同理 WeakSet 也是一样的。
四、WeakSet
1. 定义与特点
WeakSet 是一种集合类型的数据结构,类似于 Set,但只能存储对象,并且这些对象是弱引用的。这意味着,如果没有其他变量引用某个对象,该对象会被垃圾回收机制回收。
2. 基本操作
javascript
const ws = new WeakSet();
const obj = {};
ws.add(obj);
console.log(ws.has(obj)); // true
ws.delete(obj);
console.log(ws.has(obj)); // false
特点:
- 只能存储对象,不能存储原始值。
- 对象是弱引用的,不会阻止垃圾回收。
- 不可遍历,没有 size 属性。
- 不支持迭代,没有 forEach、values、keys、entries 方法。
- 没有 clear() 方法。
3. 应用场景
- 存储DOM节点:当需要存储DOM节点,并且不希望这些节点被垃圾回收机制阻止时,可以使用 WeakSet。
- 私有数据的存储:在类中使用 WeakSet 存储私有数据,防止外部访问。
五、WeakMap
1. 定义与特点
WeakMap 是一种键值对的集合,类似于 Map,但键必须是对象,值可以是任意类型。键是弱引用的,这意味着如果没有其他变量引用该键对象,该键值对会被垃圾回收机制回收。
2. 基本操作
javascript
const wm = new WeakMap();
const obj = {};
wm.set(obj, 'some value');
console.log(wm.get(obj)); // 'some value'
wm.delete(obj);
console.log(wm.has(obj)); // false
特点:
- 键必须是对象,不能是原始值。
- 键是弱引用的,不会阻止垃圾回收。
- 不可遍历,没有 size 属性。
- 没有 clear() 方法。
3. 应用场景
- 私有属性的存储:在类中使用 WeakMap 存储私有属性,防止外部访问。
- 缓存机制:缓存某些对象的计算结果,当对象被垃圾回收时,缓存也会自动清除。