JavaScript 常见内存泄漏与排查

面试回答: 内存泄漏不是“GC 没工作”,而是业务上已经无用的对象仍能从全局变量、定时器、事件监听器、闭包等根节点访问,因此 GC 不能回收。排查时应固定一条可重复的操作路径,在操作前后获取堆快照或录制内存分配,重点观察经过 GC 后仍持续增长的对象,并沿 Retainers 查找是谁持有它。修复的关键是让资源的创建者同时负责清理,在组件卸载、路由切换或任务结束时断开引用并释放外部资源。

1. 什么是内存泄漏?

JavaScript 引擎按照对象是否可达决定能否回收内存。一个对象即使业务上已经不用了,只要仍被长期存活的引用持有,对垃圾回收器来说它就是“仍在使用”。如果这类对象不断累积,页面会逐渐卡顿甚至崩溃。

业务上已经无用 + 仍然可达 + 持续累积 = 典型内存泄漏

短时间内存升高不一定是泄漏。大数据处理、图片解码或批量渲染都可能造成内存峰值;判断重点是重复相同操作并经过垃圾回收后,基线是否仍持续上升。

垃圾回收的可达性规则见JavaScript 垃圾回收机制

2. 常见泄漏来源

(1) 无边界的全局状态和缓存

全局变量与模块级变量通常和页面存活时间一样长。如果缓存只增加、不淘汰,其中的对象就始终可达。

const userCache = new Map();

export function rememberUser(user) {
  userCache.set(user.id, user);
}

处理方式: 为缓存设置容量、过期或淘汰策略;如果附加数据的生命周期应跟随某个对象,可以评估使用 WeakMap

(2) 闭包持有大型对象

闭包本身不是泄漏。问题在于闭包被长期保存时,会连带保留它实际引用的外层变量。

function createHandler() {
  const report = buildLargeReport();

  return () => {
    console.log(report.title);
  };
}

window.currentHandler = createHandler();

只要 window.currentHandler 存在,report 就不能被回收。应缩小闭包捕获范围,并在回调不再需要时解除长期引用。

(3) 未清理的定时器、订阅和事件监听器

宿主环境会保存回调,回调又可能通过闭包持有组件状态或大对象。

function mount() {
  const data = loadLargeData();
  const timer = setInterval(() => sync(data), 1000);
  const onResize = () => layout(data);

  window.addEventListener('resize', onResize);

  return () => {
    clearInterval(timer);
    window.removeEventListener('resize', onResize);
  };
}

不仅 DOM 事件需要清理,消息总线、状态管理订阅、MutationObserverResizeObserver 和 WebSocket 监听也应该遵循同一生命周期规则。

(4) 游离 DOM

DOM 节点已经从文档树移除,但 JavaScript 中仍保留着引用时,它会成为游离 DOM(Detached DOM)。如果引用的是父节点,整棵子树都可能被保留。

const references = {
  panel: document.querySelector('.panel')
};

references.panel.remove();

// DOM 已移除,但 references.panel 仍然持有该节点。
references.panel = null;

将变量设为 null 只有在它确实是最后一条持有路径时才有效,更重要的是找到并移除真正的长期引用。

(5) 未销毁的第三方实例和后台资源

图表、地图、编辑器等第三方库可能在内部创建 DOM、Canvas、观察器或全局事件。Worker、WebSocket 和媒体流也拥有独立于普通对象引用的外部资源。

组件卸载时应调用库提供的 destroydisposedisconnectcloseterminate 方法,而不能只删除页面上的容器节点。

(6) 已结束业务留下的异步回调

未完成的请求或任务回调可能继续持有页面状态。组件销毁后即使回调不再更新 UI,它捕获的数据仍可能保留到任务结束。

可以在适当场景使用 AbortController 取消请求,同时移除与任务关联的回调和订阅。

3. 如何系统定位泄漏?

不要只看一次任务管理器的内存数字。更可靠的方式是建立可重复的对照实验。

第一步:固定复现路径

选择一条可能泄漏的操作,例如“进入详情页再返回”,连续执行多次。尽量排除热更新、开发工具扩展和首次懒加载带来的干扰。

第二步:观察回收后的基线

在 Chrome DevTools 的 Performance 或 Memory 面板记录操作过程。重点观察垃圾回收发生后,JS Heap 的基线是否随着操作次数阶梯式上升。

曲线上升只能说明“值得调查”,不能单独证明泄漏,因为引擎可能保留堆空间等待复用。

第三步:比较 Heap Snapshot

  1. 操作前获取第一份堆快照。
  2. 重复执行可疑操作。
  3. 回到预期资源已经释放的状态,再获取第二份快照。
  4. 使用 Comparison 视图查看新增且未释放的对象。

重点搜索组件实例、业务数据类型以及 Detached DOM 节点。

第四步:沿 Retainers 找持有者

找到可疑对象后,查看它的 Retainers(保留路径)。真正的修复点通常不是对象本身,而是把它连接到 GC Root 的那条引用,例如:

window
└── event listener
    └── callback closure
        └── component state

第五步:用 Allocation instrumentation 定位来源

如果不知道对象在哪里被创建,可以录制 Allocation instrumentation on timeline,重复操作后查看哪些调用栈持续分配了未释放对象。

4. 修复原则

创建者负责清理

谁创建定时器、监听器、订阅或实例,谁就应该返回或注册对应的清理逻辑。这样资源生命周期不会散落在不同模块。

生命周期必须成对

常见的成对操作包括:

创建清理
setIntervalclearInterval
addEventListenerremoveEventListener
observer.observeobserver.disconnect
new Workerworker.terminate
new WebSocketsocket.close
第三方库 createdestroy / dispose

限制长期容器的增长

数组、Map、日志队列和请求缓存应有容量上限或淘汰规则。仅仅依赖页面最终关闭,不适合长期运行的单页应用。

不滥用弱引用

WeakMap 适合“不应阻止键对象回收”的关联数据,但不能替代显式资源清理,也不适合需要确定枚举、持久缓存或可靠执行清理逻辑的场景。

5. 高频追问

闭包一定会造成内存泄漏吗?

不会。闭包是正常的语言能力。只有闭包被长期持有,同时又捕获了不再需要的对象时,才可能形成泄漏。

删除 DOM 后为什么内存没有下降?

从文档树删除节点只断开了 DOM 层级关系。如果事件监听器、全局变量、缓存或闭包仍持有该节点,它依然可达。

为什么连续拍两张堆快照还不够?

首次操作可能包含模块初始化和缓存预热。更可靠的方法是先预热,再重复同一操作多轮,并比较回到相同页面状态后的对象数量和保留路径。

6. 总结

  • 内存泄漏的本质是无用对象仍然可达并持续累积
  • 优先检查长期存活的全局状态、闭包、定时器、监听器、游离 DOM 和第三方实例。
  • 排查时先固定复现路径,再比较 GC 后的基线、堆快照和 Retainers。
  • 最有效的工程约束是让资源创建与清理成对出现,并让清理动作跟随业务生命周期。