阅读 2507

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

小知识,大挑战!本文正在参与「程序员必备小知识」创作活动
本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

前言

内存泄漏是个很严肃的问题,可是迄今也没有一个非常有效的排查方案,本方案就是针对性的单点突破。

工作中,我们会对windowDOM节点,WebSoket, 或者单纯的事件中心等注册事件监听函数, 添加了,没有移除,就会导致内存泄漏,如何预警,收集,排查这种问题呢?

本文是代码篇,主要讲使用和实现。

更多理论知识,请阅读理论篇 【方案篇】事件监听函数的内存泄漏,帮你搞定!

源码和demo

源码: 事件分析vem

项目内部有丰富的例子。

核心功能

我们解决问题的时机无非为 事前事中事后

我们这里主要是 事前事后

  • 事件监听函数添加前进行预警
  • 事件监听函数添加后进行统计

了解功能之前,先了解一下四同特性:

  1. 同一事件监听函数从属对象
    事件监听总是要注册到响应的对象上的, 比如下面代码的window, socket, emitter都是事件监听函数的从属对象、

    window.addEventListener("resize",onResize)
    
    socket.on("message", onMessage);
    
    emitter.on("message", onMessage);
    复制代码
  2. 同一事件监听函数类型
    这个比较好理解,比如window的 messageresize等,Audio的 play等等

  3. 同一事件监听函数内容
    这里注意一点,事件监听函数相同,分两种:

    • 函数引用相同
    • 函数内容相同
  4. 同一事件监听函数选项
    这个可选项,EventTarget系列有这些选项,其他系列没有。
    选项不同,添加和删除的时候结果就可能不通。

    window.addEventListener("resize",onResize)
    // 移除事件监听函数onResize失败
    window.removeEventListener("resize",onResize, true)
    复制代码

预警

事件监听函数添加前,比对四同属性的事件监听函数,如果有重复,进行报警。

统计高危监听事件函数

最核心的功能。
统计事件监听函数从属对象的所有事件信息,输出满足 四同属性 的事件监听函数。 如果有数据输出,极大概率,你内存泄漏了。

统计全部的事件监听函数

统计事件监听函数从属对象的所有事件信息, 可以用于分析业务逻辑。

一览你添加了多少事件, 是不是有些应该不存的,还存在呢?

基本使用

初始化参数

内置三个系列:

new EVM.ETargetEVM(options, et); // EventTarget系列
new EVM.EventsEVM(options, et); // events 系列
new EVM.CEventsEVM(options, et); // component-emitter系列

当然,你可以继承BaseEvm, 自定义出新的系列,因为上面的三个系列也都是继承BaseEvm而来。

最主要的初始化参数也就是 options

  • options.isSameOptions
    是一个函数。主要是用来判定事件监听函数的选项。
  • options.isInWhiteList
    是一个函数。主要用来判定是否收集。
  • options.maxContentLength
    是一个数字。你可以限定统计时,需要截取的函数内容的长度。

EventTarget系列

  • EventTarget
  • DOM节点 + windwow + document
  • XMLHttpRequest 其继承于 EventTarget
  • 原生的WebSocket 其继承于 EventTarget
  • 其他继承自EventTarget的对象

基本使用

<script src="http://127.0.0.1:8080/dist/evm.js?t=5"></script>
<script>
    const evm = new EVM.ETargetEVM({
        // 白名单,因为DOM事件的注册可能
        isInWhiteList(target, event, listener, options) {
            if (target === window && event !== "error") {
                return true;
            }
            return false;
         }
    });
    // 开始监听
    evm.watch();

    // 定期打印极有可能是重复注册的事件监听函数信息
    setInterval(async function () {
        // statistics getExtremelyItems
        const data = await evm.getExtremelyItems({ containsContent: true });
        console.log("evm:", data);
    }, 3000)
</script>
复制代码

效果截图

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

events 系列

基本使用

import { EventEmitter } from "events";

const evm = new win.EVM.EventsEVM(undefined, EventEmitter);
evm.watch();
setTimeout(async function () {
    // statistics getExtremelyItems
    const data = await evm.getExtremelyItems();
    console.log("evm:", data);
}, 5000)
复制代码

效果截图

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

component-emitter 系列

  • component-emitter
  • socket.io-client(即socket.io的客户端)

基本使用

const Emitter = require('component-emitter');
const emitter = new Emitter();

const EVM = require('../../dist/evm');

const evm = new EVM.CEventsEVM(undefined, Emitter);
evm.watch();

// 其他代码

evm.getExtremelyItems()
    .then(function (res) {
        console.log("res:", res.length);
        res.forEach(r => {
            console.log(r.type, r.constructor, r.events);
        })
    })
复制代码

效果截图

image.png

事件分析的基本思路

上篇总结的思路:

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

代码结构

代码基本结构如下:

image.png

具体注释如下:

evm
    CEvents.ts // components-emitter系列,继承自 BaseEvm
    ETarget.ts // EventTarget系列,继承自 BaseEvm
    Events.ts  // events系列,继承自 BaseEvm
BaseEvm.ts  // 核心逻辑类
custom.d.ts 
EventEmitter.ts // 简单的事件中心
EventsMap.ts // 数据存储的核心
index.ts // 入口文件
types.ts // 类型申请
util.ts // 工具类
复制代码

核心实现

EventsMap.ts

负责数据的存储和基本的统计。

数据存储结构:(双层Map)

 Map<WeakRef<Object>, Map<EventType, EventsMapItem<T>[]>>();
 
interface EventsMapItem<O = any> {
    listener: WeakRef<Function>;
    options: O
}

复制代码

内部结构的大纲如下: image.png

方法都很好理解,大家可能注意到了,有些方法后面跟着byTarget的字样,那是因为 其内部采用Map存储,但是key的类型是弱引用WeakRef

我们增加和删除事件监听的时候,传入的对象肯定是普通的target对象,需要多经过一个步骤,通过target来查到其对应的key,这就是byTarget要表达的意思。

还是罗列一些方法的作用:

  • getKeyFromTarget
    通过target对象获得键
  • keys
    获得所有弱引用的键值
  • addListener
    添加监听函数
  • removeListener
    删除监听函数
  • remove
    删除某个键的所有数据
  • removeByTarget
    通过target删除某个键的所有数据
  • removeEventsByTarget
    通过target删除某个键某个事件类型的所有数据
  • hasByTarget
    通过target查询是否有某个键
  • has
    是否有某个键
  • getEventsObj
    获得某个target的所有事件信息
  • hasListener
    某个target是否存在某个事件监听函数
  • getExtremelyItems
    获得高危的事件监听函数信息
  • get data
    获得数据

BaseEVM

内部结构的大纲如下:

image.png

核心实现就是watchcancel,继承BaseEVM并重写这两个方法,你就可以获得一个新的系列。

统计的两个核心方法就是 statisticsgetExtremelyItems

还是罗列一些方法的作用:

  • innerAddCallback
    监听事件函数的添加,并收集相关信息
  • innerRemoveCallback
    监听事件函数的添加,并清理相关信息
  • checkAndProxy
    检查并执行代理
  • restoreProperties
    恢复被代理属性
  • gc
    如果可以,执行垃圾回收
  • #getListenerContent
    统计时,获取函数内容
  • #getListenerInfo
    统计时,获得函数信息,主要是name和content。
  • statistics
    统计所有事件监听函数信息。
  • #getExtremelyListeners
    统计高危事件
  • getExtremelyItems
    基于#getExtremelyListeners汇总高危事件信息。
  • watch
    执行监听,需要被重写的方法
  • cancel
    取消监听,需要被重写的方法
  • removeByTarget
    清理某个对象的所有数据
  • removeEventsByTarget
    清理某个对象某类类型的事件监听

ETargetEVM

我们已经提到过,实际上已经实现了三个系列,我们就以ETargetEVM为例,看看怎么通过继承和重写获得对某个系列事件监听的收集和统计。

  1. 核心就是重写watch和cancel,分别对应了代理和取消相关代理
  2. checkAndProxy是核心,其封装了代理过程, 通过自定义第二个参数(函数),过滤数据。
  3. 就这么简单
const DEFAULT_OPTIONS: BaseEvmOptions = {
    isInWhiteList: boolenFalse,
    isSameOptions: isSameETOptions
}

const ADD_PROPERTIES = ["addEventListener"];
const REMOVE_PROPERTIES = ["removeEventListener"];

/**
 * EVM for EventTarget
 */
export default class ETargetEVM extends BaseEvm<TypeListenerOptions> {

    protected orgEt: any;
    protected rpList: {
        proxy: object;
        revoke: () => void;
    }[] = [];
    protected et: any;

    constructor(options: BaseEvmOptions = DEFAULT_OPTIONS, et: any = EventTarget) {
        super({
            ...DEFAULT_OPTIONS,
            ...options
        });

        if (et == null || !isObject(et.prototype)) {
            throw new Error("参数et的原型必须是一个有效的对象")
        }
        this.orgEt = { ...et };
        this.et = et;

    }

    #getListenr(listener: Function | ListenerWrapper) {
        if (typeof listener == "function") {
            return listener
        }
        return null;
    }

    #innerAddCallback: EVMBaseEventListener<void, string> = (target, event, listener, options) => {
        const fn = this.#getListenr(listener)
        if (!isFunction(fn as Function)) {
            return;
        }
        return super.innerAddCallback(target, event, fn as Function, options);
    }

    #innerRemoveCallback: EVMBaseEventListener<void, string> = (target, event, listener, options) => {
        const fn = this.#getListenr(listener)
        if (!isFunction(fn as Function)) {
            return;
        }
        return super.innerRemoveCallback(target, event, fn as Function, options);
    }


    watch() {
        super.watch();
        let rp;
        // addEventListener 
        rp = this.checkAndProxy(this.et.prototype, this.#innerAddCallback, ADD_PROPERTIES);
        if (rp !== null) {
            this.rpList.push(rp);
        }
        // removeEventListener
        rp = this.checkAndProxy(this.et.prototype, this.#innerRemoveCallback, REMOVE_PROPERTIES);
        if (rp !== null) {
            this.rpList.push(rp);
        }

        return () => this.cancel();
    }

    cancel() {
        super.cancel();
        this.restoreProperties(this.et.prototype, this.orgEt.prototype, ADD_PROPERTIES);
        this.restoreProperties(this.et.prototype, this.orgEt.prototype, REMOVE_PROPERTIES);
        this.rpList.forEach(rp => rp.revoke());
        this.rpList = [];
    }
}
复制代码

总结

  • 单独设计了一套存储结构EventsMap
  • 把基础的逻辑封装在BaseEVM
  • 通过继承重写某些方法,从而可以满足不同的事件监场景。

写在最后

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

文章分类
前端
文章标签