堆快照里被标记为 Detached 的节点,并不是垃圾回收器没有执行,而是它们仍然被一条可追踪的引用链抓着。React 组件卸载后,界面上的 DOM 节点确实会被移除,但如果一个 useEffect 没有返回清理函数,或者某个 ref.current 被存进了模块级变量,这些节点就会从文档树中脱离,却仍然留在堆里,形成所谓的分离 DOM 树。查找这类问题时,单纯看代码不一定能马上发现,而堆快照可以直观地把引用关系暴露出来。

分离 DOM 树最麻烦的地方在于,它往往不是单个节点,而是以根节点为入口的整棵子树。一个容器 <div> 被保留后,它下面的所有子元素、文本节点、事件处理器以及 React 内部与之关联的 Fiber 节点都可能一起留在内存中。因此,在 React 项目里定位分离 DOM 树,不只是找到某个游离的 <div>,而是要沿着引用链找到最开始的保留点,判断它来自业务代码、第三方库还是 React 自身的并发渲染机制。
分离DOM树为什么在React中更容易出现
正常情况下,React 在提交删除操作时会调用 removeChild 把节点从文档树中移除。只要没有其他 JavaScript 对象引用它,垃圾回收器就会在后续周期中回收这块内存。但 React 本身无法控制用户代码是否继续持有 DOM 引用。如果某个监听器、定时器、全局缓存或闭包仍然指向这个节点,节点就会变成 Detached 状态:它已经不在页面上,但还不能被回收。
React 的组件模型让这种问题更容易被放大。组件挂载时通常会建立副作用,例如添加 resize 监听、创建 IntersectionObserver、把 ref.current 传给外部库。如果组件卸载时没有对称地清理,这些外部对象就会继续引用 DOM。更隐蔽的是,某些库会把 DOM 节点存进模块级 Map 或数组,组件即使卸载,Map 仍然存活,整棵子树自然也不会释放。
还有一个容易被忽略的来源是 React 自身的 Fiber 结构。Fiber 节点上的 stateNode 会指向对应的 DOM 节点,而 alternate 会指向另一个 Fiber 版本。在并发渲染或严格模式下,React 可能短暂保留旧的 Fiber 树。如果同时有用户代码引用了 Fiber 或 DOM,引用链会变得更加复杂,但这并不代表 React 泄漏,更多时候是业务代码把本该局部存在的引用提升到了全局生命周期。
下面是一个典型的错误示例:组件卸载后,模块级 Map 仍然保留着 DOM 节点的引用。
const domCache = new Map();
function ProblemComponent() {
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
domCache.set('main-panel', ref.current);
}
// 组件卸载时没有从 Map 中删除引用
}, []);
return <div ref={ref}>内容区域</div>;
}
从堆快照中过滤分离DOM节点
拍快照的时机很重要。进入 Chrome DevTools 的 Memory 面板,选择 Heap snapshot 后点击 Take snapshot。最好在操作前后各拍一次:先拍一次作为基线,然后执行打开弹窗、切换路由、卸载组件等可疑操作,再拍一次。这样可以避免历史遗留节点干扰判断。
快照生成后,在 Summary 视图顶部的 Class filter 中输入 Detached。Chrome 会把所有已经脱离文档但还存活的对象按构造函数分组,例如 Detached HTMLDivElement、Detached HTMLSpanElement。选中某一组后,下方会列出具体对象,右侧的 Retainers 视图会显示是谁在引用它。通常优先看距离最近的那条引用链,因为那往往就是需要切断的保留点。
引用链可能经过 HTMLDivElement 到某个 Map,再到模块作用域;也可能经过 FiberNode 的 stateNode 指向 DOM。如果看到 stateNode 或 alternate 出现在引用链里,说明 React 内部结构参与了保留,但这不代表一定是 React 的 bug,有时是用户代码把 Fiber 或 DOM 引用挂到了外部对象上。
除了直接过滤 Detached,还可以使用 Containment 视图查看整体内存分布,或者用 Statistics 视图了解各类型对象的数量。不过对分离 DOM 树来说,Summary 加 Retainers 是最直接的组合。重点关注引用链中是否出现 window、document、模块作用域、全局缓存等长生命周期对象,因为这些对象会阻止节点被回收。
常见的React引用保留场景与修复方式
第一种典型场景是事件监听器没有清理。组件挂载时通过 addEventListener 绑定了 resize、scroll 或自定义事件,但卸载时没有调用 removeEventListener。监听器函数内部如果捕获了 ref.current 或某个 DOM 节点,该节点就会被监听器所在的 window 或 document 一直引用。React 的 useEffect 提供了天然的清理位置,只要返回一个函数移除监听即可。
第二种场景是把 DOM 节点存进模块级缓存、Redux store 或者 Context。这种做法在需要跨组件访问某个节点时很常见,但模块级变量的生命周期和页面一样长,一旦忘了删除,组件卸载后节点也不会被回收。更好的做法是只保存节点标识或数据,不直接保存 DOM 对象;确实需要保存时,必须在组件卸载的清理函数里执行 delete 或 clear。
第三种场景是闭包捕获了事件目标。比如在一个异步回调里访问了 event.target,而这个回调又被 setTimeout 或 Promise 长期持有。即使组件已经卸载,这个回调仍然可能触发,从而保留已经不在文档中的节点。解决思路是在卸载时取消定时器或使用 AbortController 终止未完成的操作。
下面是一个正确清理事件监听器的示例:
useEffect(() => {
const node = ref.current;
if (!node) return;
const handleResize = () => {
console.log('resize', node.clientWidth);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, []);
如果组件中还创建了定时器、订阅或观察器,也应该在同一个清理函数里取消。例如使用 AbortController 管理多个监听器,可以在卸载时统一调用 controller.abort(),减少逐个移除监听器的遗漏风险。
对比快照与验证修复效果
单次快照只能说明某个时刻存在分离 DOM,无法判断它是不是刚刚产生。更可靠的方法是使用对比视图。在 Memory 面板中先拍一次快照,执行可疑操作后再拍一次,然后在顶部下拉框选择 Comparison,按 Delta 列排序。这样就能看到第二次快照相比第一次新增了哪些 Detached 节点。如果修复后再次对比,新增的 Detached DOM 数量明显下降或归零,说明引用链已经被切断。
需要注意的是,React 18 的并发特性在某些阶段会保留 alternate Fiber 树,这可能让快照中短暂出现一些 Fiber 到 DOM 的引用。遇到这种情况,不要马上判定为泄漏,可以先在空闲时间拍多次快照,或者点击垃圾桶图标手动触发垃圾回收后再观察。此外,Chrome 扩展和 React DevTools 自身也可能引入引用,分析时最好使用无痕窗口或关闭无关扩展,减少干扰。
定位分离 DOM 树的最终目的,不是把 Detached 节点从快照里删掉,而是找到让它们保持存活的那条最短引用链。每一条引用链都对应一个可以修改的代码位置:可能是忘记移除的监听器,可能是模块级的缓存,也可能是异步操作没有取消。通过堆快照的 Retainers 视图和对比视图,能把看起来复杂的内存问题拆解成明确的引用关系,修复起来就更有把握。