WeakMap 与 WeakSet:内存友好的弱引用集合
想象一个图书馆的借阅记录系统,当一本书被下架时,相关的借阅历史也应该自动清除,而不是永远占用存储空间。JavaScript 的 WeakMap 和 WeakSet 就提供了这样的"自动清理"机制——它们使用弱引用存储数据,当对象不再被其他地方使用时,这些数据会被自动垃圾回收。虽然它们的功能相比 Map 和 Set 有所限制,但在特定场景下,它们是防止内存泄漏的利器。
理解弱引用
在深入 WeakMap 和 WeakSet 之前,我们需要理解"弱引用"的概念。
强引用 vs 弱引用
// 强引用:普通的对象引用
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,但只能存储对象,且对这些对象持有弱引用。
基本特性
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)); // falseWeakSet 只有三个方法:add()、has() 和 delete()。没有 size 属性,也不能遍历,因为弱引用的特性使得无法确定集合中有多少元素(它们可能随时被回收)。
垃圾回收演示
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 元素
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,但键必须是对象,并且对键持有弱引用。
基本特性
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)); // falseWeakMap 同样只有四个方法:set()、get()、has() 和 delete()。没有 size、keys()、values() 或 entries(),也不能遍历。
垃圾回收机制
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 的一个经典用途是存储对象的私有数据:
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 中的私有数据也会被回收实际应用:缓存计算结果
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; // 缓存条目可以被回收实际应用:关联元数据
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 的区别:
// 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?
// ✅ 使用 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 实现一个内存安全的观察者模式:
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; // 所有相关监听器可以被回收内存泄漏对比
让我们看一个实际的内存泄漏场景和解决方案:
// ❌ 问题:使用 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. 不可序列化
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. 无法获取大小或遍历
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. 键必须是对象
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");性能考虑
// 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 开发者的重要一步。