从 Set、Map 到 WeakSet、WeakMap 的进阶之旅

在 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 存储私有属性,防止外部访问。
  • 缓存机制:缓存某些对象的计算结果,当对象被垃圾回收时,缓存也会自动清除。
相关推荐
Zero1017131 小时前
【详解pnpm、npm、yarn区别】
前端·react.js·前端框架
&白帝&1 小时前
vue右键显示菜单
前端·javascript·vue.js
Wannaer1 小时前
从 Vue3 回望 Vue2:事件总线的前世今生
前端·javascript·vue.js
羽球知道2 小时前
在Spark搭建YARN
前端·javascript·ajax
光影少年2 小时前
vue中,created和mounted两个钩子之间调用时差值受什么影响
前端·javascript·vue.js
青苔猿猿2 小时前
node版本.node版本、npm版本和pnpm版本对应
前端·npm·node.js·pnpm
一只码代码的章鱼3 小时前
Spring的 @Validate注解详细分析
前端·spring boot·算法
zimoyin3 小时前
Kotlin 协程实战:实现异步值加载委托,对值进行异步懒初始化
java·前端·kotlin
恋猫de小郭3 小时前
如何查看项目是否支持最新 Android 16K Page Size 一文汇总
android·开发语言·javascript·kotlin
赵大仁4 小时前
React Native 与 Expo
javascript·react native·react.js