写前端的人,多多少少都遇到过这种诡异情况:页面刚打开时很流畅,操作十几分钟之后开始卡顿,滚动像在拖泥带水,切个Tab要等两秒。打开任务管理器一看,浏览器内存占用已经悄悄爬到了几百MB,而且还在稳定增长。这种“页面越用越慢、内存只涨不跌”的现象,十有八九和事件监听器泄漏有关。
你可能会觉得奇怪,不就是addEventListener加了个监听吗,组件销毁了,监听器不也跟着没了吗?真有这么简单,就不会有“内存泄漏”这个词了。事件监听器之所以会成为泄漏重灾区,是因为它牵扯到JS的引用链、DOM节点的生命周期、闭包的特性,还有框架里useEffect或生命周期钩子的执行时机。任何一个环节没做对,监听器就会像狗皮膏药一样粘在内存里,把本该释放的变量、DOM节点、组件状态全都拽住不放。
这篇内容我按“原理 -> 实操 -> 排查 -> 案例 -> 工程化治理”的顺序整理,覆盖原生JavaScript、React、Vue里事件监听的正确销毁方式,以及用Chrome DevTools定位泄漏的具体手段。不管你是刚入行的新人,还是已经写了几年业务代码的老手,只要能照着把项目里的监听器清理逻辑过一遍,内存曲线基本就能稳定下来。
1. 为什么一个addEventListener就能把页面拖垮?
1.1 监听器不是“挂在元素上”,而是一条顽固的引用链
很多人对事件监听器有个错误认知:认为监听器是DOM元素自带的功能,元素销毁了,监听器自然也销毁了。真实情况是,addEventListener做的事情是把你的回调函数和元素绑定起来,但这段关系的存续取决于回调函数是否还被其他位置引用着。
举个例子:你在页面上创建了一个div,然后给它绑定了click监听器。后来又创建了一个弹窗组件,弹窗里监听了window.resize。当你关闭弹窗并把弹窗的DOM移除时,如果resize的监听器没有被移除,那么这个监听器依然存活在window对象上。问题在于,这个resize回调函数很可能通过闭包引用了弹窗里的DOM、状态数据,那这整条链——window -> 监听器 -> 闭包变量 -> 弹窗DOM——就全部无法被垃圾回收。结果就是弹窗关闭之后,那一堆早已看不到的内存还在那杵着,占着地方。
这里最反直觉的点是:DOM节点被移除了,并不代表它被回收了。只要有JS对象还引用着它,它就一直在内存里躺着。事件监听器就是最常见的“隐形引用者”。
1.2 闭包把一堆变量死死拽住,监听器成了“非分页缓冲池”
顺带说一个这几天技术群里聊到的热词——win11分页缓冲池和非分页缓冲池内存泄漏。这个名词本身是Windows系统内核层面的问题,非分页缓冲池表示内存中常驻物理内存、不可换页到磁盘的那部分,一旦泄漏,系统内存会持续紧张。事件监听器造成的前端内存泄漏,本质上也有点“非分页”的意思:你清理不掉的监听器,会把一堆对象固定在内存里,它们永远不会被GC扫描释放,除非整个页面销毁。
这种“常驻不释放”的特性,和闭包的机制强相关。比如:
function init() { const bigData = new Array(100000).fill('x'); window.addEventListener('scroll', function() { console.log(bigData.length); }); } init();这个scroll回调虽然没有直接使用bigData,但它定义在init作用域内,闭包天然引用着bigData。即使init早就执行完毕,这个监听器在window上存活着,就意味着bigData也无法释放。100万个字符串可能不算夸张,但你在真实项目里,一个地图组件、一个表格组件、一个图表实例,动辄几十上百MB的数据,全被一个没清理的scroll监听器攥在手心,就问你怕不怕。
1.3 每次render都重新绑定,泄漏就翻倍增长
更麻烦的情况是框架场景。React组件每次render,函数组件体都会重新执行,如果useEffect的依赖数组写得不对,或者压根没写cleanup,那就相当于每次render都往window上挂一个新的监听器。旧的没去掉,新的又来了,内存呈翻倍趋势增长。页面开得越久、操作越频繁,内存涨得越快,最终直接白屏或者崩溃。
所以核心结论很简单:监听器要销毁,不是“建议做”,而是“必须做”。销毁的本质,就是切断引用链,让GC能正常回收该回收的一切。
2. 监听器销毁实操:从原生JS到框架最佳实践
2.1 原生JavaScript:removeEventListener的三个致命细节
原生环境里,移除监听器最基础的方式是:
function handleClick() { console.log('clicked'); } const button = document.getElementById('btn'); button.addEventListener('click', handleClick); // 合适时机移除 button.removeEventListener('click', handleClick);看起来很简单?但里面有三个细节,踩坑率极高。
第一个细节:回调函数必须是同一个引用。addEventListener和removeEventListener传的参数需要是同一个函数对象。如果你添加监听器时用的是匿名函数,后面removeEventListener时又写一个一模一样的匿名函数,那是无效的——它们的内存地址不同。解决方式是先把函数抽出来命名。如果你需要传参数,可以用bind或者包装一层,但一定要把这个包装后的函数存下来,不能每次现造。
第二个细节:第三个参数必须一致。addEventListener的第三个参数如果传的是true(捕获阶段),removeEventListener也必须传true,否则移除不了。如果用options对象,比如{ capture: true },那移除时也同样要传{ capture: true }。很多人只传了函数名,第三个参数漏了,监听器就一直残留在元素上。还有一个容易忽略的:{ passive: true }这个选项只影响行为,不影响绑定/解绑的匹配,但capture会影响。
第三个细节:on属性的事件绑定要用on方式解绑。有些人习惯用element.onclick = handler这种方式,那清理时就要用element.onclick = null,而不是removeEventListener。两者是互不相干的体系,混用会导致监听器清不掉。
这里分享一个我在生产环境里用得很顺手的封装思路,把“绑定”和“解绑”职责收敛到一个函数里:
function addListener(target, type, handler, options) { target.addEventListener(type, handler, options); return () => target.removeEventListener(type, handler, options); } // 使用 const removeResizeListener = addListener(window, 'resize', onResize); // 需要销毁时 removeResizeListener();返回一个解绑函数的好处是,调用方不需要记住target、type、handler、options这些细节,只要在生命周期结束时执行一下就可以了——对接React/Vue的销毁钩子非常自然。
2.2 React:useEffect的cleanup才是亲儿子
React函数组件里最标准的写法,应该长这样:
import { useEffect } from 'react'; function useScrollPosition() { useEffect(() => { const handleScroll = () => { setScrollY(window.scrollY); }; window.addEventListener('scroll', handleScroll); return () => { window.removeEventListener('scroll', handleScroll); }; }, []); }useEffect返回一个清理函数,这个返回函数会在组件卸载时执行。只要你规范地写cleanup,监听器就会在组件销毁时被移除。三个高频踩坑点提醒一下:
坑一:handler不是同一个引用。如果你把handler定义在useEffect外面,但useCallback没用对或者干脆没记,那每次render都会生成一个新函数。addEventListener用第一版的handler,removeEventListener用第二版的handler,等于没移除。解决方案有两个:把handler定义在useEffect里面(这个最简单,依赖了状态也没关系);或者配合useCallback,把handler和依赖数组都整明白再放上面去。
坑二:依赖数组不能乱写。如果你依赖数组里放了一些经常变化的值,比如某些props、某些上下文状态,那这个useEffect就会频繁重跑。每重跑一次,就会先执行cleanup,再重新绑定,这本身没问题——前提是你cleanup写对了。如果你忘了写return清理,那每次依赖变化、每次重跑,都会额外挂一个新的监听器,你又只挂不摘,就只能等系统自然崩溃了。
坑三:React StrictMode的double effect。React 18的StrictMode在开发模式下,会刻意将effect执行两次:组件挂载 -> 效果运行 -> 清理 -> 效果再运行。这是为了帮你检查cleanup逻辑是否健全。如果你在cleanup里没有正确地移除监听器,开发环境下打开控制台就能看到监听器数量异常翻倍。这其实是好事,相当于开发阶段就给你做了趟体检。
2.3 Vue:onBeforeUnmount里的事情别等路由跳了才想起来
Vue的事件监听器场景,主要集中在mounted里绑定、beforeUnmount里解绑:
<script setup> import { onMounted, onBeforeUnmount } from 'vue'; function handleScroll() { // ... } onMounted(() => { window.addEventListener('scroll', handleScroll); }); onBeforeUnmount(() => { window.removeEventListener('scroll', handleScroll); }); </script>如果你用的是Options API,就在mounted和beforeDestroy(Vue2)或beforeUnmount(Vue3)里做对应处理。很多写Vue的人容易漏掉一件事:组件里监听了window/document上的事件,但以为组件unmount了就会自动清掉,实际上根本不会。Vue只会帮你解绑模板指令里自动绑定的事件,比如@click这种,因为它们走的是Vue自己的事件系统。你手动写到window上的、document上的,Vue看不见,只能自己负责。
2.4 有一定工程复杂度时,直接上AbortController
如果你觉得每次都要手动记录handler引用太麻烦,有个更彻底的方法——利用AbortController,现代浏览器全支持了:
const controller = new AbortController(); window.addEventListener('resize', handleResize, { signal: controller.signal }); document.addEventListener('click', handleClick, { signal: controller.signal }); button.addEventListener('mouseover', handleMouseover, { signal: controller.signal }); // 统一销毁 controller.abort();一次abort,所有传了同一个signal的监听器全部移除。这对多监听器场景特别好用,你不用挨个记handler名字、挨个removeEventListener。在React里配合useEffect也很丝滑:
useEffect(() => { const controller = new AbortController(); const signal = controller.signal; window.addEventListener('scroll', handleScroll, { signal }); window.addEventListener('resize', handleResize, { signal }); return () => controller.abort(); }, []);一个注意点:signal是一次性的,abort之后这个controller就不能再复用了,需要重新new一个。所以不要把这个controller提到组件外面做全局复用。另外,因为AbortController在监听器解绑上确实方便,我的项目里新写的代码基本都用这种方式,老代码也逐步迁移过去了,实测下来是真省心。
3. 用DevTools揪出内存泄漏元凶
3.1 从“游离DOM节点”入手
最直接的方式是Chrome DevTools的Memory面板(旧版本叫Profiles),做一次Heap Snapshot(堆快照)。打开DevTools -> Memory -> Heap snapshot -> 点击Take snapshot。拍一张快照,然后在页面上执行“打开弹窗 -> 关闭弹窗”这样的操作,再拍第二张,重复几次后拍第三张。
在快照里搜索关键字detached,就会看到Detached DOM节点。Detached DOM节点就是已经从页面移除,但由于JS引用还被悬空挂着的DOM。如果这些节点数量在每次开关弹窗后持续增加,就说明你的组件实例确实泄漏了。展开这些节点,能看到它们被哪些JS对象引用着,一路点下去,通常就能找到那个“幽灵监听器”。
不过要提醒一句:快照里看到几个Detached节点不代表一定是泄漏,有一些短暂的、正在被GC处理的节点也会被捕捉到。判断标准是:重复同一操作后,数量是否持续增长且不回落。多拍几次对比,涨了就说明有问题。
3.2 用Performance看内存曲线是否“不回头”
Memory面板适合看静态对象,但动态活动情况,建议用Performance面板。开启录制,在页面上做一些常规交互,比如点开几个弹窗、翻几页表格、切几次Tab,操作30秒到一分钟,然后停止录制。在录制的Summary面板里勾选Memory,查看JS Heap那条折线图。
正常的曲线应该是锯齿形的——升高是分配对象,下降是GC回收,起伏有规律。出问题的曲线长什么样?一直往上爬,爬到一个高位后几乎不降,或者下降的幅度越来越小,整体趋势是一条右高左低的阶梯状增长线——这种情况基本可以认定内存泄漏了。
3.3 控制台的getEventListeners,一秒现原形
还有一个启动快、贼直观的方法:在DevTools的Console面板里执行getEventListeners(window)或者getEventListeners(document.getElementById('某个元素')),它会直接把这个元素上绑定的所有事件监听器全部列出来。
这个方法特别适合做代码审查。你可以组件挂载前后分别看一下监听器数量:挂载前有几个、挂载后有几个、卸载后再看有没有回到挂载前的数量。如果卸载后数量没降,那你已经知道问题就在这个组件上。注意getEventListeners是DevTools提供的调试API,只能写在控制台里跑,不能写进项目代码,否则会直接报错。
3.4 一个简单但有效的“5次开关”检测套路
我在实际排查项目泄漏时,经常用一个有点“土”但非常管用的套路,分享给你:
- 第一步:页面加载完成,控制台执行
window.__listenerCount = getEventListeners(window).length之类的计数逻辑,先记下基础数量。 - 第二步:反复打开某个弹窗/组件再关闭,重复至少5次。
- 第三步:等约10秒,让GC该回收的回收,再数一次监听器数量。
- 第四步:对比两个值。如果数量变多了,恭喜你,你的组件没清理监听器。
这个方法的准确性不算百分之百,因为监听器数量受很多因素影响,但作为快速定位的手段,效率非常高。比架一堆监控工具直观得多。
4. 高频翻车现场:那些让人头皮发麻的真实场景
4.1 表格组件里套弹窗,每开一次泄漏一截
真实业务中最常见的翻车现场,就是表格和弹窗的组合。表格里有一个“查看详情”按钮,点击后打开一个弹窗组件,弹窗组件里在mounted时监听了window的scroll事件。你连续打开、关闭10次弹窗,内存就悄悄挂上了10个scroll监听器。而且这些监听器里的闭包,往往还引用着每次打开弹窗时的表单数据、DOM节点,导致GC想收回都收不动。
我在以前的项目里排查过一个案例:一个数据管理后台,用户操作半个小时之后页面就卡得不行。用Heap Snapshot一看,Detached节点几百个,全部指向同一类弹窗组件。破案之后发现,弹窗的Vue组件里写了这样一段代码:
mounted() { window.addEventListener('scroll', this.handleScroll); }然后只在beforeDestroy里解绑了。你觉得这没问题对吧?问题是这个弹窗组件不是用v-if直接销毁的,而是经手了一层自己封装的高阶组件,那层高阶组件把弹窗的渲染放在了另一个路由下,弹窗组件销毁的钩子压根没执行。监听器自然也就没被移除,一个不落全留在window上。
处理这类问题,单靠写规范代码还不够,你得确认组件销毁钩子真的被执行了。在cleanup逻辑里加一行console.log('listener removed'),然后在控制台实测打开关闭几次看看有没有输出,是成本最低的做法。
4.2 scroll监听 + 防抖没处理好,变成了“每次滚动叠一层”
滚动监听是最容易泄漏的事件类型,因为页面全局都涉及滚动,而且滚动频率极高。有些同学给window绑定scroll监听器时还做了防抖,比如:
const onScroll = debounce(handleScroll, 300); window.addEventListener('scroll', onScroll);到了销毁的时候,他们写的是:
window.removeEventListener('scroll', handleScroll);注意,这里removeEventListener传的是handleScroll,而不是防抖包装后的onScroll——当然移除不掉。这个case的本质仍然是“回调函数引用不一致”,但因为多了debounce这层包装,更容易被忽略。小心使用防抖/节流库时,你绑定的是包装后的函数,解绑也必须用它,原函数已经不算数了。
4.3 React StrictMode下监听器数量翻倍,差点误判成队友的锅
React 18之后默认开StrictMode,这在开发模式会对effect跑两次“挂载 -> 清理 -> 挂载”。如果你的useEffect里没写cleanup,或者cleanup里移除监听器时handler引用对不上,就会出现一个很有意思的现象:打开页面,控制台的日志正常,但监听器数量已经是预期的两倍,每次热更新还可能继续翻倍。
有一次排查一个同事提交的模块,我看他的代码:
useEffect(() => { const handler = () => console.log('scrolling...'); window.addEventListener('scroll', handler); }, []);我让他补上清理逻辑时,他一开始还不理解,觉得“组件又没卸载,干嘛要清理”。但StrictMode会强制模拟卸载再挂载,所以这会在开发环境立刻暴露问题。顺手也要检查你的handler引用是否稳定,如果handler在组件内被重新定义,但useCallback依赖数组没写对,那照样会有残留。
4.4 全局事件总线:绑定的地方写了,销毁的地方查无此人
一些中后台项目会用EventEmitter或者mitt这类库自己维护一个全局事件总线,用于非父子组件通信。在组件里通过eventBus.on('xxx', handler)绑定事件,但组件销毁时忘了eventBus.off('xxx', handler),这就和事件监听器泄漏一个性质。而且这比原生addEventListener更隐蔽,因为事件总线对象通常在window上挂着不销毁,那上面绑的事件只能有去有回,没有别的出路。
用mitt时注意它的off方法格式,比如:
import mitt from 'mitt'; const emitter = mitt(); // 绑定 emitter.on('update', handleUpdate); // 解绑 emitter.off('update', handleUpdate);mitt 3.x的off还支持off('*')这种清空所有事件的写法,但项目里不建议这么搞,容易误伤别的模块,还是老老实实按事件名+handler精确解绑。
4.5 Observer们也能泄漏,别只盯着EventListener
严格来说,ResizeObserver、MutationObserver、IntersectionObserver也会造成类似内存泄漏的问题,因为它们本质上也是“长期的监听关系”。很多人在代码里new了个ResizeObserver,在组件销毁时压根没调用resizeObserver.disconnect(),元素虽然销毁了,但observer依旧持有着对元素或回调的引用。
把这些归到一起,建议统一管理:useEffect里创建的所有Observer、所有定时器、所有事件监听器,全部在cleanup里处理一遍。我习惯写一个自定义hook,把所有需要清理的东西打包进去,而不是在useEffect和其他生命周期钩子里东一个西一个地散落。
5. 工程化治理:把“销毁监听器”变成项目默认习惯
5.1 先立一条“不要全局裸挂监听器”的代码规范
最简单却最有效的治理方式,是在项目里明确一个约定:不要在组件外部、生命周期外部裸写addEventListener。窗口级、文档级的监听器,必须放在生命周期钩子里配好销毁逻辑。如果多个组件都要监听window.scroll,可以考虑把监听器提到一个共享的hook里,让这个hook负责统一注册和统一销毁。
React的实践可以这样封装:
function useWindowEvent(type, handler, options) { useEffect(() => { window.addEventListener(type, handler, options); return () => window.removeEventListener(type, handler, options); }, [type, handler, options]); }这样每个组件只需要调useWindowEvent('scroll', handleScroll),销毁逻辑已经被封装在hook内部,就不会出现“只绑不卸”的情况。Vue里也可以用composable函数做类似事情,把onMounted/onBeforeUnmount的绑定清理逻辑收敛进一个函数里。
5.2 事件委托能一口气解决一整片区域的监听器
比逐个绑定更省心的方案,是事件委托。把监听器绑在父容器上,通过事件冒泡判断实际响应者。比如一个列表里有100个子项,你不需要每个子项都监听click,只需在列表容器上监听一次click,根据event.target判断要处理的元素。这个监听器跟容器同生命周期,容器销毁时它随之消失,既不会有“子项销毁但监听器残留”的隐患,又减少了监听器数量。
代码示意:
// 不用这样:每个item都绑监听 items.forEach(item => item.addEventListener('click', handleItemClick)); // 用这样:容器统一代理 listContainer.addEventListener('click', (event) => { const item = event.target.closest('.item'); if (item) { handleItemClick(item, event); } });事件委托的注意点在事件处理的准确性,比如防止点到了子元素内部、防止覆盖其他事件,但这些在实际业务里都可以复健。对于表格、列表、卡片这类高频且结构和数量频繁变化的位置,事件委托基本是消除监听器泄漏的治本手段。
5.3 某些依赖第三方SDK的实例,销毁实例时要顺带销毁监听
项目中可能涉及地图SDK(高德、百度)、富文本编辑器(如Quill、WangEditor)、图表库(如ECharts)等等。这类第三方库往往在内部会往window或document上挂监听器,用于处理鼠标移动、键盘事件、窗口尺寸变化等。当你销毁组件时,如果只是把DOM从页面移除,而不调用SDK提供的destroy/dispose方法,它内部注册的监听器很可能一直挂在全局。
最气人的是这种泄漏你从自己写的代码里根本看不出来,SDK封装在node_modules里,成了黑盒。建议在业务代码里强制约定:凡是创建了第三方实例的组件,销毁钩子必须调用该实例的销毁方法,而不是仅仅移除DOM节点。比如ECharts的chart.dispose()、Quill的quill.destroy()、地图实例的map.destroy()。不确定某个库有没有这么个方法时,去翻它文档里的“销毁实例”条目,总比在DevTools里一个个排查来得快。
5.4 给项目加一道“内存体检”环节
最后,我建议把内存检查写进发版前的自测列表。不需要多复杂,至少每轮迭代里抽出5分钟,用前面说的Performance录音,跑一遍核心页面交互,看看JS Heap曲线是不是只涨不降。如果发现曲线不对劲,第一时间查Detached节点,顺着引用链找到泄漏源,再补上对应的清理逻辑。
我见过不少项目,平时功能测得好好的,一上线跑小白鼠用户,页面越用越卡,用户骂声不断。内存泄漏这个问题,平时注意不到,一旦爆发就是事故级别。与其到时候加班排查,不如把“监听器销毁”这个简单的习惯写进团队约定里,写进代码模板里,写进code review的必查项里。代码层面能在生命周期钩子里多写两行清理逻辑,后面就少好几个通宵。
我在自己项目里把这件事沉淀成了一套固定的模板:每写一个需要全局监听的组件,第一行想的是“怎么绑定”,第二行想的是“什么时候解绑”。这个习惯改过来之后,内存泄漏相关的线上问题,我这边已经很久没遇到过了。