从 Redux & BugSnag 引起的内存异常定位,介绍 Chrome memory 使用

300 阅读7分钟

背景

线上反馈在 SPA 页面切换的时候,页面会有缓存的列表加载不出来的情况。由于问题偶现而且在移动端容易出现,所以一方面,从代码的角度定位到问题;另外一方面,由于代码问题出现的场景,是在两个 useEffect 的依赖项触发变更先后异常,导致顺序执行异常,说明项目在前端性能方面已经暴露问题,所以顺带对性能做出优化。
为了将定位性能问题的思路说明白,下面将思路部分用引用的方式说明,着重介绍 Chrome Debug Tool 中,Memory tab 的使用。

Memory 介绍

下面是 Memory tab 的初始状态。 image.png 我们从中央部分 select profilling type 开始介绍。这部分是选择需要监测的变量的范围

  • 4.1 Heap snapshot. 针对此时的页面变量分布,做堆变量快照。
  • 4.2 Allocation instrumentation on timeline. 在录制开始,到结束录制的时间段内,记录多次 GC 过程,用于做变量分析。
  • 4.3 Allocation sampling. 在录制开始,到结束录制的时间段内,记录多次 GC 过程,用于做函数和变量关系分析。

三者都是对当前的堆栈进行记录,各自的适用场景(个人观点):

heap snapshotAllocation instrumentation on timelineAllocation sampling
行为对当前时间点的内存一次性记录时间段记录,可以检查某个时间点的内存情况持续采样式记录
适用场景只查看当前状态下堆栈信息检查某个行为导致页面突然内存暴涨分析函数调用栈和变量的关系,适合分析不同组件的内存问题,以及分析不合理函数调用深度

除了以上的三种方式,在三个选项的下方还有动态的内存变化,可以感性的看到即时的内存变化。选中记录的方式之后,点击 1 的图标可以开始录制。针对每种录制结果,继续看下每种检查方式的都可以怎样的帮开发者定位内存问题。

监测方式

Heap snapshot

image.png 上图中可以看到有三种分析方式:

  • Summary: 根据当前实例化的类名将所有的变量分类,点击展开可以看到每种类型的值。对应的列信息:

    • Constructor: 实例化的类名
    • Distance: 距离 GC 根节点的深度信息
    • Shallow Size: 非引用值所占空间
    • Retained Size: 包含引用值和浅层值之和所占的所有空间
  • Containment: 按照引用关系树来展示变量。可能适用于分析工具函数类的变量异常 image.png

  • Stattistics:通过饼图直观的展示不同类占用内存的百分比 image.png

Allocation instrumentation on timeline

提供的分析功能和 heap snapshot 相同,但是增加了内存随着时间线/GC变化的记录。 image.png 上图中可以看到,随着时间轴推移,规律性的增加几个柱形。这也是给内存快照增加时间线功能的关键所在。 首先我们来看下每个柱形。每个柱形都是发生了一次 GC 的前后内存变化。灰色柱高是被回收的内存,蓝色柱高是 GC 之后余留的内存,所以加和之后,总柱高就是 GC 之前的内存大小。
接下来我们可以在时间轴上,用鼠标选定一个范围(在开始时间点按下鼠标 -> 滑动 -> 在结束时放开鼠标),下方内存明细也会随之变成当前的时间点的情况。
如果我们是在分析内存溢出的情况,那么这个使用场景会是一个很适合的场景。在点击录制之后,可以进行触发内存溢出的操作,然后停止记录。在时间点就可以明显看到对应的内存明显增高并且无法回收的情况。在录制的过程中,也可以通过3的按钮手动触发 GC。

Allocation sampling

类似的时间轴 + 分析的界面,但是提供的分析方式却很不同。 Allocation sampling 提供了三种分析方式:ChartHeavy(Bottom Up)Tree(Top Down),我们看下这三种分析方式:

  • Chart:函数调用堆栈图形。横坐标代表内存增大,纵坐标代表函数调用深度变化。 image.png
  • Heavy(Bottom Up): 按照函数调用栈中内存的从大到小的顺序,从上向下展示函数调用。虽然 Memory 分析提供了 load file 来分析之前记录的内存文件,但是对于在这个分析方式,我不是很建议大家这样用。因为正常去分析一个文件的时候,我们通过点击下方的函数调用栈,定位到文件位置。如果是本地重新编译,或者远程重新部署,文件变化了之后就会定位不到对应的文件。 image.png 这里面的三列也比较简明:
    • Self Size:浅层变量(非引用变量)
    • Total Size: 当前函数调用栈的总内存用量,兼具引用变量和浅层变量
    • Function:调用函数

问题分析

所有的基本知识都介绍完了,那么转回来看问题。

【分析过程】使用 Allocation instrumentation on timeline 方式记录下当前的内存分配情况,可以看到应用的字符串类型的数据内存占比58%,这个数字多少有些超限了,展开列表之后发现,除了一些 base64 格式的图像外,还有一些对象的字符串类型数据,根据内存占比可以看到,一条内存占比可以达到 ~6%
base64是因为使用了字节 icon-park 图标库,但是下图中选中的一条,应该只存在于 Redux 中并且类型为对象的业务数据,以字符串的形式保存下来就很异常。

image.png

看了下红圈中的调用栈历史,发现了 vconsole 和 bugSnag 的踪迹。所以先尝试性的移除 BugSnag 相关操作。

在尝试性移除 BugSnag 之后,打包结果运行分析如下: image.png
可以看到内存占用减少了57%,并且对应的业务数据的字符串格式记录也消失了。所以这个时候可以确定,问题是和 BugSnag 相关的。然后继续推进刚才的内存分析中,定位相关问题。

还原 BugSnag 相关监控依赖,通过调用栈找到了下图的方法。读了一下发现是 BugSnag 对浏览器的 console 相关方法做了劫持,并且将参数保存在外部的 arg 变量中,形成了闭包,影响了正常 GC。除了 console 相关方法之外,其他的浏览器方法比如 fetch 也被劫持,这应该就是 Bugsnag 监控的方式吧。
image.png

但是项目是不能弃用 BugSnag,所以只能想办法解决打印问题。而且相信 BugSnag 会妥善的处理保存变量的(应该)。通过向上寻找调用栈,发现这个报错是 Redux 抛出的(下面是用 Allocation sampling 补了一张图,但是时间线分析也可以看到对应的调用)。加了一个断点(其实此时从console就可以看到了)之后是发现,报了这样的错:
Selector unknown returned a different result when called with the same parameters. This can lead to unnecessary rerenders

image.png

更深层的原因不赘述,原因是 在使用 Redux useSelector 的时候,返回值不应变化,因为 useSelector 是执行浅比较的。比如:

// 场景一
const {isActive, name} = useSelector(store => ({
  isAlive: store.admin.isAlive,
  name: store.user.name,
})) // 虽然返回值每个属性的引用可能是相同的,但是函数执行结果不同,导致 useSelector 每次对比都会返回一个新对象
// 场景二
const adminList = useSelector(store => store.admin.list || []) // 在返回list为空时,每次返回的空数组其实都是和前一个不同的
// 改成:
// store.js
export const ARR = []
const adminList = useSelector(store => store.admin.list || ARR) // 如果有不同值之间的比较慎用这个,因为不同的值返回的空值状态可能一样

用正则匹配了一下代码,修复了问题,然后,将 configureStoredevTools 设置成了 false。。或者说这个问题其实也是因为这个配置导致的,Redux 会在开发模式下打印警告,而且默认是开发模式,需要手动根据当前环境设置开发模式。