引言
最近有一个工单是说用户在使用我们的系统的时候,如果使用某个页面的次数多了以后浏览器就开始变慢甚至卡死崩溃掉。这个问题明显是提示有内存泄露,今天就由这个问题开始分享一些关于内存泄漏的知识。
一、 Web 应用内存泄漏的危害与易忽略性
危害:
- 性能下降:内存泄漏导致浏览器内存占用持续增长,页面卡顿、响应延迟,最终可能崩溃
- 资源耗尽:移动设备电池消耗加剧,低端设备体验恶化
- 跨页面影响:SPA(单页应用)因无页面刷新,泄漏累积更严重
易忽略原因:
- 渐进性:泄漏初期症状不明显,用户仅感知轻微卡顿,难及时反馈
- 工具缺失:开发者缺乏实时监控手段,需主动使用 DevTools 分析
- 框架依赖:Vue/React 等框架的自动回收机制让开发者误以为无需手动管理
二、 什么是内存泄漏
- 核心概念:程序申请内存后未释放,导致无效内存占用持续增长
- JavaScript 中的表现:对象未被 GC 回收。即使不再使用,仍被全局变量、闭包或事件监听器引用
常见场景:
1. 意外的全局变量
// javascript function leak() { leakedVar = '全局泄漏'; // 未使用var/let/const,成为window属性 }
- 原因:未声明的变量会挂载到window对象,直到页面关闭才释放。
- 解决:严格模式(‘use strict’)或明确声明变量
2. 未清除的定时器与回调
// javascript const intervalId = setInterval(() => { // 重复操作 }, 1000); // 未调用 clearInterval(intervalId)
- 影响:定时器持续引用函数,阻止垃圾回收(GC)。
- 解决:在组件卸载或不再需要时清除定时器(clearInterval/clearTimeout)。
3. DOM引用未释放
// javascript const elements = { button: document.getElementById('myButton') }; document.body.removeChild(elements.button); // 移除了DOM节点 // 但 elements.button 仍保留引用
- 原因:JavaScript对象持有DOM引用,即使节点已从DOM树移除。
- 解决:移除节点后手动置空引用(elements.button = null)
4. 闭包引用
// javascript function createClosure() { const largeData = new Array(1000000).fill('data'); return () => console.log(largeData); // 闭包持有largeData } const closure = createClosure(); // largeData无法被GC回收
- 风险:闭包可能无意中持有大型数据结构。
- 解决:避免在闭包中保留不必要的数据,必要时手动解除引用。
5.未移除的事件监听器
// javascript document.addEventListener('click', handleClick); // 页面卸载时未调用 removeEventListener
- 后果:事件监听器阻止相关对象被回收。
- 解决:使用removeEventListener或在框架中利用生命周期钩子(如Angular的OnDestroy清理函数)
6. Web Workers未终止
// javascript const worker = new Worker('worker.js'); // 未调用 worker.terminate()
- 影响:Worker线程持续占用内存。
- 解决:在不需要时调用worker.terminate()。
7. 缓存无限增长
// javascript const cache = {}; function cacheData(key, data) { cache[key] = data; // 无缓存淘汰机制 }
- 问题:缓存未设置上限或过期策略。
- 解决:实现LRU(最近最少使用)等缓存淘汰策略。
三、 出发去找内存泄露
我们可以最大限度利用Chrome提供的工具来诊断内存泄露,我们一般有如下几种方式来诊断:
- 堆快照分析: 使用Chrome DevTools的堆快照功能记录内存状态,通过比较操作前后的快照差异来定位泄露对象。 对比视图(Comparison View)可显示操作后新增或未释放的对象,帮助确认泄露。 重点关注DOM节点泄露,例如已分离的DOM子树(Detached DOM Tree)因未被垃圾回收而持续占用内存。
- 分配分析器工具: 通过分配分析器(Allocation Profiler)实时跟踪内存分配,识别频繁创建且未释放的对象。
- 保留路径分析: 在堆快照中检查对象的保留路径(Retainers),分析为何对象未被释放。可忽略无关保留器以简化分析。
- 重复字符串与闭包检查: 过滤重复字符串(Duplicate Strings)和闭包(Closures),命名函数有助于区分闭包内存占用
四、开始诊断
如果你的应用要运行在移动端的浏览器中,那么对于内存的使用会更严格一些。需要在不同性能的设备上进行测试。但是我们这次主要是面对的是PC端,所以在测试环节会没有那么复杂。
1. 使用 Chrome 任务管理器实时监控内存使用情况
使用 Chrome 任务管理器作为调查内存问题的起点。Task Manager 是一个实时监视器,类似windows任务管理器,它能告诉页面使用了多少内存。
- 按 Shift+Esc 或者从 Chrome 主菜单选择 更多工具 > 任务管理器 打开任务管理器
- 然后右键单击 Task Manager 的表窗口启用 JavaScript 内存 。
- 实际的效果
- Memory footprint (内存占用) 列表示 OS 内存。DOM 节点存储在 OS 内存中。如果此值增加,则表示正在创建 DOM 节点。
- JavaScript Memory 列表示 JS 堆。这个列包含两个值。值得注意的是实时数字(括号中的数字)。活动数字表示页面上的可访问对象使用的内存量。如果此数量增加,则表示正在创建新对象,或者现有对象正在增长。
2. 使用性能记录可视化内存泄漏
可以使用Performance(性能)面板作为另一种调查方式。Performance(性能)面板可以让我们可视化的调查内存随着时间推移的使用情况.
- 在 DevTools 中打开 Performance (性能 ) 面板。
- 启用 Memory 复选框。
- 进行录制 ,最好在每次开始录制和结束前进行强制垃圾回收,点击小扫帚图标进行垃圾回收。
- 实际效果
记录下每次的内存数据,然后强制GC再次记录。观察如果内存数据持续增加不会被GC释放,则说明可能存在内存泄漏。
3. 上述的两种办法是初步的判断办法,下面我们以诊断分离树造成的内存泄漏为例,进行进一步分析。
首先什么是分离树(Detached DOM Tree)? 在v8执行GC的时候只有当页面的 DOM 树或 JavaScript 代码中没有对 DOM 节点的引用时,才能对 DOM 节点进行垃圾回收。当一个节点从 DOM 树中删除时,该节点会成为 “detached”的状态,但如果某些 JavaScript 仍然引用它就会造成内存泄露。
分离的 DOM 节点是内存泄漏的常见原因。这里使用 DevTools 的堆分析器来识别分离的节点。
好的,我们先开始新建一个Angular的简单APP
在Page1中,添加了监听事件统计鼠标的点击次数,随着点击次数的增加,改变背景颜色。 代码如下:
page1.html
<div> <h1>This is page 1</h1> <page-click-counter></page-click-counter> </div>
pageClickCounter.html
<div id="page-counter-child-view" style="border-radius: 10px; padding: 5px;"> <h1>This is page counter, it will show the user click count number:</h1> <h2>click: {{clickCount()}}</h2> </div>
page1.ts
import { Component } from '@angular/core'; import { PageClickCounter } from '../pageClickCounter/pageClickCounter'; @Component({ selector: 'app-page1', imports: [PageClickCounter], templateUrl: './page1.html', styleUrl: './page1.less' }) export class Page1 { }
pageClickCounter.ts
import { Component, signal, AfterViewInit, OnDestroy } from '@angular/core'; @Component({ selector: 'page-click-counter', imports: [], templateUrl: './pageClickCounter.html', styleUrl: './pageClickCounter.less' }) export class PageClickCounter implements AfterViewInit { protected clickCount = signal(0); childView: HTMLElement | null = null; ngAfterViewInit(): void { this.childView = document.querySelector('#page-counter-child-view'); document.addEventListener('click', this.clickHandler); } clickHandler = () => { this.clickCount.update(count => count + 1); console.log('Page1 click count:', this.clickCount()); // Update background color based on click count // Use HSL color with hue changing from green (120) to red (0) as clicks increase const hue = Math.min(120 - (this.clickCount() * 5), 120); (this.childView as HTMLElement).style.backgroundColor = `hsl(${hue}, 70%, 60%)`; }; }
实际的运行效果如下:
这个Demo里已经存在泄露了,这里我们使用DevTools的堆分析器进行内存泄漏的检测。
- 点击录制,录制好的快照如下:
- 在搜索框输入 detached 搜索分离的DOM树节点:
看这个搜索结果的表,里面有四个列:
- Constructor: 表示分离的DOM节点的构造类型
- Distance: 节点与根节点的距离
在浏览器中GC回收的根节点就是window对象,其他的对象或者基本类型都是从这里出发链接到一起的。
- Shallow size:这是对象本身持有的内存大小。典型的 JavaScript 对象会保留一些内存用于其描述和存储即时值。通常,只有数组和字符串可以具有明显的浅层大小。
- Retained size:这是对象及其所有子对象所占的内存大小。更精确的描述是删除对象本身及其无法从 GC 根访问的依赖对象后释放的内存大小。比如上图中的节点6和8,节点8依赖节点6儿存在,如果节点6被删除,那么节点8就无法访问。
现在我们了解了快照表上的几个列的含义,点击一个行在下面的Retainer表和看到详细的引用情况。然后我们就可以找到泄露产生的位置,来用对应的办法解决。
但是看我们搜索出来的结果很杂乱,而且在实际的复杂项目中这个结果可能更加的复杂。那我们要从哪里开始下手呢?
其实在我们Angular或者Vue这些一组件为基础组装的应用中,如果我们从 <div> 或者 <h1> 这些节点开始向上找的话大概率会找到一个自定义的组件为止。
所以这里开始解决的小技巧是从大的组件开始解决,因为好多搜索出来的基础元素泄露可能只是被自定义组件持有,当我们解决了组件级别的泄露,这些小的元素泄露会跟着消失。
好,看回我们的Demo在列表里发现了我们的自定义组件 page-click-counter,点击进去
这里提示我们的 clickHandler 函数的引用关系,我们点击进去
我们分析代码,这里的childView持有了页面上的元素,然后订阅了document的click事件。问题出现在页面销毁的时候这个点击事件的定义还在,clickHandler函数持有的DOM 对象childView就成为了分离的DOM。
好,我们开始解决这个问题。在页面销毁的生命周期函数里把订阅取消。
// javascript ngOnDestroy(): void { // Clean up the event listener to prevent memory leaks document.removeEventListener('click', this.clickHandler); }
重新编译运行,然后同样的方法记录内存快照。
我们可以看到,内存快照中不再有刚才的泄露对象。
五、 其他内存检测方案
浏览器内置工具:
- Chrome DevTools:
- Heap Snapshot:对比多次快照,识别未释放对象
- Allocation Timeline:跟踪内存分配时间线,定位泄漏点
六、 预防策略与未来方向
代码规范:
- 及时释放资源:
- 事件监听器、定时器在 ngOnDestroy 中移除
// javascript ngOnDestroy(): void { this.subscription.unsubscribe(); // 清理RxJS订阅 document.removeEventListener('click', this.handler); }
- 使用 WeakMap 替代强引用存储临时数据
// javascript // 使用WeakMap存储临时数据 const weakMap = new WeakMap(); const element = document.getElementById('target'); weakMap.set(element, { metadata: 'data' }); // 当element被移除时,关联数据可被GC回收
- 避免全局变量:严格模式(use strict)禁用意外全局声明
架构优化:
- 资源隔离:为组件分配独立作用域,避免交叉引用
// javascript // Angular服务作用域隔离示例 @Injectable({ providedIn: 'root' }) // 根作用域 class RootService {} @Injectable({ providedIn: 'platform' }) // 平台级作用域 class PlatformService {} @Injectable({ providedIn: 'any' }) // 每个模块独立实例 class ModuleService {}
- 团队实践:定期代码审查,重点关注事件绑定、闭包和第三方库
审查项
检查点
事件管理
是否在组件销毁时移除监听器
订阅管理
RxJS订阅是否正确取消
DOM引用
是否存在未释放的DOM引用
缓存机制
是否设置过期策略
未来趋势: 现在我们可以借助AI大模型和MCP来实现文件扫描,让大模型可以扫描并分析代码查找可能出现内存泄露的写法,并给出修改建议。
以下就是AI扫描pageClickCounter.ts文件后给出的修复建议。
The following memory leak issues were identified in the pageClickCounter.ts file:
1. Missing Event Listener Cleanup
The clickHandler event listener attached to document in ngAfterViewInit is not removed in ngOnDestroy.This creates a reference cycle:
- document retains a reference to clickHandler
- clickHandler retains a reference to the component instance via this
- Prevents Angular’s garbage collection of the component
2. Unmanaged DOM Reference
The childView property retains a direct DOM element reference through: javascript// javascript this.childView = document.querySelector('#page-counter-child-view');
While less critical than the event listener issue, this could prolong DOM element lifecycle beyond component destruction if not explicitly nullified.
Recommended Fix:
Uncomment and implement ngOnDestroy to clean up resources:// javascript ngOnDestroy(): void { // 1. Remove event listener document.removeEventListener('click', this.clickHandler); // 2. Clear DOM reference this.childView = null; }
Analysis Context:
- This is an Angular component using reactive signals (signal()) - The memory leak occurs through:
document -> clickHandler -> component -> childView -> DOM Element - The event handler pattern follows Angular’s best practices but requires explicit cleanup for document/window listeners
七、结论
与应用程序中的内存泄漏作斗争可能是一项艰巨的任务。但是通过执行以上这些步骤,我们可以了解内存泄漏模式确定内存泄漏的根本原因,并实施必要的清理以防止进一步危害。而且现在我们现在有了LLM工具,我们可以使用大模型来分析代码并确定内存泄漏的根本原因,并使这个自动化过程更加高效和有效。