ES6 的普及已经很多年了,但其实依然有一些大家比较陌生的特性,WeakMap 和 WeakSet 就是其中的典型之一。它们让人们觉得陌生是有原因的,一方面在 ES6 之前,没有可以实现同样功能的语言特性,也就是说它不像其他多数特性那样是一种给开发者提供便利的语法糖;另一方面,普通前端开发者对于内存管理和垃圾回收方面的理解也相对有限。
在一些带有垃圾回收机制的语言中,会实现一些特殊的引用以便在回收时做不同的策略。比如 Java 中就实现了软引用(SoftReference)和弱引用(WeakReference)。
- 软引用:如果一个对象,仅被软引用所关联,那么当即将要内存不足无法完成分配的时候,该对象将被回收
- 弱引用:如果一个对象仅被弱引用关联,那么当垃圾回收器定期扫描到它时,无论当前是否内存资源告急,对象都将会被回收。ES6 引入的 WeakMap 和 WeakSet 就属于弱引用的情况
Map 和 Set 的用法其实不用说太多,它们都很常见,下面我们主要以 WeakMap 为例看下弱引用版有什么不同。
可见,WeakMap 中与遍历相关的函数或属性全部都去掉了,这也就意味着,我们拿到一个 WeakMap 实例后,如果没有 key,那就什么数据都读不出来;而另一个非常重要的特点是,它的 key 必须是一个 Object,因此哪怕你知道这个 WeakMap 中的某个 key 是一个空对象,你也没法通过重新构造一个空对象实例来作为 key 获取到你想要的数据。
更详细的关于 WeakMap 的用法可以参考 MDN 等文档,这里就不再展开了。我们更关心的是,这个看上去像是阉割版 Map 的 WeakMap 到底有什么用呢?接下来我们通过例子来看看它的应用场景。
下面一段代码中,我们需要在请求的 ctx 上附加一些额外的信息,它在不同节点间会有一些数据上的依赖关系,但同时它们又不能掌控请求 ctx 的生命周期,也不能去修改或添加 ctx 的属性,这种情况下我们会考虑到以 ctx 作为 key,把信息保存在一个 Map 结构中。
const extInfoMap = new Map();
app.use(function middleWareA(ctx, next) {
extInfoMap.set(ctx, someValue);
return next();
});
// 另一个文件中的处理函数
function doSomeProcess(ctx, extInfoMap) {
const valueA = extInfoMap.get(ctx);
const valueB = doSomething(valueA);
extInfoMap.set(ctx, Object.assign(valueA, valueB));
});
但是,上面的方式中存在一个问题:因为 ctx 在请求结束后就应该被销毁了,但此时它被用于 extInfoMap 的 key,因此无法按预期中的进行回收,随着处理的请求越来越多,extInfoMap 引用的内存将越来越大,直到进程发生 OOM。这种情况下,如果我们能够通过读取 ctx 的某些属性判断他的生命周期,则还可以考虑使用 setInterval() 去定期检查已经处理完的 ctx,清理 map 中对应的数据,但如果我们完全无法判断此处的 ctx 的状态,可能只能用暴力的超时机制来解决了。
想想看,上面介绍的 WeakMap 是否能解决这个问题呢?没错,我们可以利用 WeakMap 间接向一些可能会被销毁或回收的对象中无侵入的添加属性。这一点其实就是 WeakMap 提供的最重要的能力:
let someObj = { myKey: 'myValue' };
// WeakMap 可以看作是一种平行空间
const weakMap = new WeakMap();
// 以一个对象作为平行空间的钥匙(key),在里面放进一些属性
weakMap.set(someObj, { hiddenKey, 'hiddenValue' });
// 如果只能拿到 weakMap 或 someObj 中的一个,那么是没有任何手段获取到平行空间的数据的
// 只有同时凑齐了两者,才能打开对应的平行空间入口获取隐藏的数据
weakMap.get(someObj); // -> { hiddenKey, 'hiddenValue' }
// 当原有的作为 key 的对象不再被其他代码引用(强引用),
someObj = null;
// 即使用另一个完全相同的对象重新赋值,效果也是一样,因为引用的内存地址发生了变化
someObj = { myKey: 'myValue' };
// 平行空间 weakMap 中的相应数据也就无法获取到了
weakMap.has(someObj) // -> false
// 最初作为 key 的对象不再被强引用关联,只是 weakMap 弱引用了它
// 当 vm 执行过一次 gc 之后,它将被销毁,而属于它的平行空间中的数据也被一同销毁
// 无论是否手动触发 gc 效果都一样
global.gc();
利用这个基本特征,其实还可以衍生出其他的一些使用场景:
某些场景下用来做较重的函数结果缓存
用 WeakMap 来做缓存的特点是缓存的有效期就是一个请求或任务的处理过程中,key 使用请求或任务的上下文对象,过程结束后缓存数据自动释放。
这种缓存机制可以结合懒加载的方式,使得某些数据的加载尽量延后或完全避免(某些执行路径上不需要),同时在一些相对可控的情况下完全不需要考虑缓存限制和缓存清理的问题。比如下面的示例:
// 通过中间件获取用户完整信息
// 有的业务中可能不一定会用到,有的业务中又会在不同阶段多次使用
app.use(async function(ctx, next) {
ctx.userInfo = await getUserExtInfo(ctx.cookies.get('SESSION'));
// some code ...
});
// 通过函数缓存实现
const userMap = new WeakMap();
async function getUserInfo() {
let info = userMap.get(this);
if (info === undefined) {
info = getUserExtInfo(this.cookies.get('SESSION'));
userMap.set(this, info); // ctx 销毁后 info 自动销毁
}
return info;
}
app.use(async function(ctx, next) {
ctx.getUserInfo = getUserInfo.bind(ctx);
// some code ...
});
async function someTask(ctx) {
ctx.getUserInfo(); // 首次触发时发起查询
}
async function anotherTask(ctx) {
ctx.getUserInfo(); // 再次查询从 userMap 中获取
}
对一些动态创建的对象添加一些用于追踪或埋点处理逻辑等
这个场景最典型的应该就是 Node 中的 process.on('unhandledRejection', listener)
的实现。
相关源码可在 node 仓库中找到: lib/internal/process/promises.js
// 用于追踪所有未处理异常的 Promise
const maybeUnhandledPromises = new WeakMap();
// 将没有处理异常的 promise 添加进来
function unhandledRejection(promise, reason) {
maybeUnhandledPromises.set(promise, {
reason,
uid: ++lastPromiseId,
warned: false
});
// some code ...
}
// 如果给 Promise 添加了异常,将其从 weakmap 中删除
function handledRejection(promise) {
const promiseInfo = maybeUnhandledPromises.get(promise);
if (promiseInfo !== undefined) {
maybeUnhandledPromises.delete(promise);
// some code ...
}
}
// 当 Promise Reject 之后的处理
function processPromiseRejections() {
// some code ...
const promise = pendingUnhandledRejections.shift();
const promiseInfo = maybeUnhandledPromises.get(promise);
if (promiseInfo === undefined) {
continue;
}
// some code ...
process.emit('unhandledRejection', reason, promise);
// some code ...
}