背景
我们公司的内部系统中,有一个配置组件(如图所示),功能为实现左边勾选节点,右边显示所选节点的交互列表(这里和下面都做了字段脱敏处理,所以看起来没什么意义,但功能是一样的)。
上线后,一直风平浪静,但是有一天,测试数据库被导入了一批1.8万的数据,然后导致这个组件的所在页面出现了下面的性能问题。
- 进入组件所在页面,初始化严重卡顿;
- 勾选一个节点要等上十几二十秒;
- 全选或取消全选甚至等上一分多钟;
通过Chrome Performance 定位性能问题
录制
- 打开Chrome控制台,点击
performance标签(下图所示),备用; - 复现会引起页面性能问题的动作;
- 点击左上角的录制按钮,开始录制;
- 录制时间取决于你的页面需要监控的时间段
- 停止录制,会出现一个十分复杂的页面。
定位耗时代码
这里录制了列表点击全选时的时间段(如下图所示)。首先这里做个说明:performance可以分析内存、CPU、FPS等好多参数,我们这里主要是页面等待太久了,而这个取决于FPS(页面每秒帧数,fps < 24 会让用户感觉到卡顿,因为人眼的识别主要是24帧)。
我们按着图上序号顺序解释:
FPS区域出现了红条,意味着帧数已经下降到影响用户体验的程度,chrome已经帮你标注了这块是有问题;Main区域展示的是火焰图,也就是函数调用的堆栈(火焰图,可以简单理解,x轴表示时间,y轴表示调用的函数,函数中还包含依次调用的函数,y轴只占用x轴的一个时间维度),点击灰色条会出现图中3的红色区域。Summary区域显示了Warning,长时间任务耗时了1.6min,其中脚本Scripting占绝大部分耗时。
这时,我们已经知道有一个耗时很长的任务了,我们继续深究到底是哪一些代码造成的:
- 我们在函数栈从上往下找(如下图所示),找到该函数占用的时间(Self Time)很大时,问题就出来这里了,Chrome还标出是哪个文件出来。
- 点击耗时文件,Chrome会为我们标出耗时长的代码(下图所示)
分析与优化
通过Chrome,我们已经定位出问题代码了,接下来就是分析问题的原因了。
这里列出有性能问题的代码块:
...
computed: {
treeData () {
const tree = this.formmatData(this.data);
return tree;
},
...
},
methods: {
formmatData (origin, rootId = 0, result = {}) {
// 1.找出根节点数组
const rootNode = origin.filter(node => node.fatherModuleId === rootId);
if (rootNode.length > 0) {
// 2.对根节点数组进行遍历赋值,并放到传入result的children中
result.children = rootNode.map(node => ({
title: node.moduleName,
id: node.moduleId,
checked: origin.filter(n => n.fatherModuleId === node.moduleId).length < 1 && this.checked.includes(node.moduleId),
expand: true,
disableCheckbox: !this.editable || this.disabledList.includes(node.moduleId),
}));
//3. 遍历根节点数组,递归调用该方法找下一级的节点
result.children.forEach((child) => {
this.formmatData(origin, child.id, child);
});
}
// 4.返回当前的result的孩子
return result.children;
},
...
}
其中this.data数据格式为:
[
{
"moduleId": 1,
"moduleName": "节点1",
"fatherModuleId": 0
},
{
"moduleId": 2,
"moduleName": "节点2",
"fatherModuleId": 0
},
{
"moduleId": 3,
"moduleName": "节点3",
"fatherModuleId": 0
},
...
]
这里先说明一下,该段代码做了什么事情吧:
- 首先,数据
data包含三个字段moduleId、fatherModuleId和moduleName,其中moduleId是模块ID,fatherModuleId是模块上一级的ID。 - 其次,通过
fromData方法计算出treeData,而formmatData做的就是递归调用,构造出树状数据。
但是,就是因为里面出现了递归,循环的嵌套导致了性能问题,通过结合业务,优化做了以下操作:
- 减少循环嵌套,
- 增加条件
- 减少循环次数
- 利用
set来优化查找
优化后的代码:
computed: {
checkedSet () {
return new Set(this.checked);
},
...
}
methods: {
formmatData (origin, rootId = 0, result = {}) {
const otherNode = [];
result.children = [];
// 1. 整合原本代码的过滤和遍历赋值,减少一次循环
for (const node of origin) {
if (node.fatherModuleId === rootId) {
result.children.push({
title: node.moduleName,
id: node.moduleId,
checked: this.checkedSet.has(node.moduleId), // 利用`set`来优化查找
expand: true,
disableCheckbox: !this.editable || this.disabledList.includes(node.moduleId),
});
} else {
otherNode.push(node);
}
}
// 2.在剩下的节点去查找子节点,而不是重复在全部数据中查找,因为当前的节点数组已经找到
if (otherNode.length > 0) {
result.children.forEach((child) => {
if (child.checked) {
// 3.利用find来代替filter
child.checked = !otherNode.find(n => n.fatherModuleId === child.id);
}
this.formmatData(otherNode, child.id, child);
});
}
return result.children;
},
...
}
效果
经过一番定位耗时问题,优化代码逻辑后,前后时间对比如下:
总结
Chrome Performance是个很好的定位问题利器,方便快速找到问题代码;- 平时写代码时,特别循环递归,如果考虑往后会有数据大的情况,就尽量用少的循环去完成功能;
- ES6的新数据类型
set在查找方面,数据量很大时性能大幅高于数组,推荐使用。