
咱们做前端的,天天都在跟Object
和Array
打交道。但ES6其实还给我们提供了另外两个非常有意思的数据结构:WeakMap
和WeakSet
。
说实话,我刚开始学这两个东西的时候,也觉得有点鸡肋。Map
和Set
用得好好的,为啥非要搞个"Weak(弱鸡)"版本?而且它们还不能遍历,功能上好像还是阉割版。
直到有一次,我排查一个线上页面卡顿的问题,用Chrome的内存快照(Heap Snapshot)工具,发现了一些本该被销毁的DOM节点,却因为被我写的一个全局Map
引用着,导致一直无法被垃圾回收(GC),造成了内存泄漏。
从那次之后,我才真正理解了WeakMap
和WeakSet
存在的意义。它们不是Map
和 Set
的替代品,而是为了解决一类非常特殊的、关于内存管理问题而生的。
JavaScript的垃圾回收
在聊WeakMap
之前,我们必须花一分钟,快速回顾一下JS的垃圾回收机制。
现代JS引擎(比如V8)的GC,主要依赖一个叫做 可达性(Reachability) 的概念。简单说就是:
- 有一个"根"对象(比如全局的
window
对象,或者函数内部的局部变量)。 - 从这个"根"出发,能通过引用链找到的对象,就是可达的,意味着它们是"活的",不能被回收。
- 如果一个对象,从任何一个"根"出发,都无法找到它了,那它就是不可达的,GC就会在适当的时候把它清理掉,释放内存。
现在,问题来了。如果我们用一个普通的Map
来存储一些数据,会发生什么?
JavaScript
// 假设这是一个全局的Map,用来缓存一些DOM节点的信息
const domCache = new Map();
function cacheNodeInfo() {
const element = document.getElementById('my-element');
if (element) {
// 我们把DOM节点作为key,存储了一些信息
domCache.set(element, { someInfo: '...' });
}
}
cacheNodeInfo();
// 后来,我们在某个操作中,把这个DOM节点从页面上移除了
document.getElementById('my-element').remove();
内存泄漏就发生在这里!
虽然你已经把#my-element
这个DOM节点从页面上移除了,但因为全局的domCache
这个Map
,还通过key
的方式,强引用着这个DOM节点对象,所以从GC的角度看,这个节点依然是可达的。
只要domCache
不被销毁,这个DOM节点对象和它关联的数据,就会永远待在内存里,无法被回收。
WeakMap
如何解决这个问题
WeakMap
就是为了解决上面这个问题而生的。
它的核心特性就一个:它的key
必须是对象,并且这个key
是对对象的弱引用。
"弱引用"是什么意思?
它是一种不会被GC计入可达性判断的引用。GC看到一个对象只被弱引用指着,就会认为:"哦,没人需要你了",然后就会把它回收掉。
我们把上面的例子改成WeakMap
:
JavaScript
const domCache = new WeakMap(); // 改成WeakMap
function cacheNodeInfo() {
const element = document.getElementById('my-element');
if (element) {
domCache.set(element, { someInfo: '...' });
}
}
cacheNodeInfo();
document.getElementById('my-element').remove();
// 在下一次GC发生时,由于#my-element这个DOM节点对象不再被任何强引用指向
// (页面上没了,domCache的引用又是弱的),
// GC就会把它连同domCache里与之关联的{ someInfo: '...' }一起回收掉。
// 内存泄漏的问题,就这么自动解决了。
WeakMap
的这个特性,决定了它最典型的应用场景:
将一些元数据(metadata),附加到一个宿主对象上,并且不影响这个宿主对象的生命周期。
就像上面的例子,DOM节点是宿主,我们想给它附加一些额外信息,但我们不希望因为我们的附加行为,导致这个DOM节点无法被正常销毁。
我在哪里用到了WeakMap
?
1. 缓存DOM节点相关的数据
这是最经典的用法,就像上面的例子一样。比如,你需要给某个DOM节点关联一个复杂的配置对象、一个事件监听器集合、或者一个类的实例。
2. Vue 3响应式系统的核心
我后来去看Vue 3的源码,发现WeakMap
在它的响应式系统里扮演了至关重要的角色。
Vue 3用Proxy
来实现响应式。它需要一个地方,来存储"原始对象"和它对应的"响应式代理对象"之间的映射关系,避免重复代理。
JavaScript
// 极简化的伪代码
const reactiveMap = new WeakMap();
function reactive(target) {
// 如果已经代理过,直接返回缓存的代理
if (reactiveMap.has(target)) {
return reactiveMap.get(target);
}
const proxy = new Proxy(target, ...);
// 缓存映射关系
reactiveMap.set(target, proxy);
return proxy;
}
这里为什么必须用WeakMap
?因为如果用普通Map
,只要reactiveMap
存在,所有被代理过的"原始对象"就永远不会被销毁,即使你的组件早就卸载了,页面上已经没人用它了。这会造成巨大的内存泄漏。
WeakSet
呢?它有什么用?
理解了WeakMap
,WeakSet
就很好懂了。
Set
存储的是值的强引用集合。WeakSet
存储的是对象 的弱引用集合。
WeakSet
的用处相对少一些,它主要用来标记一个对象,判断一个对象是否"在"或者"不在"某个集合里,同样不影响这个对象的垃圾回收。
一个常见的场景:防止重复处理
假设你有一个函数,需要处理一些DOM节点,但你想确保同一个节点在一次操作中只被处理一次。
JavaScript
const processedNodes = new WeakSet();
function processNode(node) {
if (processedNodes.has(node)) {
// 已经处理过了,直接跳过
return;
}
// ...这里是处理节点的逻辑...
console.log('Processing node:', node);
// 处理完,把它加到集合里,做个标记
processedNodes.add(node);
}
const node1 = document.getElementById('node-1');
const node2 = document.getElementById('node-2');
processNode(node1); // 会执行
processNode(node2); // 会执行
processNode(node1); // 会跳过
这里用WeakSet
的好处是,当node1
从页面上被移除后,processedNodes
里对它的弱引用会自动消失,不会造成内存泄漏。
什么时候该用它们?
你应该优先考虑使用WeakMap
或WeakSet
的场景,都符合一个核心特征:
你想把一个"对象A"和一些"信息B"关联起来,但是"信息B"的生命周期,应该完全依赖于"对象A"的生命周期。当"对象A"被销- 时,"信息B"也应该被自动清理掉。
WeakMap
:用在需要{ 对象A: 信息B }
这种键值对关联的场景。WeakSet
:用在只需要标记[ 对象A ]
是否存在的场景。
它们都不是万能的。因为它们不可遍历,功能也有限。但在合适的场景下,它们是解决内存泄漏问题。
希望这篇文章,能让你们对这两个低调的API,有更深的理解❀。