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

6,225 阅读13分钟

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

代码篇: 【代码篇】事件监听函数的内存泄漏,都给我退散吧!

前言

工作中,我们会对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, resizeplay等等
  3. 事件回调函数
  4. 事件回调函数选项

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

image.png

高危统计

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

DOM事件

截图来自我对实际项目的分析 , window对象上message消息的重复添加, 次数高达10 image.png

EventEmitter模块
截图来自我对实际项目的分析 ,APP_ACT_COM_HIDE_ 系列事件重复添加 image.png

事件统计

按照类型,罗列所有的事件监听回调函数,包含函数名,也可以包含函数体。 这中我们分析问题也是极其有用的。 image.png

所以,

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

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

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

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

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

弱引用

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

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

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

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

来看一个实际的例子:
左边target不会被回收,右边会被回收。 image.png

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

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

垃圾回收

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

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

image.png

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

image.png

小结

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

通过现象看本质

事件监听的表象

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

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

本质上来看,也是两种:

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

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

本质 - 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没有,都没有,我们就认为一样即可。

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

image.png

我们对事件从属的主体和事件回调函数进行弱引用,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.png

对象被回收之后,如期的输出消息,这里, 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.png

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

  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. 重写 EventTargetEventEmitter 两个系列的订阅和取消订阅的相关方法, 收集事件注册信息
  3. FinalizationRegistry 监听 target回收,并清除相关数据
  4. 函数比对,除了引用比对,还有内容比对
  5. 对于bind之后的函数,采用重写bind方法来获取原方法代码内容

两个疑虑

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

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

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

敬请期待 代码篇

写在最后

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