用户的浏览器标签页在运行 30 分钟后冻结。UI 开始卡顿。随后,浏览器因内存溢出(out-of-memory)错误而崩溃。

你逐行检查组件代码,却发现一切正常。这正是 React 内存泄漏最令人抓狂的地方。Bug 并不存在于你的 JSX 语法或 Hook 逻辑中,而是存在于你的组件与浏览器垃圾回收器(garbage collector)之间的缝隙里。一个游离的事件监听器或一个长生命周期的闭包,都会阻止回收器释放内存。你卸载了一个组件,但一个残留的引用却让整个组件树在堆(heap)中保持存活。仅通过阅读代码是找不到这些泄漏的。问题隐藏在表面之下,肉眼无法察觉,在单元测试中也毫无声息。

为什么内存泄漏会“近在眼前”却难以察觉

像 V8 这样的 JavaScript 引擎会自动管理内存。当从根节点到某个对象不存在引用路径时,引擎会将该对象标记为垃圾并回收其占用的空间。这个过程运行良好,直到一个隐藏的引用比你预期的存活时间更长。

在 React 中,危险通常出现在组件与 DOM 的边界处。你可能会在模态框(modal)内部向 window 附加一个 resize 监听器,或者在仪表盘小部件中订阅一个 WebSocket。当用户关闭模态框或进行页面跳转时,组件会被卸载。如果订阅依然存在,引擎会看到从全局 window 对象到你的处理函数(handler)的有效引用,并从处理函数回溯到组件的闭包。该组件、它的 props、它的 state 以及它整个 DOM 节点子树都会被“钉”在内存中。经过数百次交互后,这些被钉住的对象会不断累积。内存占用量会呈现出一种从未完全回落的锯齿状模式。

企业级仪表盘问题

这种情况在用户会在单个页面停留数小时的企业级仪表盘中最为常见。想想监控面板、分析视图或工单系统。用户打开一个详情模态框,过滤一个大型数据集,或者在单页应用(SPA)中切换标签页。每一次单独的交互看起来都很正常。然而,随着时间的推移,孤立的节点和脱离的监听器会不断累积。应用程序变慢并不是因为某次高开销的渲染,而是因为堆内存增长到足以触发频繁且高开销的垃圾回收停顿。

不要再盲目猜测你的 useEffect hook 了。了解是否存在泄漏的唯一方法是直接测量堆内存。Chrome DevTools 可以为你提供这种可见性。

使用 Chrome DevTools 猎杀内存泄漏

你需要一个可复现的操作序列和几分钟的专注时间。在 Chrome 中打开你的应用,启动 DevTools,并导航到 Memory 标签页。

记录基准(Record a baseline)。 选择 Heap snapshot 并点击 Take snapshot。这会捕获当前存在于 JavaScript 堆中的每一个对象,并显示初始内存。请在页面进入初始空闲状态后进行此操作,而不是在初始加载期间,这样你测量的就仅仅是由用户操作引起的增长。

触发操作(Trigger the action)。 执行你怀疑会导致泄漏的精确 UI 交互。打开并关闭一个模态框,切换一个复杂的图表,或者更改路由并返回。完成后,将应用恢复到原始的视觉状态。这一步至关重要。你希望 UI 看起来与基准状态时完全一致。如果 UI 看起来是空的,但堆内存却增长了,那么你就有了内存泄漏的有力证据。

强制垃圾回收(Force garbage collection)。 点击 Memory 标签页中的垃圾桶图标。这将触发一次完整的 GC 周期,并清除那些合法存活到下次回收的临时对象。剩下的就是真正的泄漏——即那些本该被回收、却被意外引用持活的对象。

拍摄第二次快照(Take a second snapshot)。 再次点击 Take snapshot。现在你拥有了两张在相同 UI 条件下拍摄的堆内存“照片”。

对比结果(Compare results)。 将视图从 Summary 切换到 Comparison。将基准设置为你的第一个快照,将对比对象设置为第二个快照。Comparison 视图会列出每个对象类别,并显示 delta(即两次捕获之间对象数量的净变化)。

按 Delta 排序(Sort by Delta)。 寻找显著增加的类别。特别关注 Detached HTMLElement 和 React fiber 节点。脱离的 HTML 元素(Detached HTML element)是指不再连接到活动文档树,但仍被某些 JavaScript 引用持有的 DOM 节点。这些就是确凿的证据。在关闭模态框或卸载组件后,它们不应该存在。

阅读保留路径 (Retaining Path)

当你在快照中选择一个脱离文档流(detached)的元素时,Chrome 会在底部面板中显示保留路径(retaining path)。该路径是从根节点到所选对象的引用链。请仔细追踪它。你经常会发现一个事件监听器、一个 IntersectionObserver、一个 setInterval ID,或者一个指向组件中特定代码行的闭包。

寻找你熟悉的名称。如果你看到一个绑定到 window 的监听器,其函数名来自你的代码库,那么你就找到了锚点(anchor)。持有该监听器的对象会让你的整个组件保持存活。有时引用链会经过第三方库。在这种情况下,请检查该库是否需要显式的销毁(teardown)调用,而你忘记在清理函数(cleanup function)中调用它。

修复根本原因

一旦确定了保留路径,修复方法通常是机械性的,但需要团队保持一致的规范。

使用清理函数。 当你向 windowdocument 添加监听器时,务必在 useEffect 中返回一个清理函数。如果你的 effect 订阅了 resize 事件,请在组件卸载前移除该订阅。当 React 销毁组件时,清理函数会运行,这为你提供了一个切断外部连接的可靠钩子。

稳定引用。 使用 useCallback 包装你的处理函数(handlers)。这可以确保你传递给 removeEventListener 的函数引用与最初传递给 addEventListener 的完全一致。如果你注册了一个内联函数,例如 window.addEventListener('resize', () => { ... }),随后又尝试用另一个内联函数来移除它,那么这两个引用将无法匹配。监听器会保持挂载状态,其中的闭包会让你的组件状态持续存活。使用带有稳定依赖数组的 useCallback 可以防止这种身份不匹配(identity mismatch)。

切断全局链接。 请记住,浏览器的事件目标对象(如 windowdocument)在页面的整个生命周期内都存在。它们指向你组件的任何引用都会充当全局锚点。移除监听器可以打破该锚点,从而让 V8 引擎在下一次垃圾回收(garbage collection)周期中清理掉组件状态和 DOM 节点。

考虑一个追踪窗口宽度的模态框(modal)。如果没有清理逻辑,用户每打开一次模态框,就会挂载一个新的监听器。旧的监听器永远不会脱离,因为它们所属的组件实例已经消失了,而这些函数本身又是匿名的,导致无法被引用。通过使用包装在 useCallback 中的具名处理函数,并配合调用 removeEventListener 的清理函数,可以干净地闭合这个循环。

核心总结

React 中的内存泄漏很少会通过清晰的错误消息来提醒你。它们表现为:页面标签页开启的时间越长,占用的内存就变得越重。不要浪费时间去猜测哪个 hook 有问题。打开 Memory 标签页,强制进行垃圾回收,然后对比快照。让堆分析器(heap profiler)为你展示准确的保留路径。然后编写清理函数,稳定回调引用,并切断全局链接。你的用户不会直接察觉到修复动作,但他们会发现仪表盘在一天结束时依然运行流畅。