面试回答: 内存泄漏不是“GC 没工作”,而是业务上已经无用的对象仍能从全局变量、定时器、事件监听器、闭包等根节点访问,因此 GC 不能回收。排查时应固定一条可重复的操作路径,在操作前后获取堆快照或录制内存分配,重点观察经过 GC 后仍持续增长的对象,并沿 Retainers 查找是谁持有它。修复的关键是让资源的创建者同时负责清理,在组件卸载、路由切换或任务结束时断开引用并释放外部资源。
JavaScript 引擎按照对象是否可达决定能否回收内存。一个对象即使业务上已经不用了,只要仍被长期存活的引用持有,对垃圾回收器来说它就是“仍在使用”。如果这类对象不断累积,页面会逐渐卡顿甚至崩溃。
短时间内存升高不一定是泄漏。大数据处理、图片解码或批量渲染都可能造成内存峰值;判断重点是重复相同操作并经过垃圾回收后,基线是否仍持续上升。
垃圾回收的可达性规则见JavaScript 垃圾回收机制。
全局变量与模块级变量通常和页面存活时间一样长。如果缓存只增加、不淘汰,其中的对象就始终可达。
处理方式: 为缓存设置容量、过期或淘汰策略;如果附加数据的生命周期应跟随某个对象,可以评估使用 WeakMap。
闭包本身不是泄漏。问题在于闭包被长期保存时,会连带保留它实际引用的外层变量。
只要 window.currentHandler 存在,report 就不能被回收。应缩小闭包捕获范围,并在回调不再需要时解除长期引用。
宿主环境会保存回调,回调又可能通过闭包持有组件状态或大对象。
不仅 DOM 事件需要清理,消息总线、状态管理订阅、MutationObserver、ResizeObserver 和 WebSocket 监听也应该遵循同一生命周期规则。
DOM 节点已经从文档树移除,但 JavaScript 中仍保留着引用时,它会成为游离 DOM(Detached DOM)。如果引用的是父节点,整棵子树都可能被保留。
将变量设为 null 只有在它确实是最后一条持有路径时才有效,更重要的是找到并移除真正的长期引用。
图表、地图、编辑器等第三方库可能在内部创建 DOM、Canvas、观察器或全局事件。Worker、WebSocket 和媒体流也拥有独立于普通对象引用的外部资源。
组件卸载时应调用库提供的 destroy、dispose、disconnect、close 或 terminate 方法,而不能只删除页面上的容器节点。
未完成的请求或任务回调可能继续持有页面状态。组件销毁后即使回调不再更新 UI,它捕获的数据仍可能保留到任务结束。
可以在适当场景使用 AbortController 取消请求,同时移除与任务关联的回调和订阅。
不要只看一次任务管理器的内存数字。更可靠的方式是建立可重复的对照实验。
选择一条可能泄漏的操作,例如“进入详情页再返回”,连续执行多次。尽量排除热更新、开发工具扩展和首次懒加载带来的干扰。
在 Chrome DevTools 的 Performance 或 Memory 面板记录操作过程。重点观察垃圾回收发生后,JS Heap 的基线是否随着操作次数阶梯式上升。
曲线上升只能说明“值得调查”,不能单独证明泄漏,因为引擎可能保留堆空间等待复用。
重点搜索组件实例、业务数据类型以及 Detached DOM 节点。
找到可疑对象后,查看它的 Retainers(保留路径)。真正的修复点通常不是对象本身,而是把它连接到 GC Root 的那条引用,例如:
如果不知道对象在哪里被创建,可以录制 Allocation instrumentation on timeline,重复操作后查看哪些调用栈持续分配了未释放对象。
谁创建定时器、监听器、订阅或实例,谁就应该返回或注册对应的清理逻辑。这样资源生命周期不会散落在不同模块。
常见的成对操作包括:
| 创建 | 清理 |
|---|---|
setInterval | clearInterval |
addEventListener | removeEventListener |
observer.observe | observer.disconnect |
new Worker | worker.terminate |
new WebSocket | socket.close |
第三方库 create | destroy / dispose |
数组、Map、日志队列和请求缓存应有容量上限或淘汰规则。仅仅依赖页面最终关闭,不适合长期运行的单页应用。
WeakMap 适合“不应阻止键对象回收”的关联数据,但不能替代显式资源清理,也不适合需要确定枚举、持久缓存或可靠执行清理逻辑的场景。
不会。闭包是正常的语言能力。只有闭包被长期持有,同时又捕获了不再需要的对象时,才可能形成泄漏。
从文档树删除节点只断开了 DOM 层级关系。如果事件监听器、全局变量、缓存或闭包仍持有该节点,它依然可达。
首次操作可能包含模块初始化和缓存预热。更可靠的方法是先预热,再重复同一操作多轮,并比较回到相同页面状态后的对象数量和保留路径。