跨页面未注销 addEventListener 是 SPA 最隐蔽的内存泄漏源,因监听器挂全局对象且未解绑,导致闭包引用无法回收;须用同一函数引用配对增删,并在组件卸载钩子中主动清理。

跨页面未注销 addEventListener 是企业级单页应用(SPA)中最隐蔽、危害最大的内存泄漏源头之一。它不报错、不崩溃,但会让用户越用越卡——页面切换 10 次,监听器就堆积 10 份;50 次后,每次点击都触发 50 个已失效的回调,还死死拽着销毁组件的全部数据。
只在当前页面注册,却忘了在离开时解绑
典型错误是把监听器挂到 window 或 document 这类全局对象上,又依赖路由跳转或组件卸载来“自然结束”。但 JS 不会自动清理——只要监听函数还被全局对象引用着,它闭包里持有的组件实例、props、state、甚至大数组或 DOM 节点,全都不会被垃圾回收。
- 比如在 React 的
useEffect中只加了addEventListener,却漏写返回的清理函数 - Vue 项目中在
mounted绑定document.addEventListener('click', handler),但在beforeUnmount或unmounted里完全没调removeEventListener - Angular 中通过
fromEvent(window, 'resize')创建 Observable,但没在ngOnDestroy中unsubscribe()
必须保证“添加”和“移除”用的是同一个函数引用
这是最容易踩的坑:传匿名函数进去,再传另一个匿名函数去删,等于白删。引擎根本找不到对应监听器,removeEventListener 直接静默失败。
- 正确做法:把处理函数声明为具名常量或
useCallback缓存的稳定引用 - React 示例:
const handleResize = useCallback(() => { /* ... */ }, []),然后在useEffect中统一用它增删 - Vue 示例:方法定义在
methods里,或使用箭头函数赋值给setup中的const变量,避免每次渲染生成新函数
用生命周期钩子做对称式清理,不依赖“页面是否还在”
别假设用户会刷新页面或关闭标签页。企业级应用往往要运行数小时,路由频繁切换,必须把清理逻辑绑定到组件/模块自身的销毁时机,而不是靠用户行为兜底。
- React:所有
useEffect中注册的全局监听,清理函数必须显式调用removeEventListener - Vue 3:
onBeforeUnmount是最安全的清理入口,确保 DOM 移除前就把监听器清掉 - 纯 JS 场景(如微前端子应用):在
unmount生命周期里统一遍历并清除所有曾注册过的监听器,可配合 WeakMap 记录注册清单
主动监控与兜底机制,防漏防滚雪球
靠人写代码 100% 不出错不现实。大型项目建议加两层防护:
- 开发阶段启用 ESLint 插件(如
eslint-plugin-react-perf或自定义规则),检测addEventListener出现但无对应removeEventListener的代码块 - 上线后加轻量级运行时检查:比如封装一个
safeAddEventListener,内部用WeakMap自动记录注册关系,在子应用卸载或路由跳转前自动批量清理 - 定期用 Chrome DevTools 的 Memory 面板拍堆快照,筛选
EventListener对象数量是否随路由次数线性增长——这是最直接的泄漏证据

















