深入浅出JS的弱引用 WeakMap FinalizationRegistry

大家好,我是一个习惯 发散思维 💡、喜欢 小猫咪 🐱、爱喝 可乐🧃

爱看别人 🎤、 🕺🏽、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 和 map2key,来彼此做对照实验

  • 设置完成之后,将 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 分别存储 personfriends

如果这个时候把 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版)》

一文让你彻底搞懂JS垃圾回收机制 - 掘金 (juejin.cn)

相关推荐
it_remember44 分钟前
新建一个reactnative 0.72.0的项目
javascript·react native·react.js
敲代码的小吉米2 小时前
前端上传el-upload、原生input本地文件pdf格式(纯前端预览本地文件不走后端接口)
前端·javascript·pdf·状态模式
da-peng-song2 小时前
ArcGIS Desktop使用入门(二)常用工具条——数据框工具(旋转视图)
开发语言·javascript·arcgis
低代码布道师3 小时前
第五部分:第一节 - Node.js 简介与环境:让 JavaScript 走进厨房
开发语言·javascript·node.js
满怀10154 小时前
【Vue 3全栈实战】从响应式原理到企业级架构设计
前端·javascript·vue.js·vue
伟笑4 小时前
elementUI 循环出来的表单,怎么做表单校验?
前端·javascript·elementui
确实菜,真的爱5 小时前
electron进程通信
前端·javascript·electron
魔术师ID6 小时前
vue 指令
前端·javascript·vue.js
Clown957 小时前
Go语言爬虫系列教程 实战项目JS逆向实现CSDN文章导出教程
javascript·爬虫·golang
星空寻流年7 小时前
css3基于伸缩盒模型生成一个小案例
javascript·css·css3