大家好,我是一个习惯 发散思维 💡、喜欢 小猫咪 🐱、爱喝 可乐🧃
爱看别人 唱 🎤、跳 🕺🏽、rap 💬、 篮球 🏀 的 前端开发者 村头一只鹅鹅 😉
感谢阅读我写的文章,你的支持是我更新的动力
1. 背景
最近在写深拷贝代码的时候,发现大部分文章都会使用 WeakMap
来解决依赖循环的问题,而且在 Vue
的源码中也大量使用了 WeakMap
但是对于 WeakMap
一直没怎么深入了解过。
那么为什么去选择 WeakMap
,以及 WeakMap
背后代表的弱引用究竟是什么意思?通过阅读本文章可以收获:
-
关于 gc 的相关知识
-
FinalizationRegistry API 的使用
-
从内存图的角度来说明 WeakMap 代表的 弱引用 究竟是意思
-
WeakSet 和 WeakRef 的使用
-
弱引用的应用示例:DOM属性缓存、数据缓存
-
......
注意 :本文大部分实验都位于 Nodejs 18 的版本来做的处理,可能 gc 版本存在差异
2. 看下 Java 的引用
大家都知道高级语言的其实都是相通的,特别是高级语言,比如隔壁的 Java,毕竟咱是 JavaScript(doge),多少带点相似。
我们可以看下图, Java 分为了下面的 4个引用方式
弱引用:在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存
从概念的角度来看,我们可以发现它与 JavaScript 其实是一致的。对于垃圾收集(GC)机制而言,如果一个对象仅存在弱引用而无强引用,那么该对象将被视为可清理对象,并随之执行清除操作。
3. 前置学习条件
在展开主线之前,让我们先了解一下必要的前置知识。如果你已经知道这些知识了,可以直接跳过
3.1 Nodejs如何去执行GC
首先我们需要知道如何使用 Nodejs 来手动执行 GC,以及手动执行的一些坑
Nodejs 中其实是可以手动执行 gc 函数,只不过被 Nodejs 给封存起来了,我们需要手动解开
参考下图就可以知道 gc 在没解开封印之前是不能被找到的
我们只需要在执行的时候,带上 --expose-gc
即可解开封装
3.2 FinalizationRegistry
我们在介绍弱引用之前还需要介绍一下 FinalizationRegistry 这个API,他是 ES12 新加的,FinalizationRegistry 允许我们注册一个回调函数,当注册的值被垃圾回收时,这个回调函数会被调用。这样我们就可以直观的看到哪些参数被回收了
具体可以上 MDN 查看详细使用:FinalizationRegistry - JavaScript | MDN (mozilla.org)
-
使用构造函数创建,并且传递一个回调函数,当注册的对象被垃圾回收就会执行该回调函数
-
使用 register 来注册对象,后面可以传递参数,该参数会传递给回调函数
-
使用 register 注册的对象进行的引用时弱引用,这样时符合逻辑的,不然就无法被垃圾回收了
-
使用 unregister 即可取消注册,传入注册时的对象
这样我们就可以很直观的看到哪些对象被垃圾回收了
我们再来看 MDN 对于 GC 的解释,可以看出 GC 的实现其实也可能存在不同
GC 在一个 JavaScript 引擎中的行为有可能在另一个 JavaScript 引擎中的行为大相径庭,或者甚至在同一类引擎,不同版本中 GC 的行为都有可能有较大的差距
前辈们解决了浏览器兼容,现在还要为 GC 不同而苦恼,哥哥,好苦啊!!!前端开发不做也罢!!!(开玩笑的,开玩笑的)
既然我们知道了这个 API 的使用,那么我们来实战一下,我来发问:为什么 person 没有被移除掉,person没被用到啊!V8引擎会把他清理掉吧!而且allocateMemory 已经疯狂吃内存了
这里我们不能惯性思维,这个代码我给 交流群(吹水群) 好多人看过,好多人其实都没看出来,都按照惯性思维来理解了
这里我们先手动清除,可以看到已经被清除了
我们来解释一下上图为什么会出现这个问题,因为他在全局的作用域中,并不会清除掉,并且 person 作为一个变量引用着在,即便你没使用过他
我们来试一下函数作用域,函数执行完毕就会标记为垃圾,那么就会清理释放,我们来看下图示例
欸,这不是没清除吗?别着急,因为垃圾清理是需要触发机制的,我们再请出我们的 垃圾制造机 来触发 GC,这个函数之后会频繁用到
可以看到在函数作用域的情况下,函数执行完毕就会将不需要使用的变量标记为 不活跃的对象,随后垃圾回收就会将这部分数据清除掉,同时 FinalizationRegistry 也监听到了垃圾回收,执行了回调
4. WeakMap / WeakSet
4.1 WeakMap
我们就以 WeakMap 为切入点来介绍一下弱引用,同时编写如下代码:
-
我们创建 WeakMap 和 Map ,并且传入 key1 和 key2 对象作为 map1 和 map2 的 key,来彼此做对照实验
-
设置完成之后,将 key1 和 key2 赋值为 null,解除对它们的引用
-
手动执行 gc 来清除不活跃的对象,再来查看输出的格式
有长的帅的要说了:"为啥 WeakMap 是 < item unknown > 啊!看着好难受啊,能不能解决呢?"其实是可以的,我们引入 inspect 即可打印 WeakMap 中的值
解决方案:javascript - ES6: console.log on WeakSet gives - Stack Overflow
inspect文档:Util | Node.js v20.12.1 Documentation (nodejs.org)
javascript
const { inspect } = require("util");
const fr = new FinalizationRegistry((value) => {
console.log(`${value} 于 ${new Date().getTime()} 被垃圾回收掉了~`);
});
// 执行 key1
let key1 = { name: "key1" };
const map1 = new WeakMap();
map1.set(key1, "key1");
// 执行 key2
let key2 = { name: "key2" };
const map2 = new Map();
map2.set(key2, "key2");
// 注册 FinalizationRegistry
fr.register(key1, "key1");
fr.register(key2, "key2");
// 手动释放 key1 和 key2
key1 = null;
key2 = null;
console.log(inspect(map1, { showHidden: true }));
console.log(inspect(map2, { showHidden: true }));
console.log("============================================");
// 手动执行gc
gc();
console.log(inspect(map1, { showHidden: true }));
console.log(inspect(map2, { showHidden: true }));
我们通过下面的输出结果可以对比出结果,可以发现 Map 中的引用依旧存在,而 WeakMap 中的引用已经消失了,这就是弱引用和强引用核心的区别
我这里画了一个内存图来供大家查看,这是存储时的结构内存图(注意:JS引擎不同,实现的原理也不一定相同,下面只做原理解释,并非实际情况)
-
可以看到 key1 和 key2 都是引用类型,所以只存储地址值,实际的数据在别处
-
map1 和 map2 也是一样,存储 键(key) 是地址值来做的引用
-
map1 作为 WeakMap 的弱引用 引用着 key1 ,也就是下图的 0x123
当我们把 key1 和 key2 赋值为 null 的时候
-
可以发现 key1 和 key2 对于地址值为 0x123 和 0x456 的引用没了 ,只剩下 map1 和 map2 的引用了
-
这个时候手动执行 gc 操作,key1 和 key2 会被清除
-
map1 对于 key1(0x123) 是弱引用,在垃圾清除的时候会被清除掉,而 map2 的 依旧引用着 key2,所以没有被删除
最终的结果可以参考下图:map1 已经没数据了,而 map2 依旧强引用这 key2 的地址,所以没有被清除
根据上面的内存图的解释,是不是更加明白了弱引用的作用了。弱引用可以去建立连接,但是他不保证该引用被清除(你把它当作渣男,是不是就好理解了 doge )
好,上面的知道了,下面我们再来一个示例来看看
-
person 是一个引用类型,其中 friends 也是一个引用类型
-
map1 和 map2 都是 WeakMap 分别存储 person 和 friends
如果这个时候把 person 设置为 null 的时候,内存会如何变化
-
person 的引用断开了,只剩下 WeakMap 的 key 的引用
-
如果执行 gc 的话,2个对象都会被清除,我们来执行一下结果,看下是不是的
可以看到和预期一致,引用全部断开
5.2 WeakSet
如果你知道了 WeakMap 的解释,那么 WeakSet 也是一样的,这里就不画图了,直接放例子即可
-
创建 person 引用类型 和 WeakSet ,将 person 放进 WeakSet 中
-
我们手动取消 person 的引用,并且手动执行 gc ,可以看到 WeakSet 和 Person 都被清理掉了
5. WeakRef
我们来介绍一下 WeakRef ,这个 API 使用的比较少,也不推荐使用,其原理看懂了上面的 WeakMap 就知道它如何去使用了
-
使用 WeakRef 来引用 person,这样来建立弱引用,随后便分别取消引用,赋值为 null
-
使用 process.memoryUsage() 来监听内存变化,主要看 heapUsed 即可,在执行完 gc 之后内存确实减少了
-
执行完 gc 之后,weakRef 可以被释放掉,但是 person 不能被释放掉,也可能是已经释放掉了,但是 FinalizationRegistry 并没有监听到而已(浏览器环境可以被释放掉)
注意 :我在 Nodejs 的环境中始终无法去释放 WeakRef 引用的对象,但是在浏览器环境中可以正常的释放,我暂时不知道是因为什么原因导致的差异,如果有知道的可以评论区留言,感谢大佬们!!!
我们来看下浏览器环境 的变化,我来手动去控制台执行 gc,可以看到 person 和 weakref 都可以被释放掉
同时在 MDN 中也给出了 Avoid where possible 的警告,告诉我们最好不要使用 WeakRef 这个 API,这个我就当作了解了吧!
6. 应用
我们知道了弱引用是啥!那么我们在那里可以应用到呢?老实说,在平常的业务中使用的频率不算很高,都有更加熟悉的处理方式。大部分都是主前端的项目或者框架级别的项目,去使用这个特性。如果硬是要用的话,可以总结为数据缓存
6.1 DOM属性缓存
我看了很多网上 DOM节点 的示例,一直没发现特别能说服自己的,我这里就引用 张鑫旭 大佬的博客中提到点作为参考自己想了一个示例
博客地址:JS WeakMap应该什么时候使用 << 张鑫旭-鑫空间-鑫生活 (zhangxinxu.com)
在看 DOM属性缓存之前先看一下下面的代码,熟悉一下代码
-
我们对 .main 进行了点击点击监听,当我们点击之后便会删除 .main 自身节点
-
删除之后,我们来循环打印 mainEl ,可以发现 节点 被删除了,但是节点的数据依旧保持着引用,我们是可以通过这个节点来复原
-
如果你想删除引用只能手动将 mainEl 设置 null,然后让 gc 来做清除
知道了上面的点之后,我们再来看下面的代码
-
我添加了一个 .main 并且使用 FinalizationRegistry 来监听 .main 的引用 mainEl 是否被垃圾回收了
-
使用 WeakMap 来缓存 mainEl 的各类属性,这样我们可以为该节点添加各种各样的初始化属性了
-
为 mainEl 添加事件监听,如果我们点击之后,就会删除 mainEl 自身 。同时也添加一个 button 的点击事件,点击之后就会使用 WeakMap 中保存的初始化属性
-
在删除节点之后,疯狂扩展内存,手动触发浏览器的 垃圾回收 机制,来查看输出的结果
html
<div class="main">
<div class="item">列表元素</div>
<div class="item">列表元素</div>
<div class="item">列表元素</div>
</div>
<button class="apply" style="margin-top: 10px">应用属性</button>
<script>
const fr = new FinalizationRegistry((value) => {
console.log(`${value} 于 ${new Date().getTime()} 被垃圾回收掉了~`);
});
let mainEl = document.querySelector(".main");
// 使用 WeakMap 为 main 添加属性
const wm = new WeakMap();
wm.set(mainEl, {
style: {
textColor: "blue",
backgroundColor: "red",
},
attr: {
name: "wrapper-main",
},
});
// 注册监听,如果 mainEl 被垃圾回收就执行回调函数
fr.register(mainEl, "mainEl");
// 对 main 进行事件监,点击之后删除 main 节点
const removeMainEl = () => {
// 解除绑定,删除元素数据
mainEl.removeEventListener("click", removeMainEl);
mainEl.remove();
mainEl = null;
allocateMemory();
};
mainEl.addEventListener("click", removeMainEl);
// 对 apply 进行事件监听,点击之后为 mainEl 添加属性
const applyEl = document.querySelector(".apply");
applyEl.addEventListener("click", () => {
if (wm.has(mainEl)) {
const { style, attr } = wm.get(mainEl);
mainEl.style.backgroundColor = style.backgroundColor;
mainEl.style.color = style.textColor;
mainEl.setAttribute("name", attr.name);
}
});
// 疯狂扩展内存,激活 V8 的 GC
function allocateMemory() {
Array.from({ length: 50000 }, () => () => {});
setInterval(allocateMemory, 1000);
console.log("V8:累死我了,数组添加成功~");
}
</script>
代码已经准备好了,我们来分析一下 WeakMap 在这段代码中做了什么?
先前已提及,WeakMap 采用弱引用方式,适用于数据缓存。对于 mainEl(.main节点) 同样采用弱引用,这意味着一旦 mainEl 被清除,作为 key 的它也将不复存在,进而与之关联的 value 也会被一并释放。这种机制实现了对 mainEl 节点属性的缓存初始值操作:节点存在则属性保留,节点消失则属性随之消失,我们无需再手动清除节点属性的引用
注意 :可能各个浏览器存在差异,存在差异,存在差异!!! 我在 Edge 123 中控制台无法监听到 mainEl 被释放掉了,其实已经被释放掉了,只不过没在控制台输出,必须手动点击浏览器才能恢复,但是在 谷歌浏览器 中是正常运行,要运行这段代码记得注意一下
6.2 数据缓存
在一些情况下,对象中的一些数据我们需要存储管理,而这些数据和对象没什么关联,不好直接丢到对象中,我们就可以将这些数据传递进 WeakMap 中,我们来看下面的示例:
-
创建一个对象 person,它有一些不完整的数据,我们可以手动来做处理
-
创建一个 setCustom 函数,它可以计算出 key 的个数,还可以记录 learnFactory 函数的返回结果
-
我们执行 get 就可以获取这个对象的额外属性,这样可以避免很多的麻烦,提前对部分数据做缓存,需要的时候取出即可
-
如果我们将 person 设置为 null 的时候,那么 垃圾回收 会做处理,WeakMap 的 key 消失,其引用的 value 也会随之被清理
7. 总结
写原创文章不易!!!如果讲的好,请给出你手动的点赞👍👍👍,如果文章有问题,也可以在评论区留言哦!
参考文章
强引用、软引用、弱引用和虚引用 - 掘金 (juejin.cn)
WeakMap - JavaScript | MDN (mozilla.org)
WeakMap and WeakSet(弱映射和弱集合) (javascript.info)
《JavaScript高级程序设计(第4版)》