背景
线上反馈在 SPA 页面切换的时候,页面会有缓存的列表加载不出来的情况。由于问题偶现而且在移动端容易出现,所以一方面,从代码的角度定位到问题;另外一方面,由于代码问题出现的场景,是在两个 useEffect 的依赖项触发变更先后异常,导致顺序执行异常,说明项目在前端性能方面已经暴露问题,所以顺带对性能做出优化。
为了将定位性能问题的思路说明白,下面将思路部分用引用的方式说明,着重介绍 Chrome Debug Tool 中,Memory tab 的使用。
Memory 介绍
下面是 Memory tab 的初始状态。
我们从中央部分
select profilling type 开始介绍。这部分是选择需要监测的变量的范围
- 4.1 Heap snapshot. 针对此时的页面变量分布,做堆变量快照。
- 4.2 Allocation instrumentation on timeline. 在录制开始,到结束录制的时间段内,记录多次 GC 过程,用于做变量分析。
- 4.3 Allocation sampling. 在录制开始,到结束录制的时间段内,记录多次 GC 过程,用于做函数和变量关系分析。
三者都是对当前的堆栈进行记录,各自的适用场景(个人观点):
| heap snapshot | Allocation instrumentation on timeline | Allocation sampling | |
|---|---|---|---|
| 行为 | 对当前时间点的内存一次性记录 | 时间段记录,可以检查某个时间点的内存情况 | 持续采样式记录 |
| 适用场景 | 只查看当前状态下堆栈信息 | 检查某个行为导致页面突然内存暴涨 | 分析函数调用栈和变量的关系,适合分析不同组件的内存问题,以及分析不合理函数调用深度 |
除了以上的三种方式,在三个选项的下方还有动态的内存变化,可以感性的看到即时的内存变化。选中记录的方式之后,点击 1 的图标可以开始录制。针对每种录制结果,继续看下每种检查方式的都可以怎样的帮开发者定位内存问题。
监测方式
Heap snapshot
上图中可以看到有三种分析方式:
-
Summary: 根据当前实例化的类名将所有的变量分类,点击展开可以看到每种类型的值。对应的列信息:
- Constructor: 实例化的类名
- Distance: 距离 GC 根节点的深度信息
- Shallow Size: 非引用值所占空间
- Retained Size: 包含引用值和浅层值之和所占的所有空间
-
Containment: 按照引用关系树来展示变量。可能适用于分析工具函数类的变量异常
-
Stattistics:通过饼图直观的展示不同类占用内存的百分比
Allocation instrumentation on timeline
提供的分析功能和 heap snapshot 相同,但是增加了内存随着时间线/GC变化的记录。
上图中可以看到,随着时间轴推移,规律性的增加几个柱形。这也是给内存快照增加时间线功能的关键所在。
首先我们来看下每个柱形。每个柱形都是发生了一次 GC 的前后内存变化。灰色柱高是被回收的内存,蓝色柱高是 GC 之后余留的内存,所以加和之后,总柱高就是 GC 之前的内存大小。
接下来我们可以在时间轴上,用鼠标选定一个范围(在开始时间点按下鼠标 -> 滑动 -> 在结束时放开鼠标),下方内存明细也会随之变成当前的时间点的情况。
如果我们是在分析内存溢出的情况,那么这个使用场景会是一个很适合的场景。在点击录制之后,可以进行触发内存溢出的操作,然后停止记录。在时间点就可以明显看到对应的内存明显增高并且无法回收的情况。在录制的过程中,也可以通过3的按钮手动触发 GC。
Allocation sampling
类似的时间轴 + 分析的界面,但是提供的分析方式却很不同。 Allocation sampling 提供了三种分析方式:Chart、Heavy(Bottom Up)、Tree(Top Down),我们看下这三种分析方式:
- Chart:函数调用堆栈图形。横坐标代表内存增大,纵坐标代表函数调用深度变化。
- Heavy(Bottom Up): 按照函数调用栈中内存的从大到小的顺序,从上向下展示函数调用。虽然 Memory 分析提供了 load file 来分析之前记录的内存文件,但是对于在这个分析方式,我不是很建议大家这样用。因为正常去分析一个文件的时候,我们通过点击下方的函数调用栈,定位到文件位置。如果是本地重新编译,或者远程重新部署,文件变化了之后就会定位不到对应的文件。
这里面的三列也比较简明:
- Self Size:浅层变量(非引用变量)
- Total Size: 当前函数调用栈的总内存用量,兼具引用变量和浅层变量
- Function:调用函数
问题分析
所有的基本知识都介绍完了,那么转回来看问题。
【分析过程】使用
Allocation instrumentation on timeline方式记录下当前的内存分配情况,可以看到应用的字符串类型的数据内存占比58%,这个数字多少有些超限了,展开列表之后发现,除了一些 base64 格式的图像外,还有一些对象的字符串类型数据,根据内存占比可以看到,一条内存占比可以达到 ~6%。
base64是因为使用了字节 icon-park 图标库,但是下图中选中的一条,应该只存在于 Redux 中并且类型为对象的业务数据,以字符串的形式保存下来就很异常。
看了下红圈中的调用栈历史,发现了 vconsole 和 bugSnag 的踪迹。所以先尝试性的移除 BugSnag 相关操作。
在尝试性移除 BugSnag 之后,打包结果运行分析如下:
可以看到内存占用减少了57%,并且对应的业务数据的字符串格式记录也消失了。所以这个时候可以确定,问题是和 BugSnag 相关的。然后继续推进刚才的内存分析中,定位相关问题。
还原 BugSnag 相关监控依赖,通过调用栈找到了下图的方法。读了一下发现是 BugSnag 对浏览器的 console 相关方法做了劫持,并且将参数保存在外部的
arg变量中,形成了闭包,影响了正常 GC。除了 console 相关方法之外,其他的浏览器方法比如fetch也被劫持,这应该就是 Bugsnag 监控的方式吧。
但是项目是不能弃用 BugSnag,所以只能想办法解决打印问题。而且相信 BugSnag 会妥善的处理保存变量的(应该)。通过向上寻找调用栈,发现这个报错是 Redux 抛出的(下面是用
Allocation sampling补了一张图,但是时间线分析也可以看到对应的调用)。加了一个断点(其实此时从console就可以看到了)之后是发现,报了这样的错:
Selector unknown returned a different result when called with the same parameters. This can lead to unnecessary rerenders
更深层的原因不赘述,原因是 在使用
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) // 如果有不同值之间的比较慎用这个,因为不同的值返回的空值状态可能一样
用正则匹配了一下代码,修复了问题,然后,将
configureStore的devTools设置成了false。。或者说这个问题其实也是因为这个配置导致的,Redux 会在开发模式下打印警告,而且默认是开发模式,需要手动根据当前环境设置开发模式。