前言
工作中,我们会对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 } 复制代码
今天我们的主题,就是分析事件监听,并排查因此可能导致的内存泄漏。
本文主要讨论和分析几个技术点:
- 怎么准确知道某个对象或者函数是否被回收
- 常见事件监听函数的本质
- 如何收集DOM事件监听函数
- 拦截方法常见方式
- 弱引用回收问题
- 如何裁定事件监听函数重复
效果演示
报警,高危事件统计,事件统计等功能,我们一起来看看效果吧。
预警
当你进行事件注册的时候,如果发现四同属性的事件监听,就进行报警。
四同:
- 同一事件从属对象
比如Window
,Socket
等同一个实例 - 事件类型,比如
message
,resize
,play
等等 - 事件回调函数
- 事件回调函数选项
截图来自我对实际项目的分析
, message事件重复添加,预警!!
高危统计
高危统计是对预警的拔高,他会统计 四同属性 的事件监听。是排查事件回调函数泄漏最重要方法。
DOM事件
截图来自我对实际项目的分析
, window对象上message消息的重复添加, 次数高达10
EventEmitter模块
截图来自我对实际项目的分析
,APP_ACT_COM_HIDE_ 系列事件重复添加
事件统计
按照类型,罗列所有的事件监听回调函数,包含函数名,也可以包含函数体。 这中我们分析问题也是极其有用的。
所以,
各位大哥,各位大哥,各位大哥, 务必给函数起个名字,务必给函数起个名字,务必给函数起个名字。,
这时候,名字真的很重要!!!!!!!
怎么准确知道某个对象或者函数是否被回收
一种直观有效的答案: 弱引用 WeakRef + GC(垃圾回收)
为什么要弱引用呢? 因为我们不能因为我们的分析和统计影响对象的回收?不然分析肯定也不准了。
弱引用
WeakRef 是ES2021提出来的,用于直接创建对象的弱引用, 不会妨碍原始对象被垃圾回收机制清除。
WeakRef 实例对象有一个deref()
方法,如果原始对象存在,该方法返回原始对象;如果原始对象已经被垃圾回收机制清除,该方法返回undefined
。
let target = {}; let wr = new WeakRef(target); let obj = wr.deref(); if (obj) { // target 未被垃圾回收机制清除 // ... } 复制代码
来看一个实际的例子:
左边target不会被回收,右边会被回收。
看到这里,你应该至少有两个意识:
window.gc()
是什么鬼
其是v8提供的方法,主动触发垃圾回收,接下来会提到。IIFE
这种闭包的应用,确实可以程度减少变量污染和泄漏
垃圾回收
垃圾回收是有周期的,以chrome浏览器为例,是可以主动执行垃圾回收的。 本应该被回收的对象,主动执行回收操作之后,倘若他还在,那很大可能性就导致了泄漏。
基于v8引擎的浏览器,怎么主动执行垃圾回收呢?
答案是: 修改chrome的启动参数,加上 --js-flags="--expose-gc"
即可
之后,你就可以只直接调用gc
方法进行垃圾回收
小结
有了 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
上面,没错,问题的本质都回到原型上。
所以,事件回调收集,就是在原生上做手脚,改写原型上订阅和取消订阅的方法,两个点:
- 收集事件监听信息
- 保持原有功能
进一步的本质就是方法拦截, 那我们就一起再走近方法拦截
方法拦截
几种方法拦截的方式
方法拦截,我这里收集和整理了大约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
其只是直接修改了原型方法,并在节点上存储相关信息,结果可行,不推荐这么玩。
缺点
- 入侵了每个节点,节点上保留了事件信息
- 单次只能获取一个元素的监听事
chrome 控制台 getEventListeners方法获得单个Node的事件
缺点
- 只能在控制台使用
- 单次只能获取一个元素的监听事件
chrome控制台, Elements => Event Listeners
- 只能在开发者工具界面使用
- 查找相对麻烦
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); 复制代码
对照代码分析:
target
事件挂载的对象,假如是EventEmitter,挂载的对象就是其实例type
事件类型listener
监听函数options
选项,虽然EventEmitter没有,都没有,我们就认为一样即可。
选择的是对target
和listener
进行弱引用, , 大致的数据存储结构如下。
我们对事件从属的主体和事件回调函数进行弱引用,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); 复制代码
对象被回收之后,如期的输出消息,这里, 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
我们比内容,绝大情况是没问题了, 除了:
- 你的函数代码真就一样
ESLint里面有一个规则,好像是没使用this
的方法,是不该写在class里面的。真一样,你应该思考是的代码实现了。 - 内置函数
const random = Math.random console.log("name:", random.name, ",content:", random.toString()) // name: random ,content: function random() { [native code] } 复制代码
- 被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之后,必然会多了不安定元素, 所以:
- 也采用WeakRef来引用,减少不安定心理
- 默认不开启重写bind
基本问题排查差不多了,再开启重写bind选项,仅仅分析被bind之后的事件监听函数。
怎么识别是不是被bind之后的函数,还是上面提到的
- 函数名, 其名为
bound [原函数名]
- 函数体,
{ [native code] }
小结
基本思路
WeakRef
建立和target
对象的关联,并不影响其回收- 重写
EventTarget
和EventEmitter
两个系列的订阅和取消订阅的相关方法, 收集事件注册信息 - FinalizationRegistry 监听
target
回收,并清除相关数据 - 函数比对,除了引用比对,还有内容比对
- 对于bind之后的函数,采用重写bind方法来获取原方法代码内容
两个疑虑
- 兼容性
是的,只能用在比较新的浏览器上调试。但是,问题不大! 发现并修复了,低版本大概率也修复了。
- 移动端怎么调试
可以的,不是本文重点。
上面的几个问题分析完毕之后,我们完事具备,只欠东风。
敬请期待 代码篇
写在最后
技术交流群请到 这里来。 或者添加我的微信 dirge-cloud,带带我,一起学习。