Skip to content

WeakMap 与 WeakSet:内存友好的弱引用集合

想象一个图书馆的借阅记录系统,当一本书被下架时,相关的借阅历史也应该自动清除,而不是永远占用存储空间。JavaScript 的 WeakMap 和 WeakSet 就提供了这样的"自动清理"机制——它们使用弱引用存储数据,当对象不再被其他地方使用时,这些数据会被自动垃圾回收。虽然它们的功能相比 Map 和 Set 有所限制,但在特定场景下,它们是防止内存泄漏的利器。

理解弱引用

在深入 WeakMap 和 WeakSet 之前,我们需要理解"弱引用"的概念。

强引用 vs 弱引用

javascript
// 强引用:普通的对象引用
let user = { name: "Alice", age: 25 };
let users = [user]; // 数组持有对 user 的强引用

// 即使 user 变量被设为 null,对象仍然存在
user = null;
console.log(users[0]); // { name: "Alice", age: 25 } - 对象still在内存中

// 弱引用:WeakMap/WeakSet 中的引用
let weakMap = new WeakMap();
let obj = { id: 1 };
weakMap.set(obj, "some data");

// 当 obj 没有其他引用时,它会被垃圾回收
// 同时 WeakMap 中的条目也会被自动删除
obj = null; // 对象可以被回收了(假设没有其他引用)

强引用就像用钉子把照片钉在墙上,只要钉子 still 在,照片就不会掉。弱引用则像用磁铁贴照片,一旦磁性消失(没有其他强引用),照片就会自动掉落。

WeakSet - 弱引用对象集合

WeakSet 类似于 Set,但只能存储对象,且对这些对象持有弱引用。

基本特性

javascript
let weakSet = new WeakSet();

// ✅ 可以添加对象
let obj1 = { name: "Object 1" };
let obj2 = { name: "Object 2" };

weakSet.add(obj1);
weakSet.add(obj2);

// ❌ 不能添加基本类型值
// weakSet.add(1);        // TypeError
// weakSet.add("string"); // TypeError
// weakSet.add(true);     // TypeError

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

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

WeakSet 只有三个方法:add()has()delete()。没有 size 属性,也不能遍历,因为弱引用的特性使得无法确定集合中有多少元素(它们可能随时被回收)。

垃圾回收演示

javascript
let weakSet = new WeakSet();

// 创建对象并添加到 WeakSet
let obj = { data: "important" };
weakSet.add(obj);

console.log(weakSet.has(obj)); // true

// 移除强引用
obj = null;

// 此时,对象没有其他强引用,可以被垃圾回收
// WeakSet 中的条目也会被自动删除
// (实际回收时间由垃圾回收器决定,不是立即发生)

// 我们无法直接验证对象是否被回收,
// 因为没有 obj 的引用,也就无法调用 weakSet.has(obj)

实际应用:标记 DOM 元素

javascript
class DOMElementTracker {
  constructor() {
    this.processedElements = new WeakSet();
  }

  // 处理 DOM 元素
  process(element) {
    if (this.processedElements.has(element)) {
      console.log("Element already processed");
      return;
    }

    // 执行处理逻辑
    console.log("Processing element...");
    element.classList.add("processed");

    // 标记为已处理
    this.processedElements.add(element);
  }

  // 检查元素是否已处理
  isProcessed(element) {
    return this.processedElements.has(element);
  }
}

// 使用示例(浏览器环境)
// let tracker = new DOMElementTracker();
// let button = document.querySelector('.btn');
// tracker.process(button);
// tracker.process(button); // "Element already processed"

// 当 DOM 元素从页面移除且没有其他引用时,
// WeakSet 中的条目会自动清理,不会造成内存泄漏

WeakMap - 弱引用键值对集合

WeakMap 类似于 Map,但键必须是对象,并且对键持有弱引用。

基本特性

javascript
let weakMap = new WeakMap();

let key1 = { id: 1 };
let key2 = { id: 2 };

// 设置键值对
weakMap.set(key1, "value 1");
weakMap.set(key2, "value 2");

// ❌ 键必须是对象
// weakMap.set("string", "value"); // TypeError
// weakMap.set(123, "value");      // TypeError

// 获取值
console.log(weakMap.get(key1)); // "value 1"

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

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

WeakMap 同样只有四个方法:set()get()has()delete()。没有 sizekeys()values()entries(),也不能遍历。

垃圾回收机制

javascript
let weakMap = new WeakMap();

function createUser() {
  let user = { name: "Bob", email: "[email protected]" };
  weakMap.set(user, { lastLogin: new Date(), sessionId: "abc123" });
  return user;
}

let user = createUser();
console.log(weakMap.get(user)); // { lastLogin: ..., sessionId: "abc123" }

// 移除强引用
user = null;

// 此时 user 对象可以被垃圾回收
// WeakMap 中的对应条目也会被自动删除
// 不会造成内存泄漏

实际应用:私有数据存储

WeakMap 的一个经典用途是存储对象的私有数据:

javascript
const privateData = new WeakMap();

class User {
  constructor(name, email, password) {
    // 公开数据
    this.name = name;
    this.email = email;

    // 私有数据存储在 WeakMap 中
    privateData.set(this, {
      password: password,
      createdAt: new Date(),
    });
  }

  // 验证密码(访问私有数据)
  authenticate(password) {
    let data = privateData.get(this);
    return data.password === password;
  }

  // 修改密码
  changePassword(oldPassword, newPassword) {
    if (!this.authenticate(oldPassword)) {
      throw new Error("Invalid old password");
    }

    let data = privateData.get(this);
    data.password = newPassword;
  }

  // 获取账户年龄
  getAccountAge() {
    let data = privateData.get(this);
    let now = new Date();
    return Math.floor((now - data.createdAt) / (1000 * 60 * 60 * 24));
  }
}

// 使用示例
let user = new User("Emma", "[email protected]", "secret123");

console.log(user.name); // "Emma" - 公开属性可访问
console.log(user.password); // undefined - 密码是私有的

console.log(user.authenticate("secret123")); // true
console.log(user.authenticate("wrong")); // false

user.changePassword("secret123", "newPassword456");
console.log(user.authenticate("newPassword456")); // true

// 当 user 对象被销毁时,WeakMap 中的私有数据也会被回收

实际应用:缓存计算结果

javascript
class ExpensiveCalculator {
  constructor() {
    this.cache = new WeakMap();
  }

  // 复杂计算(示例:计算对象的哈希)
  calculate(obj) {
    // 检查缓存
    if (this.cache.has(obj)) {
      console.log("Returning cached result");
      return this.cache.get(obj);
    }

    // 执行计算
    console.log("Performing calculation...");
    let result = this.performExpensiveOperation(obj);

    // 缓存结果
    this.cache.set(obj, result);

    return result;
  }

  performExpensiveOperation(obj) {
    // 模拟复杂计算
    let hash = 0;
    let str = JSON.stringify(obj);
    for (let i = 0; i < str.length; i++) {
      hash = (hash << 5) - hash + str.charCodeAt(i);
      hash = hash & hash;
    }
    return hash;
  }
}

// 使用示例
let calculator = new ExpensiveCalculator();

let data1 = { id: 1, values: [1, 2, 3] };
let data2 = { id: 2, values: [4, 5, 6] };

console.log(calculator.calculate(data1)); // Performing calculation...
console.log(calculator.calculate(data1)); // Returning cached result

console.log(calculator.calculate(data2)); // Performing calculation...

// 当 data1 和 data2 不再被使用时,缓存会自动清理
data1 = null; // 缓存条目可以被回收

实际应用:关联元数据

javascript
class ElementMetadata {
  constructor() {
    this.metadata = new WeakMap();
  }

  // 为元素设置元数据
  setMetadata(element, data) {
    if (!this.metadata.has(element)) {
      this.metadata.set(element, {});
    }

    let meta = this.metadata.get(element);
    Object.assign(meta, data);
  }

  // 获取元数据
  getMetadata(element, key) {
    if (!this.metadata.has(element)) {
      return undefined;
    }

    let meta = this.metadata.get(element);
    return key ? meta[key] : meta;
  }

  // 增加计数器
  incrementCounter(element, counterName) {
    if (!this.metadata.has(element)) {
      this.metadata.set(element, {});
    }

    let meta = this.metadata.get(element);
    meta[counterName] = (meta[counterName] || 0) + 1;
    return meta[counterName];
  }
}

// 使用示例(浏览器环境)
// let metadataManager = new ElementMetadata();
//
// let button = document.querySelector('.btn');
// metadataManager.setMetadata(button, {
//   tooltip: "Click to submit",
//   theme: "primary"
// });
//
// // 跟踪点击次数
// button.addEventListener('click', () => {
//   let clicks = metadataManager.incrementCounter(button, 'clicks');
//   console.log(`Button clicked ${clicks} times`);
// });
//
// // 当 button 从 DOM 移除且没有其他引用时,元数据自动清理

WeakMap vs Map 对比

让我们系统对比 WeakMap 和 Map 的区别:

javascript
// 1. 键的类型
let map = new Map();
let weakMap = new WeakMap();

// Map 可以使用任何类型作为键
map.set("string", "value");
map.set(123, "value");
map.set({ id: 1 }, "value");

// WeakMap 只能使用对象作为键
let objKey = { id: 1 };
weakMap.set(objKey, "value");
// weakMap.set("string", "value"); // TypeError

// 2. 引用类型
// Map 持有强引用
let obj1 = { name: "Test" };
map.set(obj1, "data");
obj1 = null; // 对象仍在 Map 中,不会被回收

// WeakMap 持有弱引用
let obj2 = { name: "Test" };
weakMap.set(obj2, "data");
obj2 = null; // 对象可以被垃圾回收

// 3. 可迭代性
console.log(map.size); // 可以获取大小
for (let [key, value] of map) {
  // 可以遍历
  console.log(key, value);
}

// console.log(weakMap.size);     // undefined - 没有 size
// for (let entry of weakMap) {}  // TypeError - 不可遍历

// 4. 可用方法
// Map: set, get, has, delete, clear, keys, values, entries, forEach
// WeakMap: set, get, has, delete (仅这四个)

选择 WeakMap 还是 Map?

javascript
// ✅ 使用 WeakMap 当:
// 1. 需要关联对象与数据,但不想影响垃圾回收
let elementCache = new WeakMap();

// 2. 存储私有数据
const privateProps = new WeakMap();

// 3. 临时缓存,不需要手动清理
let computationCache = new WeakMap();

// ❌ 不要使用 WeakMap 当:
// 1. 需要遍历键值对
// 2. 需要知道集合大小
// 3. 键不是对象
// 4. 需要序列化数据

// ✅ 使用 Map 当:
// 1. 需要遍历或获取所有键/值
let userRoles = new Map();

// 2. 需要持久化数据
let appConfig = new Map();

// 3. 需要使用非对象键
let statusCodes = new Map([
  [200, "OK"],
  [404, "Not Found"],
]);

实战案例:观察者模式

让我们用 WeakMap 实现一个内存安全的观察者模式:

javascript
class EventEmitter {
  constructor() {
    // 使用 WeakMap 存储监听器,避免内存泄漏
    this.listeners = new WeakMap();
  }

  // 为对象注册事件监听
  on(target, eventName, callback) {
    if (!this.listeners.has(target)) {
      this.listeners.set(target, new Map());
    }

    let targetListeners = this.listeners.get(target);

    if (!targetListeners.has(eventName)) {
      targetListeners.set(eventName, []);
    }

    targetListeners.get(eventName).push(callback);
  }

  // 移除事件监听
  off(target, eventName, callback) {
    if (!this.listeners.has(target)) {
      return;
    }

    let targetListeners = this.listeners.get(target);

    if (!targetListeners.has(eventName)) {
      return;
    }

    let callbacks = targetListeners.get(eventName);
    let index = callbacks.indexOf(callback);

    if (index > -1) {
      callbacks.splice(index, 1);
    }
  }

  // 触发事件
  emit(target, eventName, ...args) {
    if (!this.listeners.has(target)) {
      return;
    }

    let targetListeners = this.listeners.get(target);

    if (!targetListeners.has(eventName)) {
      return;
    }

    let callbacks = targetListeners.get(eventName);
    callbacks.forEach((callback) => {
      callback.apply(target, args);
    });
  }

  // 移除对象的所有监听器
  removeAllListeners(target) {
    this.listeners.delete(target);
  }
}

// 使用示例
let emitter = new EventEmitter();

class DataModel {
  constructor(name) {
    this.name = name;
    this.data = {};
  }

  set(key, value) {
    this.data[key] = value;
    emitter.emit(this, "change", key, value);
  }

  get(key) {
    return this.data[key];
  }
}

// 创建模型
let userModel = new DataModel("user");

// 注册监听器
let onChange = (key, value) => {
  console.log(`User data changed: ${key} = ${value}`);
};

emitter.on(userModel, "change", onChange);

// 触发事件
userModel.set("name", "Alice"); // User data changed: name = Alice
userModel.set("age", 25); // User data changed: age = 25

// 当 userModel 被销毁时,WeakMap 中的监听器也会被自动清理
userModel = null; // 所有相关监听器可以被回收

内存泄漏对比

让我们看一个实际的内存泄漏场景和解决方案:

javascript
// ❌ 问题:使用 Map 可能导致内存泄漏
class LeakyCache {
  constructor() {
    this.cache = new Map();
  }

  processElement(element) {
    // 为每个元素缓存处理结果
    let result = this.performProcessing(element);
    this.cache.set(element, result);
    return result;
  }

  performProcessing(element) {
    // 模拟复杂处理
    return { processed: true, timestamp: Date.now() };
  }
}

// 问题:即使元素从 DOM 移除,Map 仍持有引用
// let cache = new LeakyCache();
// let div = document.createElement('div');
// cache.processElement(div);
// div.remove(); // DOM 中移除
// div = null;   // 但 Map 仍持有引用,导致内存泄漏!

// ✅ 解决方案:使用 WeakMap
class SafeCache {
  constructor() {
    this.cache = new WeakMap();
  }

  processElement(element) {
    // 检查缓存
    if (this.cache.has(element)) {
      return this.cache.get(element);
    }

    // 处理并缓存
    let result = this.performProcessing(element);
    this.cache.set(element, result);
    return result;
  }

  performProcessing(element) {
    return { processed: true, timestamp: Date.now() };
  }
}

// WeakMap 会在元素被移除且无其他引用时自动清理
// let safeCache = new SafeCache();
// let div = document.createElement('div');
// safeCache.processElement(div);
// div.remove();
// div = null; // WeakMap 中的条目可以被回收,不会泄漏

限制与注意事项

1. 不可序列化

javascript
let weakMap = new WeakMap();
let obj = { id: 1 };
weakMap.set(obj, "data");

// ❌ 无法序列化 WeakMap
console.log(JSON.stringify(weakMap)); // "{}"

// 如果需要序列化,使用 Map
let map = new Map([[obj, "data"]]);
let serialized = JSON.stringify([...map]);

2. 无法获取大小或遍历

javascript
let weakSet = new WeakSet();
let obj1 = { id: 1 };
let obj2 = { id: 2 };
weakSet.add(obj1);
weakSet.add(obj2);

// ❌ 无法获取大小
// console.log(weakSet.size); // undefined

// ❌ 无法遍历
// for (let obj of weakSet) {} // TypeError

// 这是设计如此,因为弱引用的对象可能随时被回收

3. 键必须是对象

javascript
let weakMap = new WeakMap();

// ❌ 基本类型不能作为键
// weakMap.set(1, "value");      // TypeError
// weakMap.set("key", "value");  // TypeError
// weakMap.set(Symbol(), "val"); // TypeError

// ✅ 只能使用对象
weakMap.set({}, "value");
weakMap.set([], "value");
weakMap.set(new Date(), "value");

性能考虑

javascript
// WeakMap/WeakSet 在某些场景下性能更好,因为不需要手动管理内存

let size = 10000;

// 测试:Map 需要手动清理
console.time("Map with manual cleanup");
let map = new Map();
let objects = [];

for (let i = 0; i < size; i++) {
  let obj = { id: i };
  objects.push(obj);
  map.set(obj, `data${i}`);
}

// 清理:需要手动删除
objects.forEach((obj) => map.delete(obj));
console.timeEnd("Map with manual cleanup");

// 测试:WeakMap 自动清理(但我们无法直接测试回收)
console.time("WeakMap auto cleanup");
let weakMap = new WeakMap();

for (let i = 0; i < size; i++) {
  let obj = { id: i };
  weakMap.set(obj, `data${i}`);
  // obj 在循环结束后没有引用,可以被回收
}
console.timeEnd("WeakMap auto cleanup");

总结

WeakMap 和 WeakSet 是 JavaScript 中特殊的集合类型:

WeakSet 特点:

  • 只存储对象
  • 对对象持有弱引用
  • 不可迭代,没有 size
  • 方法:add、has、delete

WeakMap 特点:

  • 键必须是对象
  • 对键持有弱引用
  • 不可迭代,没有 size
  • 方法:set、get、has、delete

主要优势:

  • 自动垃圾回收,防止内存泄漏
  • 适合存储临时数据或元数据
  • 不影响对象的生命周期

适用场景:

  • 存储 DOM 元素关联数据
  • 实现私有属性
  • 缓存对象计算结果
  • 标记/跟踪对象状态

不适用场景:

  • 需要遍历或获取大小
  • 需要序列化
  • 需要使用基本类型作为键
  • 需要持久化数据

虽然 WeakMap 和 WeakSet 的功能有限,但它们在特定场景下是不可替代的工具。正确使用它们可以让你的应用更加内存高效,避免常见的内存泄漏问题。理解何时使用弱引用集合是成为高级 JavaScript 开发者的重要一步。