事件监听先管回收
前端页面里最隐蔽的一类性能问题,不是某个接口突然慢了,也不是某段计算写得太重,而是事件监听没有被回收。它平时不吵不闹,页面刚打开时一切正常;等用户在几个页面之间来回切换,弹窗开关几次,列表滚动一阵,问题才慢慢露出来:同一个按钮点一次触发两次请求,滚动事件越跑越频繁,内存曲线缓慢上升,最后页面开始卡。
我遇到过一次很典型的排查。运营后台有一个浮层组件,打开时会监听 window.resize 调整位置,关闭时理论上要移除监听。线上反馈是“用久了变慢”,本地复现很困难。后来在 DevTools 里看 Event Listeners,才发现浮层每打开一次就新增一个 resize 监听,关闭时没有删掉。用户打开十几次后,窗口尺寸变化一次,旧监听全被唤醒,里面还闭包引用着已经卸载的组件状态。
事件监听不是写上 addEventListener 就结束了。它更像一份订阅关系:谁订阅、订阅了什么、什么时候取消、取消时用的是不是同一个函数引用,都要说清楚。事件监听先管回收,说的就是先把生命周期边界写清,再谈交互逻辑。
泄漏常常不是内存立刻爆掉
很多人提到泄漏,会想到内存迅速飙升。但事件监听泄漏更常见的表现是“行为重复”和“性能慢性变差”。因为监听函数可能只引用少量对象,单个泄漏不明显;真正麻烦的是它会随着用户操作次数累积。
比如列表页每次进入都绑定一次滚动监听,但离开页面没有解绑。用户切十次页面后,滚动一次会触发十个 handler。每个 handler 又会读 DOM、算位置、发埋点或请求下一页。你看到的是滚动卡顿,却不一定第一时间想到是监听数量变多了。
还有一种情况是重复请求。按钮组件在 mounted 时监听某个全局事件,收到事件后发请求。组件卸载后监听没有移除,新组件又绑定一次。结果同一个全局事件会被多个旧组件响应,服务端看到重复调用,前端却觉得“我明明只触发了一次”。
所以排查这类问题时,不要只看内存。要同时看监听数量、请求次数、函数调用次数和组件生命周期。很多泄漏先表现为行为异常,再表现为资源异常。
函数引用必须能找回来
事件解绑最容易踩的坑,是 remove 时传入的函数和 add 时不是同一个引用。
- window.addEventListener('resize', () => {
- updatePosition()
- })
- window.removeEventListener('resize', () => {
- updatePosition()
- })
这段代码看起来对称,其实无效。两个箭头函数是两个不同引用,浏览器无法知道你要移除哪个监听。正确做法是把 handler 保存下来。
- const handleResize = () => {
- updatePosition()
- }
- window.addEventListener('resize', handleResize)
- window.removeEventListener('resize', handleResize)
这个规则简单,但在真实项目里经常被间接破坏。比如在 React 组件里每次 render 都创建新函数,在 Vue watch 里临时拼一个 handler,在工具函数里包装 debounce 后没有保存包装结果。你以为传的是同一个业务函数,实际监听系统看到的是不同函数。
debounce 和 throttle 尤其要注意。下面这种写法也容易解绑失败:
- window.addEventListener('scroll', debounce(loadMore, 200))
- window.removeEventListener('scroll', debounce(loadMore, 200))
每次调用 debounce 都会返回一个新函数。要把返回值存起来,解绑时使用同一个包装函数。
组件生命周期不是全局生命周期
前端组件里的监听有两种:一种跟随组件,一种跟随应用。跟随组件的监听,组件卸载时必须清理;跟随应用的监听,通常应该放在应用级模块统一管理。把这两种混在一起,就会出现“页面走了,监听还在”的问题。
React 里常见写法是 useEffect 返回清理函数。
- useEffect(() => {
- const handleResize = () => {
- setWidth(window.innerWidth)
- }
- window.addEventListener('resize', handleResize)
- return () => {
- window.removeEventListener('resize', handleResize)
- }
- }, [])
这里有两个要点:handler 在 effect 内部创建,并且清理函数能拿到同一个引用;依赖数组要符合实际。如果 handler 依赖 props 或 state,就不能随手写空数组,否则 handler 可能拿到旧值。可以用 ref 保存最新值,也可以把订阅逻辑拆开。
Vue 里也类似。组件 mounted 绑定,beforeUnmount 或 unmounted 清理。组合式 API 可以把绑定和清理封装成 composable,但封装后更要明确谁负责调用清理。一个好的 composable 不应该只返回数据,还应该让生命周期关系清楚。
全局事件要有统一出口
当项目里到处都是 window.addEventListener、document.addEventListener、eventBus.on,排查会很困难。全局事件最好有统一出口,比如封装一个订阅工具,返回 unsubscribe。
- function listen(target, type, handler, options) {
- target.addEventListener(type, handler, options)
- return () => target.removeEventListener(type, handler, options)
- }
- const stop = listen(window, 'resize', handleResize)
- stop()
这个写法的价值不是少写几行代码,而是把“订阅以后必须有取消函数”变成习惯。业务代码拿到 stop,就会自然思考什么时候调用它。
事件总线也一样。很多旧项目里有 bus.on,却没有明确 bus.off。如果事件总线是全局单例,组件销毁后旧回调仍然存在,泄漏会更隐蔽。建议所有 on 都返回取消函数,或者在组件维度维护订阅列表,卸载时统一清理。
全局事件还要控制数量。不要每个列表项都监听 window scroll,也不要每个弹窗都监听同一个 document click。能上提就上提,能委托就委托。监听越分散,回收越难。
异步回调也会留住旧状态
事件监听泄漏常常和异步回调一起出现。监听函数里发请求,请求回来后更新状态;组件已经卸载,回调仍然执行。框架可能给出警告,也可能只是静默失败,但闭包引用已经留了一段时间。
这时不仅要移除事件监听,还要考虑取消异步任务。比如使用 AbortController 取消 fetch,或者在组件卸载时标记任务失效。事件触发、请求发出、响应返回,这是一条链路,不能只清理第一段。
如果一个 handler 里会启动定时器,也要清理定时器。比如 scroll 后 debounce 保存了 timer,组件卸载时如果只 remove listener,不取消 debounce 内部 timer,最后一次延迟执行仍可能发生。很多 debounce 工具提供 cancel 方法,应该一起调用。
排查时先数监听,再看闭包
遇到疑似泄漏时,我会先做几件事。
第一,重复进入和离开页面,观察同类监听数量是否增加。DevTools 的 Event Listeners 能看到一部分信息,也可以在开发环境临时包装 add/remove,统计绑定次数。
第二,观察同一操作是否触发多次。比如点击一次按钮,接口是否发多次;滚动一次,handler 是否运行多次。行为重复往往比内存曲线更早暴露问题。
第三,看 handler 是否闭包引用了大对象。比如列表数据、表单状态、编辑器实例、图表对象。监听没清理时,这些对象也可能被一起留住。
第四,看清理路径是否一定会走到。弹窗被路由切换关闭、组件异常卸载、条件渲染变化、keep-alive 缓存,这些路径都可能绕开你以为的关闭逻辑。
写一个小检查表,比事后排查省力
上线前可以给事件监听加一张简单检查表:有没有保存 handler 引用;有没有清理函数;清理函数是否覆盖所有卸载路径;debounce/throttle 是否保存包装函数;异步任务是否能取消;全局事件是否有统一出口;重复进入页面后监听数量是否稳定。
这些检查不复杂,但能挡住大量慢性问题。事件监听泄漏最烦人的地方是它不像语法错误那样立刻报出来,它会等用户操作足够久、页面状态足够复杂时才出现。把回收边界提前写清楚,是最划算的预防。
最后留一个判断标准:只要你写下一个监听,就应该能立刻回答“谁负责取消它”。如果这个答案说不清,后面大概率会在某个页面、某个弹窗、某个滚动列表里变成难查的问题。
