React 应用在长时间运行或频繁切换路由时,如果页面越用越卡、内存占用持续攀升,大概率是内存泄漏在作祟。组件卸载后,定时器未清除、事件监听未移除、订阅未取消,或者闭包意外持有了组件引用,都会让垃圾回收器无法回收已卸载组件的整个对象树。Chrome DevTools 的 Memory 面板是排查这类问题的利器,通过堆快照对比可以直观看到哪些构造函数对应的对象数量在持续增长。

React 中常见的内存泄漏场景
内存泄漏的本质是:不再需要的对象仍然被某些活跃的根对象引用,导致垃圾回收器无法回收。在 React 函数组件中,最常见的泄漏来源就是 useEffect 中注册的副作用没有返回清理函数。例如在组件挂载时启动了定时器,但卸载时没有清除,定时器回调会继续持有组件作用域中的变量,包括状态更新函数,导致整个组件实例无法被回收。
另一个高发场景是事件监听。如果直接在 useEffect 中调用 window.addEventListener 或 document.addEventListener,却没有在清理函数中调用对应的 removeEventListener,那么全局对象会一直持有回调引用,而回调内部又通过闭包引用了组件状态,组件卸载后仍然无法释放。订阅外部数据源(如 WebSocket、Redux store、EventEmitter)也存在同样的问题,必须提供取消订阅的逻辑。
除了副作用清理遗漏,闭包不当使用也会造成泄漏。比如在 useEffect 依赖数组中遗漏了某个函数,导致每次渲染都创建新的闭包并注册,旧的闭包又被定时器或事件引用,层层叠加。脱离文档的 DOM 节点也可能被 JavaScript 变量引用而无法回收,这在自定义指令或手动操作 DOM 的场景中比较常见。下面是一个典型的问题代码:
useEffect(() => {
const timer = setInterval(() => {
console.log('still alive');
}, 1000);
// 没有返回清理函数,组件卸载后定时器仍然运行
}, []);
上述代码中,setInterval 返回的句柄没有被保存,也无法在卸载时调用 clearInterval,即使组件卸载,回调依然每秒钟执行一次,并且闭包中的任何引用都不会被释放。如果这个组件被频繁创建和销毁(例如列表项的展开收起),内存会迅速增长。
Chrome DevTools Memory 面板核心功能解析
打开 Chrome DevTools,切换到 Memory 面板,可以看到三种主要分析方式:Heap snapshot(堆快照)、Allocation instrumentation on timeline(分配时间线)和 Allocation sampling(分配采样)。对于 React 内存泄漏排查,最常用的是堆快照对比法。堆快照会记录当前 JavaScript 堆中所有对象及其引用关系,连续拍摄多次快照后,通过对比可以找出哪些对象数量在增加、哪些对象应该被回收却仍然存在。
录制堆快照时,建议先进行一次初始快照作为基线,然后执行一系列会造成泄漏的操作(比如反复进入和退出某个页面、多次打开和关闭弹窗),再触发一次垃圾回收(可以在 DevTools 的 Console 中执行 gc(),但需要先开启实验性功能或在命令行启动 Chrome 时加上 --js-flags=--expose-gc),最后拍摄第二次快照。在快照对比视图中,选择 Comparison 模式,按 Delta 排序,就能看到新增对象最多的构造函数。如果某个自定义组件构造函数或闭包数量异常增长,基本可以锁定泄漏点。
Allocation instrumentation on timeline 适合观察内存分配的时间线,它会记录每次分配的对象并标出分配位置。当怀疑某个交互导致内存飙升时,可以开启录制,执行操作,停止后查看时间线上的分配柱状图,点击柱状图可以定位到具体的分配代码。Allocation sampling 则通过采样方式降低性能开销,适合长时间运行下的内存趋势分析。三种工具结合使用,能够覆盖从粗粒度定位到细粒度代码行的完整排查链路。
实战:从堆快照对比到定位泄漏组件
假设有一个 React 列表页面,每次点击“加载更多”都会向列表追加数据,但切换离开页面后内存并没有下降。首先在 Memory 面板选择 Heap snapshot,点击录制按钮生成第一个快照。然后在页面上重复点击加载更多 20 次,再切换路由离开列表页,等待几秒后手动触发垃圾回收,最后录制第二个快照。将第二个快照的视图切换为 Comparison,并选择与第一个快照对比。
在对比结果中,按 Delta 列降序排列,观察构造函数名称。如果出现大量 HTMLDivElement、Object 或某个自定义类名的对象,需要展开查看 Retained Size。特别关注那些 Retained Size 很大且明显不属于当前页面的对象。点击对象后,下方的 Retainers 面板会显示引用链,通常能看到某个事件监听器、定时器或闭包持有引用。结合 React DevTools 的 Components 面板,可以查看对应组件是否已经从树中卸载,如果已卸载但仍出现在引用链中,就是泄漏源头。
有时候泄漏不是直接由组件引起,而是第三方库或全局状态管理工具。此时可以在 Retainers 中逐层点击展开,找到最上层的根引用(Root)。如果根引用是 window、document 或某个全局 Store,说明有全局性引用没有解除。对于 React 组件,可以关注 FiberNode 对象,它代表了 React 的内部节点,如果大量 FiberNode 在组件卸载后仍然存活,说明 React 树中存在悬挂引用。下面的代码展示了如何用 useEffect 正确清理事件监听,避免这类问题:
useEffect(() => {
const handleScroll = () => {
console.log('scrolling');
};
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
这段代码通过返回清理函数,在组件卸载时移除事件监听,确保 handleScroll 不会继续被 window 引用。对于定时器同样如此,需要保存定时器 ID 并在清理函数中调用 clearInterval 或 clearTimeout。如果使用 setTimeout 执行异步操作,还要考虑组件卸载后异步回调是否会调用 setState,此时可以引入一个 isMounted 标志或使用 AbortController 取消请求。
修复与预防内存泄漏的最佳实践
要从根本上避免 React 内存泄漏,最有效的方式是严格遵守副作用清理原则。所有在 useEffect 中创建的定时器、事件监听、网络请求、订阅都必须返回清理函数。对于订阅外部数据源,可以使用 RxJS 的 Subscription 对象的 unsubscribe 方法,或者 Redux 的 store.subscribe 返回的取消函数。对于异步请求,推荐使用 AbortController 在组件卸载时中止请求,或者使用 axios 的取消令牌。
另外,尽量减少不必要的全局引用。如果确实需要跨组件通信,优先使用 React Context 或状态管理库,并注意在 Provider 卸载时清理全局缓存。对于循环引用,可以利用 WeakMap 和 WeakSet 存储对象元数据,它们不会阻止垃圾回收。在开发阶段,可以借助 React.StrictMode 来暴露副作用清理问题,StrictMode 会在开发模式下故意重复挂载和卸载组件,帮助开发者发现遗漏的清理逻辑。
最后,建立定期监控内存的习惯。在 CI 中集成简单的内存泄漏检测脚本,或者使用 Performance 面板观察 JavaScript 堆的动态变化,结合 Memory 面板定期分析线上关键页面的堆快照。一旦发现内存曲线持续上升且不回落,就要立即定位并修复。内存泄漏往往不是一次性爆发,而是逐渐累积,越早发现,定位和修复成本越低。
React内存泄漏Chrome DevToolsMemory面板修改时间:2026-08-26 07:33:01