先记住这个答案
Effect 建立的全局监听、定时器或外部订阅具有自己的生命周期。组件离开页面后若没有解除,外部源可能继续持有回调,路由回来再建立一份后,同一事件就可能被处理多次,并继续读取旧闭包中的业务参数。cleanup 应释放本轮建立的资源,使用对应取消函数、监听器引用或句柄;验证时要覆盖反复进入、参数切换和卸载后触发事件。
- 卸载 React 组件不等于外部订阅自动注销
- 移除监听需要匹配事件类型、函数引用与 capture
- 清理应释放本轮资源,并检查卸载后的实际行为
重复处理不只是性能问题
例如一个文档页面监听 Escape 关闭自己的帮助提示。第一次进入建立一个监听,离开时没有移除,第二次进入又建立另一个。后续按键可能同时运行新旧回调;若回调还记录文档 ID 或触发业务命令,就可能对已经离开的页面执行错误操作。
外部事件源持有函数,函数又可能通过闭包引用数据或对象,从而延长它们的存活时间。具体是否形成持续内存增长,要看引用链与资源生命周期,不能把每个迟到回调都直接称作永久泄漏。可见的重复请求、重复提示和错误参数,本身已经足以说明需要修复。
清理要拿到同一轮注册使用的函数
下面示例在 Effect 内定义 handleKey,并在 cleanup 中使用同一个函数引用移除监听。事件类型与 capture 参数也保持一致。若注册和移除时分别写两个内容相同的箭头函数,它们仍是不同对象,旧监听不会因为函数体相似而被找到。
这个组件将 onDismiss 作为依赖,调用方换了处理函数时会先清理旧监听再建立新监听。若调用方的函数频繁变化,可以再分析是否需要稳定引用或调整职责,但不能直接漏掉依赖。示例只处理帮助提示,不承担完整弹窗的焦点管理与无障碍行为。
import { useEffect } from 'react';
export default function HelpHint({ onDismiss }) {
useEffect(() => {
function handleKey(event) {
if (event.key === 'Escape') onDismiss();
}
window.addEventListener('keydown', handleKey, false);
return () => window.removeEventListener('keydown', handleKey, false);
}, [onDismiss]);
return <aside>按 Escape 关闭这条帮助提示。</aside>;
}在 React 客户端挂载后,Escape 会调用传入的 onDismiss;卸载后,本组件注册的处理器应不再响应。示例没有控制台输出,验收需要实际按键或派发键盘事件。
用可重复的进入离开流程验证修复
先进入页面并触发一次事件,记录业务处理次数,再离开并触发同样事件,确认已离开页面的处理器不再工作。随后重复进入多次,检查一次事件仍只产生当前这份订阅对应的处理。参数切换也要覆盖,确保旧参数的回调被替换而不是继续并存。
对于连接池或多个组件共享的服务,cleanup 通常应取消自己的订阅份额,而不是直接关闭整个全局连接。请求取消也不能保证服务器已经停止执行;迟到结果仍可能需要轮次判断。内存分析可以作为进一步证据,但首要验收是外部行为与资源所有权符合预期。
容易答错的地方
- removeEventListener 只要写相同函数内容就行
- 函数引用必须对应注册时的监听器,事件类型与 capture 也要匹配。保留本轮函数引用或使用 API 提供的取消句柄,比在清理中重新构造函数可靠。
- 用 ref 阻止第二次 Effect 建立即可解决
- 这可能掩盖开发检查或重新挂载暴露的问题,却没有解除首次建立的外部订阅。应该使建立和清理正确配对,确保真实路由往返也不会积累资源。
面试官还会怎么问?
setInterval 需要怎样清理?
保留本轮 setInterval 返回的句柄,在 cleanup 中调用 clearInterval。若计时回调依赖响应式输入,也要同时处理依赖与闭包,清理定时器并不会自动修复读旧值。
使用 AbortController 能移除事件监听吗?
支持 signal 选项的事件监听可以绑定 AbortSignal,并在清理时中止对应 controller。仍要保证 controller 属于本轮资源,不要误中止其它消费者共享的操作。
没有 React 的卸载更新警告就代表没泄漏吗?
不代表。警告行为与版本及操作有关,而外部回调可能仍然重复请求、持有引用或改变其它系统。应验证监听数量、业务行为与资源生命周期,不能只观察控制台是否安静。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。