← 中文资源

简体中文版本

浏览器内存泄漏 — 分离的 DOM 节点

识别并修复由已移除 DOM 节点仍被引用而导致的内存泄漏:事件监听器、闭包或缓存持有强引用时,垃圾回收无法释放节点。包含堆快照与保留树诊断流程。

阅读英文原文

什么是分离的 DOM 节点泄漏

分离的 DOM 节点是指已经从文档树中移除、但仍被 JavaScript 引用的元素。由于引用链仍然可以从 GC 根到达,浏览器无法回收其内存。

症状

  • DevTools 的 Performance / Memory 面板中内存占用持续上升
  • 反复导航或动态替换内容后页面变卡
  • 堆快照中的 “Detached HTMLElement” 数量不断增加
  • 移除 DOM 元素后,DevTools 中的内存曲线不下降

诊断步骤

1. 打开 Chrome DevTools 的 Memory 面板

先拍一份堆快照作为基线,执行疑似泄漏的操作(例如打开/关闭弹窗 10 次),再拍一份快照。比较两份快照:如果 “Detached” 节点增加,就存在泄漏。

2. 使用 Allocation instrumentation on timeline

选择 “Allocation instrumentation on timeline” 并开始录制,执行导致泄漏的操作。蓝色柱状条代表新分配;如果柱状条持续增长且从不回落,说明泄漏对象仍被引用。

3. 检查分离节点的保留树

在堆快照中找到 “Detached HTMLDivElement”,展开其 Retaining Tree。保留树会显示是什么让该节点保持存活,常见来源包括 closure、array、listener,以及框架内部的 Fiber / vnode 引用。

4. 检查陈旧的事件监听器

在 Elements 面板的 Event Listeners 侧栏查看已被移除元素上的监听器。如果节点移除后监听器仍然存在,说明没有调用 removeEventListener,或者传入的函数引用不一致。

常见泄漏模式

1. 闭包捕获 DOM 引用

function setupHandler() {
  const button = document.getElementById('myBtn');
  const heavyData = new Array(10000).fill('payload');

  button.addEventListener('click', function() {
    console.log(heavyData); // 闭包同时捕获 button 和 heavyData
  });

  // 即使移除 button,监听器和闭包仍让它保持存活
}

修复:移除 DOM 节点之前先移除监听器:

button.removeEventListener('click', handler);
button.remove();

2. 使用 DOM 节点作为 Map/Set 的键

// ❌ 强引用阻止 GC
const metadata = new Map();
metadata.set(domNode, { created: Date.now() });

// ✅ 弱引用:节点不可达时允许 GC
const metadata = new WeakMap();
metadata.set(domNode, { created: Date.now() });

3. 框架 effect 清理(React)

useEffect(() => {
  const handler = (e) => console.log(e);
  const el = ref.current;
  el.addEventListener('mousemove', handler);

  // ✅ 组件卸载时移除监听器
  return () => el.removeEventListener('mousemove', handler);
}, []);

DevTools 工作流

  1. 打开 Memory 面板 → 拍摄 Heap Snapshot 基线
  2. 执行疑似泄漏操作(打开/关闭弹窗)10 次
  3. 再拍一份快照
  4. 将视图切换为两份快照的 Comparison
  5. 在构造函数列表中筛选 “Detached”
  6. 展开分离节点并检查其 Retaining Tree

根本原因

某个可达的 JavaScript 对象(数组、Map、闭包或事件监听器)仍然强引用已从文档树移除的 DOM 节点。引用链仍然存活,因此浏览器无法回收该节点及其关联的事件处理器。

修复方案

移除节点后,删除所有指向它的 JavaScript 引用。以 DOM 节点为键的缓存应使用 WeakMap。移除节点前先移除事件监听器。框架代码应在 useEffect / beforeUnmount / destroy 回调中清理引用。

风险提示

使用 WeakRef 时,必须在每次使用时检查 .deref() 是否为 null,因为目标可能在任何一次 GC 中被回收,不能假设引用仍然有效。

框架注意事项

  • React:检查 useEffect 清理函数以及保存 DOM ref 的 state
  • Vue:在 beforeUnmount 中清理监听器
  • Svelte:检查 action 是否缺少 destroy 回调

验证修复

应用修复后,重复 Memory 分析流程。反复打开/关闭或导航后,分离节点数量应保持稳定(曲线平直),不再持续增长。

预防准则

  • 始终把 addEventListener 与 removeEventListener 成对使用
  • 与 DOM 节点关联的元数据使用 WeakMap / WeakSet
  • 在清理/销毁阶段把大对象引用置空
  • 任何动态创建/销毁 DOM 的功能完成后都做一次内存分析

来源