深入浅出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)

相关推荐
清灵xmf22 分钟前
TypeScript 类型进阶指南
javascript·typescript·泛型·t·infer
小白学大数据28 分钟前
JavaScript重定向对网络爬虫的影响及处理
开发语言·javascript·数据库·爬虫
qq_3901617737 分钟前
防抖函数--应用场景及示例
前端·javascript
334554321 小时前
element动态表头合并表格
开发语言·javascript·ecmascript
John.liu_Test1 小时前
js下载excel示例demo
前端·javascript·excel
PleaSure乐事1 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro
哟哟耶耶1 小时前
js-将JavaScript对象或值转换为JSON字符串 JSON.stringify(this.SelectDataListCourse)
前端·javascript·json
理想不理想v1 小时前
vue种ref跟reactive的区别?
前端·javascript·vue.js·webpack·前端框架·node.js·ecmascript
测试19982 小时前
2024软件测试面试热点问题
自动化测试·软件测试·python·测试工具·面试·职场和发展·压力测试
栈老师不回家2 小时前
Vue 计算属性和监听器
前端·javascript·vue.js