昨天临近下班时,产品突然告诉我运营后台线上某页面进入就卡死了,一番操作没反应,稍后浏览器弹窗提示"该页面无响应",如下图所示:
性能统计
浏览器页面进入后卡死导致无法操作的原因有很多,如电脑配置过低(cpu和内存)、当前时间运行软件过多、当前浏览器主进程处理过多任务、当前页面渲染进程处理过多任务等原因。
既然知道发生时机了,那就复现下来确认问题的真伪及原因。首先通过chrome 浏览器的devtools performance的Start profiling and reload page按钮下来测下页面性能,具体性能统计截图如下:
性能解读
- 该图的最上方是
FPS信息,代表浏览器每秒渲染的帧数。绿条越高,FPS越高,用户感觉越流畅。红条代表FPS低于60,颜色越红则FPS越低。 - 在
FPS的下方是CPU信息,代表当前页面的CPU消耗的状态。当有CPU消耗时,会呈现出对应于底部面板Summary选项卡中的颜色。CPU条越高代表CPU消耗越大。从图中可以看到在页面加载后CPU消耗很快就被Scripting拉满,而Scripting代表了执行JavaScript。 - 在
CPU的下方是NET信息,代表了当前页面的网络活动。NET条存在则代表当前有网络活动。 - 在
NET的下方是HEAP信息,代表当前页面的JS HEAP,即JavaScript的堆的使用情况。从图中可以看到JS HEAP在页面3500ms处开始从21MB快速爬上到52MB,一直处于内存膨胀状态,超过了最佳页面速度所需的内存。 Main信息代表页面主线程上发生的活动,而页面主线程主要负责JS的计算与执行、CSS样式计算和布局计算、预绘制及提交等。从图中可以看到在页面3500ms开始处主线程在重复执行某逻辑,其所处时间段与高消耗CPU和高消耗JS HEAP相对应。再往下看发现合成器线程Compositor及子线程和其他一些线程池任务等未有明显动作。- 在
Summary选项卡上方显示一个新的内存图表,这是因为勾选了Memory后进行性能录制。在该图表有五个彩色复选框,分别是JS Heap、Documents``(document的数量)Nodes(操作的dom node的数量)Listeners(JavaScript侦听器的数量)GPU Memory(GPU的内存消耗情况)。
问题初步定位
以上可得知在性能记录时间内以下指标都居高不下:
JS的内存使用情况;- 操作的
dom node数量; GPU的内存使用情况;
上文看到了主线程中在重复执行某逻辑,尝试从事件调用栈出发,如下图(这里小编使用的构建工具是webpack,其devtool设置为false,但其实还是能根据经验判断出关键信息,请记得不要把关键业务数据硬编码在前端代码中):
element-ui 2.x级联组件(面板)的源码如下,上图中主线程中的调用栈顺序与组件源码中的事件调用顺序完全匹配,而这些调用的事件就是组件加载时的初始化内部store、渲染级联元素、更新勾选状态和多选节点路径等等。
原来主线程重复执行的逻辑是级联组件组件的加载和初始化,等等!!!怎么会重复初始化(监听的options只更新一次)?此时FPS也没有值?这不是卡顿的性能记录!但为什么又出现这个样子呢?还未得知。
重新性能统计
重新使用devtools performance的Record按钮来测试下性能,点击按钮后刷新页面,待页面可操作时停止统计。截图如下所示:
从上图可以看到在页面加载完成后(红线代表onload事件被触发),此后很快FPS条变红,而Frames条也提示有21341ms的空闲段,代表页面卡顿但无渲染。与此同时CPU消耗也被Scripting占满,JS HEAP也被消耗至62MB,仔细观察线程占用情况,发现主线程在该时间段拥有一个级联组件的20s的长任务,可基本判断出是级联组件的初始化配置逻辑长期执行占据了主线程和运行内存,导致浏览器无暇处理用户体验,这也就出现了文章顶部的问题截图。
问题定位
经过对比数据量发现,级联组件的初始化配置长期执行的原因是单个节点的直属子节点数据过多(问题出现的数据量是5000+)。
方案调研
- 向
elemeng-ui组件提优化需求; - 使用
虚拟滚动技术重写级联组件; - 优化数据的加载方式;
其实在大量数据面前,即使虚拟滚动也难以保障正常加载数据。最优雅的方案是
分块获取数据,然后在视图组件中使用虚拟滚动技术渲染数据。 - 分块获取数据,即常见的限制查询数据的数量;
- 虚拟滚动技术,即大批量的数据展示支持以滚动形式查看,但它强调借用浏览器的原生滚动在功能组件的视口附近动态渲染合理的数据量,渲染出的元素或组件在脱离视口时适当丢弃;
方案实现
由于当前问题场景中的数据是组织架构内的员工,在现有功能实现基础上使用数据懒加载即可(数据大小1.5MB)。由于接口中数据是一次返回,需要在组件渲染层面进行数据懒加载,具体修改如下:
- 对
element cascader组件剔除options属性,并添加lazy和lazyLoad; - 将返回的树形结构数据进行格式转换,将其所有节点转换为一维数组,方便数据懒加载时减少算法复杂度;
// Vue SFC
{
data() {
return {
/* others */
optionsArray: [], // 一维数组形式的组织架构数据
}
},
methods: {
/**
* 获取组织架构数据
* @returns {Promise<true>}
*/
getData() {
return new Promise((resolve, reject) => {
/*
...
异步获取组织架构数据并调用formatData2Array进行格式转换
...
*/
}
},
/**
* 将树形组织架构数据转换为一维数组格式的数据,方便数据懒加载时减少算法复杂度。
* @param {Array} data 当前节点数据列表
* @param {string | number} pid 当前节点数据列表的父级节点的ID
* @returns {undefined}
*/
formatData2Array(data, pid) {
for (const item of data) {
// 将所有节点数据重新组装,因为要懒加载暂时排除children字段
this.optionsArray.push({leaf: !item.children, pid, ...omi(item, 'children') })
// 递归处理子节点列表
if (item.children) {
this.formatData2Array(item.children, item.value);
}
}
},
/* others */
}
}
- 当第一次加载或点击/滑动某节点时,
lazyLoad()被触发,在lazyload()中动态获取该节点下的直属子节点;
// Vue SFC
{
methods: {
/**
* 数据懒加载,获取son,而不是grandson。
* @param {CascaderNode} node 当前操作节点或根节点
* @param {Function} resolve 数据加载完成的回调(必须调用)
* @returns {undefined}
*/
async lazyLoad(node, resolve) {
try {
// 当组件初始化时lazyLoad()可能会早于数据获取前执行,这里要保证组织架构数据获取完成并完成格式化。
if (!this.optionsArray.length) {
await this.getData();
}
// 注意运算符优先级,===的优先级为10,条件运算符的优先级为4,===要优先执行。
const data = this.optionsArray.filter(
item => item.pid === (node.root ? "root" : node.value)
);
resolve(data);
} catch (error) {
this.$message(error?.msg ?? '默认错误提示');
}
}
/* others */
}