你的应用运行十分钟后一切正常。接着滚动变得卡顿。半小时后,标签页占用达到 1GB。最终,页面因内存溢出错误而崩溃,而且你甚至拿不到任何堆栈追踪。
这不是渲染性能问题。React DevTools Profiler 的表现会看起来很平稳,因为问题不在于组件重绘的频率,而在于组件卸载后哪些东西仍然存活。JavaScript 堆中的某个游离引用钉住了整个 DOM 节点、闭包和状态树。浏览器无法回收其中的任何内容,因此内存不断攀升,直到进程崩溃。
阅读源代码并不能发现泄漏。Bug 存在于你认为“已卸载”与垃圾回收器实际“看到”的内容之间的鸿沟中。V8 只会释放那些保留路径(retaining paths)为零的对象。如果一个游离的事件监听器、一个未清除的观察器或一个长生命周期的闭包哪怕只持有一个指向 fiber 或 DOM 节点的指针,整个组件子树都会存活下来。你卸载了一个模态框,但它的脱离节点(detached nodes)仍留在内存中,因为 window 上的一个监听器仍然指向该模态框内部定义的处理函数。
要证明泄漏发生在哪里,你需要查看堆,而不是编辑器。
为什么堆能反映真相
Chrome DevTools 为你提供了一个直接观察垃圾回收器所见内容的窗口。Memory 标签页可以记录堆快照(heap snapshots):即当前保存在 JavaScript 内存中的每个对象、DOM 节点和闭包的完整清单。通过对比两个快照——一个在怀疑发生泄漏之前,一个在之后——你可以精确地隔离出哪些对象未能被销毁。
这不是抽象理论。单个泄漏的 React 组件可能会保留数千个脱离的 HTMLElement 对象。这些对象不再连接到可见的文档,但 JavaScript 引用阻止了它们的回收。它们会以构造函数名称 Detached HTMLElement 出现在对比视图中。当你看到它们不断增加时,你就找到了泄漏点。
Chrome DevTools 工作流
从干净的状态开始。关闭无关的浏览器标签页,禁用无关的扩展程序,让你的应用程序进入稳定状态。打开 Chrome DevTools,切换到 Memory 标签页,选择 Heap snapshot。点击 Take snapshot。这个基准快照记录了你的初始内存占用。
现在执行你怀疑的精确用户操作。打开并关闭那个沉重的模态框。挂载并卸载该组件。导航到某个路由再返回。一旦 UI 回到原始的视觉状态,点击 Memory 标签页中的垃圾桶图标。这会强制进行一次全局垃圾回收。渲染周期产生的临时对象应该会被清除。任何留下的东西都是潜在的泄漏候选者。
再次点击 Take snapshot。你现在有了两张内存“照片”。将视图从 Summary 切换到 Comparison。将对比范围(comparison scope)设置为第一个快照。该工具将只显示两次捕获之间发生的变化,剔除运行时的噪音。
按 Delta 排序。寻找对象数量增加的部分。特别注意像 Detached HTMLElement、Array、Function 这样的构造函数,甚至是来自你代码库的命名类实例。Delta 值上升意味着对象在你的操作期间被创建,但在操作结束后未被回收。
追踪保留路径
当你发现泄漏的元素时,选中它。底部面板会显示保留路径(retaining path):一条解释了为什么该对象仍然存活的引用链。这条链可能从一个脱离的 div 开始,向上经过 React 内部属性,进入一个闭包,最后落在你某个组件内部注册的事件监听器上。链条中的最后一个环节就是你的代码行号。
这就是你从诊断转向寻找根本原因的时刻。如果保留路径止于 window.addEventListener,你就知道是一个全局监听器扣留了你的组件。如果它止于一个 IntersectionObserver 实例,你就知道一个观察器仍在监视一个本该被垃圾回收的节点。
React 中的常见罪魁祸首
React 中的内存泄漏通常分为三种模式。
孤立的全局监听器。 一个 useEffect 挂载到 window 或 document 上以跟踪滚动位置、按键或调整大小事件。如果该 effect 没有返回一个调用 removeEventListener 的清理函数,该监听器将在页面的整个生命周期内存活。因为监听器是一个闭包,它会在 React 卸载组件很久之后,依然让整个组件作用域保持存活。
未清理的观察者。IntersectionObserver 和 ResizeObserver 功能强大,但它们会在 React 控制范围之外创建原生引用。如果你在组件内部实例化了一个观察者,却忘记在清理阶段调用 disconnect(),那么观察者会持有目标 DOM 节点,而该 DOM 节点又会持有 React fibers、props 和 state。
闭包陷阱。当你在组件内部定义一个函数并将其传递给第三方库、全局缓存甚至 setTimeout 时,该函数会闭包捕获其词法作用域内的每一个变量。如果外部持有者保留了这个函数,它也会连带保留你整个组件的作用域。
真正有效的清理模式
修复内存泄漏意味着切断你在快照中发现的所有保留路径(retaining path)。
务必在 useEffect 中返回一个清理函数。如果你在 effect 中添加了监听器,请在那里将其移除。
对于任何附加到 DOM 或 window 的处理函数,请使用 useCallback。如果不使用它,每次渲染都会创建一个新的函数引用。如果你使用一个引用调用 addEventListener,随后又使用另一个不同的引用调用 removeEventListener,移除操作会静默失败。原始监听器将永远留在 window 上。useCallback 可以保持引用的稳定性,从而确保添加和移除的操作完全匹配。
以同样的严谨态度处理观察者。将观察者实例存储在 ref 或 effect 内部的局部变量中。在清理函数中,调用 observer.disconnect()。不要假设组件卸载就会销毁观察者。事实并非如此。
如果你的组件向全局命名空间或单例服务发布了任何内容,请在卸载时删除这些引用。只有当一个对象真正变得不可达时,V8 引擎才能回收其内存。在 window 上留下一个钩子,或者在模块级的 Map 中留下一个条目,都会创建一个隐形的桥梁,导致堆内存持续增长。
核心启示
内存泄漏不会立即导致应用崩溃。在长时间的用户会话中,它们会通过一个接一个的脱离节点(detached node)不断累积。修复方法不是升级库或更改编译器标志,而是养成使用堆快照(heap snapshots)来验证清理逻辑的习惯。
获取基准快照,触发可疑流程,强制进行垃圾回收,然后进行对比。如果增量(delta)显示内存增长,请检查保留路径,找到那个不该存在的监听器或观察者,并切断其引用。再次运行测试。当增量保持平稳时,说明你已经真正解决了问题。你的应用程序将保持响应,用户也不会因为浏览器标签页冻结而丢失工作进度。
