监听预算先算清
后台页面做久了,很容易把事件监听当成一种“用完就忘”的东西。比如窗口尺寸变化时重新计算浮层位置,列表滚动时补拉数据,点击空白处关闭菜单,键盘按下 Escape 退出编辑态。每一件事单看都不复杂,真正出问题往往是页面运行半小时之后:滚动开始变卡,按钮点一次发两次请求,弹窗关闭了还会响应键盘事件,甚至一条埋点在同一次操作里上报好几遍。
我遇到过一次排查,现象很朴素。运营后台有个商品编辑抽屉,打开后会监听 resize 和 keydown。测试同学连续编辑几十个商品后反馈:按一次 Escape,有时会关闭两个层级的弹窗;浏览器窗口调整时页面抖动明显。代码看起来没有明显问题,打开时绑定,关闭时解绑。但往下一查,才发现其中一个 handler 经过 debounce 包装后没有保存引用,解绑时传进去的是另一个新函数;还有一个键盘监听是在异步数据回来后才绑定,关闭抽屉时清理函数没有覆盖这条路径。
事件监听预算说的不是“少用事件”,而是先给监听关系定一个可解释的边界:谁创建、谁持有、谁取消、最多同时存在几份、页面切走时是否必须归零。预算不是为了限制交互,而是让交互长时间运行后仍然可控。
监听问题为什么总是慢半拍暴露
事件监听泄漏通常不会立刻让页面崩掉。一个 handler 占不了多少内存,绑定一次也不一定带来肉眼可见的性能损耗。它像慢性问题,用户每进入一次页面、打开一次弹窗、切换一次 tab,都可能多留下一份旧关系。等旧关系累积到一定数量,同一个事件就会唤醒一串已经过期的逻辑。
这类问题最容易被误判成接口慢或渲染慢。比如滚动卡顿,大家先看列表虚拟滚动有没有做好;按钮重复请求,大家先怀疑防抖没有加;弹窗关闭异常,大家先查状态管理。真正的根因可能是旧组件已经卸载,但它的监听还挂在 window、document 或事件总线上。
还有一个麻烦点:监听泄漏不一定表现为内存曲线上升。旧 handler 如果只闭包引用了少量字段,堆快照里未必显眼,但行为已经重复了。一次点击触发两次、一次滚动执行三轮计算,这些比内存更早出现,也更应该作为排查入口。
所以我不建议只在出问题后打开 Performance 面板抓火焰图。对后台、编辑器、看板、运营工具这类长时间使用页面,应该在开发阶段就建立监听预算:页面级监听数量、全局事件入口、组件卸载后的归零标准,都需要可观察。
add 和 remove 要共享同一个身份
事件系统识别监听函数靠引用,不靠函数内容。两段代码长得一样,不代表是同一个函数。这个规则非常基础,却是实际项目里最常见的坑。
- window.addEventListener('resize', () => {
- updateLayout()
- })
- window.removeEventListener('resize', () => {
- updateLayout()
- })
上面这段代码无法解绑,因为 add 和 remove 传入的是两个不同的箭头函数。真正可回收的写法要保存 handler。
- const handleResize = () => {
- updateLayout()
- }
- window.addEventListener('resize', handleResize)
- window.removeEventListener('resize', handleResize)
问题在真实业务里会更隐蔽。比如你并没有直接写匿名函数,而是写了:
- window.addEventListener('scroll', debounce(loadMore, 200))
- window.removeEventListener('scroll', debounce(loadMore, 200))
每次执行 debounce(loadMore, 200) 都会生成一个新的包装函数。业务函数 loadMore 是同一个,但监听系统看到的不是它,而是包装后的返回值。解决方式不是“不用 debounce”,而是把包装后的函数也当成资源保存起来。
- const handleScroll = debounce(loadMore, 200)
- window.addEventListener('scroll', handleScroll)
- window.removeEventListener('scroll', handleScroll)
- handleScroll.cancel?.()
最后一行也不要忽略。有些 debounce/throttle 工具内部有 timer。移除了事件监听,只代表未来不会再触发;已经排队的延迟执行仍可能发生。弹窗关闭后延迟回调更新旧状态,就是这种路径来的。
组件生命周期不等于监听生命周期
很多前端框架都提供了组件卸载钩子,React 有 useEffect 的 cleanup,Vue 有 onUnmounted,但“有钩子”不代表监听生命周期就天然正确。核心问题是:监听是否真的只在组件存活期间有效。
React 里常见写法是:
- useEffect(() => {
- const onKeydown = (event) => {
- if (event.key === 'Escape') closePanel()
- }
- window.addEventListener('keydown', onKeydown)
- return () => window.removeEventListener('keydown', onKeydown)
- }, [])
这段代码在 closePanel 不依赖变化状态时问题不大。如果 handler 读取 props、表单草稿、权限状态,空依赖数组可能让它一直拿旧值。有人会把依赖加进去,但依赖变化会重新绑定监听;如果 cleanup 没写好,就会出现多份监听。更稳的做法通常是让监听关系稳定,把最新业务状态放进 ref,或者把事件入口提升到更明确的层级。
Vue 组合式 API 里也类似。一个 composable 如果内部绑定全局事件,最好返回停止函数,或者明确只在组件生命周期里使用。不要让调用方以为只是拿了一个数据,却顺手创建了全局订阅。
预算在这里的价值是把规则写清:页面级监听可以存在几份,组件级监听必须随组件消失,弹窗级监听必须随弹窗关闭。只要规则不清,后面就会靠“应该会卸载吧”来赌。
全局事件应该有统一入口
项目里最难管的不是某一个 addEventListener,而是到处都有 window.addEventListener、document.addEventListener、eventBus.on。当全局事件分散在几十个组件里,谁也不知道当前页面到底挂了多少监听。
一个简单但有效的做法,是封装统一的监听工具,让每次订阅都返回取消函数。
- export function listen(target, type, handler, options) {
- target.addEventListener(type, handler, options)
- return () => target.removeEventListener(type, handler, options)
- }
业务代码使用时会自然形成一条资源链。
- const stopResize = listen(window, 'resize', handleResize)
- const stopKeydown = listen(window, 'keydown', handleKeydown)
- return () => {
- stopResize()
- stopKeydown()
- }
这不是为了少写几行代码,而是为了让“订阅以后必须有取消函数”变成默认形态。事件总线也应该遵守这个规则。bus.on 如果不返回 off,业务就很容易忘记取消;如果所有 on 都返回停止函数,审查代码时会清楚很多。
统一入口还能做开发期统计。比如在非生产环境记录每类事件的绑定数量,页面切换后检查是否回落。这个统计不需要很复杂,只要能发现“进入同一个页面十次后监听数量从 1 变成 10”,就足够提前拦住问题。
监听预算要和交互复杂度匹配
不是所有监听都要追求最少。有些页面交互复杂,监听多一点很正常。编辑器、拖拽看板、图表大屏、低代码画布,都可能需要鼠标、键盘、窗口、滚动、选择区域等多类事件。预算的意思不是给所有页面规定同一个数字,而是让数量和生命周期有理由。
比如一个普通详情页,如果同时存在 15 个全局监听,就值得追问;一个画布编辑器有 15 个监听,不一定异常,但它需要有统一的输入系统和销毁流程。不同场景的标准不同,关键是能解释。
我通常会按三个层级看预算。应用级监听,例如登录态刷新、网络状态、主题变化,应该非常少,并由应用壳管理。页面级监听,例如看板 resize、页面滚动,随路由切换清理。组件级监听,例如弹窗键盘事件、浮层点击外部关闭,随组件或弹窗关闭清理。层级越低,越不能长期留在全局对象上。
如果一个组件确实需要长期监听全局事件,也要说明原因。比如通知中心在整个后台都要响应快捷键,那它应该被放到应用层,而不是藏在某个页面组件里。位置放错了,后面别人维护时就很难判断它为什么还活着。
排查时先看行为重复,再看内存
怀疑监听泄漏时,我会先做一个低成本复现:重复进入页面、打开关闭弹窗、切换 tab,然后观察同一次操作是否触发多次。比如点击保存是否发多次请求,按 Escape 是否关闭多个层级,滚动是否多次执行加载逻辑。
第二步再看监听数量。Chrome DevTools 的 Event Listeners 可以看到一部分绑定情况,事件总线需要自己加统计。对框架组件来说,也可以在开发环境临时包装统一监听工具,记录 add/remove 的次数和调用栈。
第三步看闭包引用。旧 handler 可能引用表单草稿、列表数据、编辑器实例、图表对象。它们不一定马上让内存爆,但会让旧状态继续参与业务。比如旧弹窗的校验状态还在,新的弹窗打开后按键事件同时命中新旧状态,就会出现非常怪的交互。
第四步看清理路径是否覆盖异常流程。用户点击关闭会清理,不代表路由切走会清理;接口失败关闭弹窗会清理,不代表权限变化隐藏组件会清理;组件卸载会清理,不代表 keep-alive 缓存时会清理。监听预算要覆盖真实路径,不只覆盖最顺的那条路径。
上线前给监听做一次小验收
这类问题最适合在上线前用小检查挡住。检查项可以很朴素:
add 和 remove 是否使用同一个 handler 引用。
debounce/throttle 包装函数是否被保存。
组件关闭、卸载、路由切换是否都能清理。
异步回调、timer、AbortController 是否和监听一起处理。
全局事件是否有统一入口。
重复进入页面后监听数量是否稳定。
如果团队有端到端测试,可以加一个“重复进出页面”的用例。它不需要验证复杂业务,只要连续进入页面 5 次,再触发一次核心交互,确认请求次数和事件响应次数仍然是 1。这个测试看起来笨,但对后台系统很有效。
最后给一个判断标准:写下任何监听之前,先问“谁负责取消它”。如果这个问题答不上来,这个监听就还没有进入预算。事件监听不是不能用,而是不能没有边界地用。
