[实践] 如何使用Chrome工具排查内存泄露问题?

前言

目前团队缺少一套对业务的内存指标进行测试、排查、定位问题的方法论。对采集线上数据并上报方案进行了预研,发现线上方案由于API的粗糙粒度、获取数据的不准确、难以关联用户行为等原因,可以预见一定会有噪音,因此将线上方案定位为查看趋势与辅助分析。而准确排查、定位内存泄露的任务则交给通过工具与脚本进行线下排查。这篇文章则记录通过Chrome工具来排查前端业务中的内存泄露问题

内存增长与泄露

内存增长的常见原因

Web页面不是所有操作后都能彻底恢复到"初始"的最低内存。正常增长 = 合理缓存、加载的资源、代码、事件绑定等轮次随业务变化增长、但不无限制上涨,也不会产生大量无用垃圾。

类型 典型对象 增长场景 常见度 增长量
chunk编译代码/源码 JS模块/业务代码会保存在浏览器内存里(由JS引擎和模块管理缓存),以便页面跳转后快速复用,不会主动释放 compiled code, string 首次/新增页面 ★★★★★ 很大
base64图片、富媒体 通常以base64字符串等形式缓存于组件或全局数据,避免反复重新加载,只要引用存在就不会回收 string, array 编辑器/图片上传 ★★★★★ 很大
全局缓存/路由/store 全局变量(如store、context)保存,维护长会话和页面状态,始终被业务持有引用 array, object 持续业务会话 ★★★★☆
接口数据缓存 接口/业务数据被缓存到变量或store中,提升体验和性能;数据通常随用户会话保留,多路由/页面间复用,不主动清除 array, object 网络请求/业务缓存 ★★★★☆ 中~大
第三方库实例资源 如图表/编辑器/地图等实例初始化后,为支持多次渲染或交互,资源和实例对象长时间保留在内存中 object, function 图表/编辑/地图等 ★★★☆☆ 小~中
静态资源文件缓存 预加载的JS、CSS、字体、SVG等静态资源被浏览器和脚本缓存住,方便后续使用,未手动释放则一直在内存里 string, array 页面加载 ★★★☆☆
全局事件监听和闭包 窗口事件或自定义全局监听被长时间挂载,闭包引用页面数据,只要监听未解绑就会常驻 function, object 持久绑定/全局事件 ★★★☆☆
定时器回调函数 轮询、心跳等定时器回调保留在任务队列或变量中,只要定时器未销毁,对应闭包和数据就留在内存 function, object 轮询/心跳 ★★☆☆☆
页面 DOM 节点对象 页面挂载和卸载会创建和销毁DOM节点,但如果有代码/闭包或事件引用这些DOM,或没有及时销毁,相关节点会长留 DOM node 挂载/卸载页面 ★★☆☆☆
临时UI数据/状态 表单输入、弹窗交互等临时UI状态变量通常由组件/全局保存,流程结束后未及时清理则可能残留 object, array 表单/临时弹窗 ★☆☆☆☆

内存泄露常见原因

浏览器将"对象被需要(an object is needed)" 的概念等效于"对象可访问 (an object is reachable)",即只有某对象无法通过其变量和其他可访问对象的字段访问时,浏览器才认为可以安全地将其回收。由于两者概念之间的差异使得实际不被需要的对象不一定能被检测到,因此导致了内存泄露问题的存在。在业务中,常见的会导致内存泄露的场景有:

1、未正确清理订阅和定时器

在组件卸载后如果没有清理 setIntervalsetTimeoutaddEventListener,就会导致回调一直存在,造成内存泄露。

js 复制代码
useEffect(() => {
  const timer = setInterval(() => {
    // 轮询逻辑
  }, 1000);
  return () => {
    clearInterval(timer); // 必须清理
  };
}, []);
js 复制代码
useEffect(() => {
  window.addEventListener('resize', handler);
  return () => {
    window.removeEventListener('resize', handler); // 清理事件监听
  };
}, []);

2、闭包引用导致的内存泄漏

函数或变量因为被闭包包住,导致它们不会被垃圾回收。

js 复制代码
function createHandler() {
  let bigObj = { /* 很大的数据 */ };
  return () => {
    // 使用 bigObj
  };
}

useEffect(() => {
  const handler = createHandler();
  window.addEventListener('click', handler);
  return () => window.removeEventListener('click', handler); // handler/bigObj只要这里解绑后,没有其他外部引用,会被回收
}, []);

3、Ref、全局变量错误引用

ref 或 global 变量持续引用 DOM 或组件内部数据,即使组件卸载也未释放。

js 复制代码
let cache = []; // 全局变量
function MyComponent(props) {
  const data = useRef(props.data);
  cache.push(data); // ⛔ 错误,不应在这里放到全局缓存
  // ...
}

4、第三方库未正确卸载

如 ECharts、Swiper 等库未在组件销毁时释放内部资源。

js 复制代码
useEffect(() => {
  const chart = echarts.init(dom);
  // ...
  return () => {
    chart.dispose(); // 一定要在组件卸载时释放
  };
}, []);

此外还有 DOM节点死引用 、iframe/worker残留 、大型资源未释放等

如何排查内存泄露

看到这里,貌似我们已经知道了造成内存泄漏的大部分原因。那么到底我们该如何排查呢?难道只能通过一行行代码去排查吗,当然不是,谷歌的开发者工具也就是我们所说的浏览器控制台(Chrome Devtool)功能其实十分强大,通过它可以帮助我们分析程序中像性能、安全、网络等各种东西,也可以让我们快速定位到问题源,只是很多人并不熟悉其使用而已。

如果不需要代理本地,可以开启浏览器的无痕模式,无痕模式默认不会加载插件,这样可以排除浏览器插件对性能的影响。

页面概况分析:Performance 面板

一般可以先在 Performance 面板中获取到页面堆内存随着时间流逝的图像,据此分析页面操作和内存泄漏的总体情况,然后再通过 Memory 面板进行内存泄漏的详细分析。

  1. 首先打开Chrome Devtool 开发者工具,点击进入到 Performance 面板,勾选上 Screenshots(默认勾选) 及 Memory 选项,点击箭头所指的 record 按钮开始记录页面参数信息,在此过程中可以进行一些内存泄漏相关的可疑操作,方便后续的分析。
  1. 点击 stop 按钮结束记录,接着会生成涵盖此过程中页面各种参数的图表。

GC 不一定随时执行:浏览器会在合适的时机(比如内存压力大、后台空闲等)自动运行 GC。记录 Memory Snapshot 时,可能有些对象刚变为不可达,但还没被 GC 扫描到和释放。

因此注意在记录之前及记录结束时都要按一下上方的垃圾桶图标进行手动垃圾回收,可以确保两次内存快照中都没有即将被清除掉的内存占用。如果结束时js heap图像没有下降,一直是阶梯式增长,那么很有可能页面存在内存泄漏。

反复重复"页面A->页面B->页面A"五次,可以看到每次从页面B返回页面A都会有回落,且除了第一次从页面B返回首页A之外,其余情况返回页面A之后内存上升不明显,仅有少量增长,可以判断这个场景内存泄露可能性或程度较低,但如果要精细化排查则需要用到Memory面板工具。

更加精准的定位:Memory面板

Chrome DevTools Memory 通过页面的 JavaScript 对象和相关的 DOM 节点显示内存分布。使用它来获取JS堆快照,分析内存图,比较快照,并查找内存泄漏。

打开谷歌浏览器的调试页面,选择Memory Tab

查看内存堆快照

Heap snapshot 可以记录内存堆快照。内存堆快照中只包含可访问的对象,在开始记录内存堆快照前,手动执行一次内存回收。我们可以通过比较前后两次堆快照数据来分析内存泄漏问题。

顶部下拉选项含义:

  • 摘要 Summary

    显示按构造函数名称分组的对象。使用它根据构造函数类型查找对象(以及它们的内存使用)。它对于跟踪 DOM 内存泄漏特别有用。

  • 比较 Comparison

    显示两个快照之间的差异。使用它来比较一个操作前后的两个内存快照(在右侧下拉选项中选择要和当前对比的内存快照)。通过检查已释放内存中的增量和引用计数,可以确认内存泄漏的存在及其原因。

  • 控制 Containment

    此选项提供了一个更好的对象结构视图,帮助分析全局命名空间(window)中引用的对象,以找出是什么使它们保持在内存中。使用它来分析闭包,并从较低的层次深入到对象中。

  • 统计信息 Statistics

    统计各个类型的大小。如下图所示:

表头含义:

  • 构造函数 Constructor 表示这一行对象的类型。比如 array(数组)、objectfunction(函数)、stringHTMLDivElement(一个 DOM 元素)等。×后面的数字:这个类型的对象一共创建了多少个。展开">"后,@后面的数字:对象的唯一编号,定位用。一些常见类型举例说明:

    • compiled code:存储 JS 执行过程中编译出来的代码和相关结构体(包括 function、module wrap等)
    • system:不是普通 JS 对象,而是引擎内部结构,比如底层的句柄、底层 ArrayBuffer。JS代码不能直接访问它们。
    • detached DOM(游离DOM):被移出页面但未销毁的 DOM 节点,被 JS 变量闭包持有
    • array, string, number, regexp 等:存放运行时变量
  • 浅层大小 Shallow size :这类对象每个自身占多少内存,加总展示。数组和字符串浅层大小通常最大,因为它们内容直接塞在对象本体里。

  • 保留的大小 Retained size :如果这个对象彻底被干掉了(它和它能带走的下游对象),一共能放出来多少内存。

  • 距离 Distance 从根节点(比如 window,全局)一路最短路径能到这个对象的层级数,距离小,往往说明:对象被全局或关键变量持有,不容易被GC,容易泄漏。

内存堆快照对比分析示例

进行一些内存泄漏的可疑操作后继续记录堆快照,我这里重复'页面A->页面B->页面A'流程录制了5个堆快照,分别是快照1(页面A),快照2(第1次从页面B返回页面A),快照3(第2次从页面B返回页面A),快照4(第3次从页面B返回页面A),快照5(第4一次从页面B返回页面A)。

选择快照5,用比较选项,在右侧选择快照4,分析快照5和快照4的堆内存差异

此时即可根据增量大小来一一查看内存增长项是否合理。

举例:

查看Compiled code:主要增长为安全相关的三方脚本加密函数动态创建,判断为正常增长

使用内存堆快照检测分离的 DOM 内存泄漏

ArrayFunction等可能存在可读性差,难以确定根源等问题,可以尝试从DOM内存泄露入手。

DOM节点的垃圾回收机制是:当页面的DOM树和JavaScript代码都没有对某个DOM节点的引用时,才可以对其进行垃圾回收。如果一个DOM节点已经被从DOM树中删除,但某些JavaScript变量仍引用该节点,则该节点被称为detached DOM节点,不会被回收。它是内存泄漏的常见原因。

在Memory插件中,可以使用筛选器,输入关键字"Detached"查找分离的DOM树,然后点击DOM可以查看引用它的变量位置。

此时需要结合业务分析游离dom是否是由于代码问题导致的,举例:

例如游离div中大小排名最靠前的是 <div class="radar-echart-nmmyV" _echarts_instance_="ec_1760152242080">,这其实是页面B中的div节点,正常情况下在页面A时应该卸载

查看这里的代码,发现echarts实例没有卸载逻辑:

补上卸载逻辑,上述游离Dom消失:

查看内存分配时间轴信息

在 Memory 面板选择 Allocation instrumentation on timeline 可以记录内存分配时间轴信息。Chrome 会周期性以一定的间隔记录内存堆快照,并在记录结束时进行最后一次快照,以此生成内存分配时间轴信息。

开始记录后,在页面上进行一些操作,一段时间后停止记录,结果如下所示。

最上方的时间轴中,条形说明在该时间段发生了内存分配。条形的高度对应了分配的内存大小,条形的蓝色部分说明依然存活的对象数量,灰色部分说明已经被 GC 的对象数量。如果较早时间分配的对象依然大量存活,则说明可能有内存泄漏的问题。我们可以点击时间轴上该条形,检查该时间段内的详细内存分配信息。

相关推荐
我是天龙_绍5 小时前
Easing 曲线 easings.net
前端
知识分享小能手5 小时前
微信小程序入门学习教程,从入门到精通,电影之家小程序项目知识点详解 (17)
前端·javascript·学习·微信小程序·小程序·前端框架·vue
訾博ZiBo5 小时前
React组件复用导致的闪烁问题及通用解决方案
前端
Dever5 小时前
记一次 CORS 深水坑:开启 withCredentials 后Response headers 只剩 content-type
前端·javascript
临江仙4555 小时前
流式 Markdown 渲染在 AI 应用中的应用探秘:从原理到优雅实现
前端·vue.js
Hilaku6 小时前
为什么我开始减少逛技术社区,而是去读非技术的书?
前端·javascript·面试
m0_728033136 小时前
JavaWeb——(web.xml)中的(url-pattern)
xml·前端
猪哥帅过吴彦祖6 小时前
第 8 篇:更广阔的世界 - 加载 3D 模型
前端·javascript·webgl
七月十二6 小时前
[Js]使用highlight.js高亮vue代码
前端
Asort6 小时前
JavaScript设计模式(十二)——代理模式 (Proxy)
前端·javascript·设计模式