【方案篇】事件监听函数的内存泄漏,帮你搞定!不谢!!!

本文是 理论篇 ,还有下篇 代码篇。

前言

工作中,我们会对window, DOM节点,WebSoket, 或者单纯的事件中心等注册事件监听函数。

// window
window.addEventListener("message", this.onMessage);
// WebSoket
socket.addEventListener('message', function (event) {
    console.log('Message from server ', event.data);
});
// EventEmitter
emitter.on("user-logined", this.onUserLogined);

要是没有移除,就可能导致内存的泄漏。

SPA更加剧了这种现象
比如React组件加载后,在window上注册了监听事件,组件卸载没有删除,极有可能像滚雪球一样,一发不可收拾。

componentDidMount(){
    window.addEventListener("resize", this.onResize);
}
componentWillUnmount(){
  // 忘记remove EventListener
}

今天我们的主题,就是分析事件监听,并排查因此可能导致的内存泄漏。

本文主要讨论和分析几个技术点:

  1. 怎么准确知道某个对象或者函数是否被回收
  2. 常见事件监听函数的本质
  3. 如何收集DOM事件监听函数
  4. 拦截方法常见方式
  5. 弱引用回收问题
  6. 如何裁定事件监听函数重复

效果演示

报警,高危事件统计,事件统计等功能,我们一起来看看效果吧。

预警

当你进行事件注册的时候,如果发现四同属性的事件监听,就进行报警。
四同:

  1. 同一事件从属对象
    比如Window, Socket等同一个实例
  2. 事件类型,比如 message, resize, play等等
  3. 事件回调函数
  4. 事件回调函数选项

截图来自我对实际项目的分析, message事件重复添加,预警!!

[图片上传失败...(image-22f4a5-1632814967824)]

高危统计

高危统计是对预警的拔高,他会统计 四同属性 的事件监听。是排查事件回调函数泄漏最重要方法。

DOM事件

截图来自我对实际项目的分析 , window对象上message消息的重复添加, 次数高达10
[图片上传失败...(image-6ad34-1632814967825)]

EventEmitter模块
截图来自我对实际项目的分析 ,APP_ACT_COM_HIDE_ 系列事件重复添加
[图片上传失败...(image-127a0-1632814967825)]

事件统计

按照类型,罗列所有的事件监听回调函数,包含函数名,也可以包含函数体。
这中我们分析问题也是极其有用的。
[图片上传失败...(image-8473bd-1632814967825)]

所以,

各位大哥,各位大哥,各位大哥, 务必给函数起个名字,务必给函数起个名字,务必给函数起个名字。,

这时候,名字真的很重要!!!!!!!

怎么准确知道某个对象或者函数是否被回收

一种直观有效的答案: 弱引用 WeakRef + GC(垃圾回收)

为什么要弱引用呢? 因为我们不能因为我们的分析和统计影响对象的回收?不然分析肯定也不准了。

弱引用

WeakRef 是ES2021提出来的,用于直接创建对象的弱引用, 不会妨碍原始对象被垃圾回收机制清除。

WeakRef 实例对象有一个deref()方法,如果原始对象存在,该方法返回原始对象;如果原始对象已经被垃圾回收机制清除,该方法返回undefined。

let target = {};
let wr = new WeakRef(target);

let obj = wr.deref();
if (obj) { // target 未被垃圾回收机制清除
  // ...
}

来看一个实际的例子:
左边target不会被回收,右边会被回收。
[图片上传失败...(image-b97350-1632814967825)]

看到这里,你应该至少有两个意识:

  1. window.gc()是什么鬼
    其是v8提供的方法,主动触发垃圾回收,接下来会提到。
  2. IIFE这种闭包的应用,确实可以程度减少变量污染和泄漏

垃圾回收

垃圾回收是有周期的,以chrome浏览器为例,是可以主动执行垃圾回收的。 本应该被回收的对象,主动执行回收操作之后,倘若他还在,那很大可能性就导致了泄漏。

基于v8引擎的浏览器,怎么主动执行垃圾回收呢?
答案是: 修改chrome的启动参数,加上 --js-flags="--expose-gc"即可

[图片上传失败...(image-4af508-1632814967825)]

之后,你就可以只直接调用gc方法进行垃圾回收

[图片上传失败...(image-5f7d65-1632814967825)]

小结

有了 WeakRef + 主动GC
你就可以在你觉得可能泄漏或污染的地方进行测试,排查问题。

通过现象看本质

事件监听的表象

回归主题,我们今天的重点是事件回调函数,
我们的在web编程中常遇见的事件订阅类型表象有:

  • DOM事件
    主要是DOM2级别的事件,也就是addEventListener, removeEventListener
  • WebSocket, socket.io, ws, mqtt等等这

从本质上来看,也是两种:

  • 基于 EventTarget 的事件订阅
    window, document , body, div等等这种常见DOM相关的元素,XMLHttpRequest, WebSocket,AudioContext 等等,其本质都是继承了EventTarget。
  • 基于 EventEmitter的事件订阅
    mqtt 和 ws 是基于 events 。
    著名的 socket.io是基于component-emitter。
    其都有共同点就是通过 on和off等方法来订阅和取消事件。

所以,我们要想监听和收集事件的订阅和取消订阅的信息,就变得简单了。

本质 - prototype

不管是EventTarget系列还是EventEmitter系列, 其本质都是实例化之后,在实例上进行订阅和取消订阅。

而实例化,为了更加好复用和更少的内存开销,一般会把公用的方法放到prototype上面,没错,问题的本质都回到原型上。

所以,事件回调收集,就是在原生上做手脚,改写原型上订阅和取消订阅的方法,两个点:

  1. 收集事件监听信息
  2. 保持原有功能

进一步的本质就是方法拦截, 那我们就一起再走近方法拦截

方法拦截

几种方法拦截的方式

方法拦截,我这里收集和整理了大约7种方式,当然有些类似,问题不大:

  • 简单重写原来的方法
  • 简单代理
  • 继承方式
  • 动态代理
  • ES6+ 标准的的 Proxy
  • ES5 标准的的 defineProperty
  • ES6+ 标准的的 decotator

具体每个方法的简单例子可以到这 几种方法拦截的方式

比较理想和通用的当然是后三种,

  • decotator
    显然这里不太合适,一是装饰器要 显式 的入侵代码,二是成本代价太高。

  • defineProperty
    非常直接和有效的方法,重定义 get,返回我们修改后的函数即可。
    不过,我就不,就不,我就喜欢玩Proxy.

  • Proxy
    其一:Proxy,返回的是一个新的对象,你需要使用这个新的对象,才能有效的代理。
    其二:我们做事要负责,要支持还原,所以更准确的说,我们要使用的是可取消的代理。 简单的代码就是如下:

const ep = EventTarget.prototype;
const rvAdd = Proxy.revocable(ep.addEventListener, this.handler);
ep.addEventListener = rvAdd.proxy;

如何收集DOM事件监听函数

我们拦截原型方法,本质就是为了收集事件监听函数。 其实除了拦截原型,也有一些方式可以获取到。

第三方库 getEventListeners

其只是直接修改了原型方法,并在节点上存储相关信息,结果可行,不推荐这么玩。

缺点

  1. 入侵了每个节点,节点上保留了事件信息
  2. 单次只能获取一个元素的监听事

chrome 控制台 getEventListeners方法获得单个Node的事件

缺点

  1. 只能在控制台使用
  2. 单次只能获取一个元素的监听事件

chrome控制台, Elements => Event Listeners

  1. 只能在开发者工具界面使用
  2. 查找相对麻烦

chrome more tools => Performance monitor 可以得到 JS event listeners, 也就是事件总数

并未有详细的信息,只有一个统计数据

数据结构和弱引用的问题

前面已经提到过了WeakRef,但是我们得思考,需要对哪些对象进行弱引用。

选择什么数据结构存储

既然是进行统计和分析,肯定要存储一些数据。
而这里我们是需要以对象作为键,因为我们要统计的是某个EventTarget或者EventEmitter的实例的事件订阅情况。

所以首选项, Map , Set, WeakMap, WeakSet,你会选择谁呢?
WeapMap和WeakSet看起来美好,但存在一个 很致命 的问题,那就是 不能进行遍历 。 不能遍历,当然就没法进行统计。

所以这里选择Map是比较合适的。

对那些数据弱引用

先罗列一下事件订阅和取消订阅需要设计到的数据:

window.addEventListener("message", this.onMessage, false);
emitter.on("event1", this.onEvent1);

对照代码分析:

  1. target
    事件挂载的对象,假如是EventEmitter,挂载的对象就是其实例
  2. type
    事件类型
  3. listener
    监听函数
  4. options
    选项,虽然EventEmitter没有,都没有,我们就认为一样即可。

选择的是对target和listener进行弱引用,
, 大致的数据存储结构如下。

[图片上传失败...(image-dc1b6d-1632814967825)]

我们对事件从属的主体和事件回调函数进行弱引用,TypeScript表示为:

interface EventsMapItem {
    listener: WeakRef<Function>;
    options: TypeListenerOptions
}

Map<WeakRef<Object>, Record<string, EventsMapItem[]>>

看似OK了,其实有个不容小视的问题,伴随着程序的支持运行, Map的Key的数量会增长,这个Key为WeakRef, WeakRef弱引用的对象可能已经被回收了,而与tartget关联的WeakRef并不会被回收。

当然,你可以周期性的去清理,也可以遍历的时候无视这些没有真实引用的WeakRef的Key。

但是,不友好! 这里,就有请下一个主角 FinalizationRegistry

FinalizationRegistry

FinalizationRegistry 对象可以让你在对象被垃圾回收时请求一个回调。

看个简单的例子:

const registry = new FinalizationRegistry(name => {
    console.log(name,  " 被回收了");
});
var theObject = {
    name: '测试对象',
}
registry.register(theObject, theObject.name);
setTimeout(() => {
    window.gc();

    theObject = null;
}, 100);

[图片上传失败...(image-57e5f9-1632814967825)]

对象被回收之后,如期的输出消息,这里, theObject = null不可以少,所以,确定对象不被使用之后,设置为null绝对不是一个坏习惯。

我们用FinalizationRegistry来监听对事件从属对象的回收,代码大致如下:

  #listenerRegistry = new FinalizationRegistry<{ weakRefTarget: WeakRef<object> }>(
    ({ weakRefTarget }) => {
      console.log("evm::clean up ------------------");
      if (!weakRefTarget) {
        return;
      }
      this.eventsMap.remove(weakRefTarget);
      console.log("length", [...this.eventsMap.data.keys()].length);
    }
  )

不难理解,因为Map的key就是WeakRef<object>, 所以target被回收之后,我们需要把与其关联的WeakRef也删除掉。

到此为止,我们可以收集对象和其注册的事件监听函数信息,有了数据,下一步就是预警,分析和统计。

如何判断重复添加的事件监听函数

基于EventTarget的事件订阅

先来一起看一段代码,请思考一下,按钮点击之后,输出了几次 clicked:

<button id="btn1">点我啊</button>

function onClick(){
    console.log("clicked");
}
const btnEl = document.getElementById("btn");

btnEl.addEventListener("click", onClick);
btnEl.addEventListener("click", onClick);
btnEl.addEventListener("click", onClick);

答案是: 1次

因为EventTarget有天然去重的本领,具体参见 多个相同的事件处理器

你可能说你懂了,那我们稍微提升一下难度, 现在是几次呢??

    btnEl.addEventListener("click", onClick);
    btnEl.addEventListener("click", onClick, false);
    btnEl.addEventListener("click", onClick, {
        passive: false,
    });
    btnEl.addEventListener("click", onClick, {
        capture: false,
    });
    btnEl.addEventListener("click", onClick, {
        capture: false,
        once: true,
    });

答案: 还是 1次

其裁定是否相同回调函数的标准是:options中的capture的参数值一致。capture默认值是false。

正因为这个特性,我们在拦截订阅函数的时候,需要进行判断,以免误收集。

如果是addEventListener返回的是布尔值,那倒是可以作为一个判断的依据,可惜的是返回的是undefined,天意,笑过,不哭。

到这里,有些人应该是笑了, 这不是不重复添加吗? 那又何来泄漏???

泄漏的根本来源

我开头提了一句 SPA更加剧了这种现象, 这种现象就是事件函数导致内存泄漏的现象。

// Hooks 也有同样问题
componentDidMount(){
    window.addEventListener("resize", this.onResize);
}
componentWillUnmount(){
  // 忘记remove EventListener
}

this.onResize是随着组件一起创建的,所以组件每创建一次,其也会被创建一次,虽然代码相同,但依旧是一个新的她。
组件销毁时,但是this.onResize时被 window 给引用了,并不能被销毁。

后果可想而知, 基于 EventEmitter的事件函数,也是同样的道理。 如果你有打日志的习惯,就会发现,疯狂输出的日志,你该庆幸, 发现了泄漏问题。

何为相同函数

如何判断相同函数,成为了我们的关键。

引用相同当然是,在基于EventTarget事件订阅体系下,是天然屏蔽的,而基于EventEmitter的订阅体系就没那么幸运了。

这时候,大家天天见,却也忽视不见的方法出场了, toString, 没错,是他,是他,是他,我们可爱的小toString。

function fn(){
    console.log("a");
}
console.log(fn.toString()) 
// 输出::
// function fn(){
//    console.log("a");
//}

大家还有记得 玉伯 的 seajs 吗,其依赖查找,就是借助了toString

[图片上传失败...(image-e7bba0-1632814967825)]

我们比内容,绝大情况是没问题了, 除了:

  1. 你的函数代码真就一样
    ESLint里面有一个规则,好像是没使用this的方法,是不该写在class里面的。真一样,你应该思考是的代码实现了。
  2. 内置函数
const random = Math.random
console.log("name:", random.name, ",content:", random.toString())
// name: random ,content: function random() { [native code] }
  1. 被bind的函数
function a(){
    console.log("name:", this.name)
}

var b = a.bind({})
console.log("name:", b.name, ",content:", b.toString())
// name: bound a ,content: function () { [native code] }

所以我们检查内置函数和bind之后的函数,基本思路就是name和{ [native code] }。

问题大不,问题挺大,也就是说我们被bind之后的函数无法被比较了,就无法裁定是否是相同的函数了。

重写bind

答案就是重写bind,让其返回的函数有属性指向原函数,如果有更好的方式,请务必告诉我。

function log(this: any) {
    console.log("name:", this.name);
}

var oriBind = Function.prototype.bind;
var SymbolOriBind = Symbol.for("__oriBind__");
Function.prototype.bind = function () {
    var f = oriBind.apply(this as any, arguments as any);
    f[SymbolOriBind] = this;
    return f;
}

const boundLog: any = log.bind({ name: "哈哈" });
console.log(boundLog[SymbolOriBind].toString());

//function log() {
//    console.log("name:", this.name);
//}

重写bind之后,必然会多了不安定元素, 所以:

  1. 也采用WeakRef来引用,减少不安定心理
  2. 默认不开启重写bind
    基本问题排查差不多了,再开启重写bind选项,仅仅分析被bind之后的事件监听函数。

怎么识别是不是被bind之后的函数,还是上面提到的

  1. 函数名, 其名为 bound [原函数名]
  2. 函数体, { [native code] }

小结

基本思路

  1. WeakRef建立和target对象的关联,并不影响其回收
  2. 重写 EventTarget 和 EventEmitter 两个系列的订阅和取消订阅的相关方法, 收集事件注册信息
  3. FinalizationRegistry 监听 target回收,并清除相关数据
  4. 函数比对,除了引用比对,还有内容比对
  5. 对于bind之后的函数,采用重写bind方法来获取原方法代码内容

两个疑虑

  1. 兼容性
    是的,只能用在比较新的浏览器上调试。但是,问题不大! 发现并修复了,低版本大概率也修复了。

  2. 移动端怎么调试
    可以的,不是本文重点。

上面的几个问题分析完毕之后,我们完事具备,只欠东风。

敬请期待 代码篇

写在最后

技术交流群请到 这里来。
或者添加我的微信 dirge-cloud,带带我,一起学习。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容