有一天体验公司项目,录制performance时候,发现一段预期之外longtask,是一个筛选项,使用ui库的treeSelect组件渲染的(大体样式如下面截图),点这个筛选项在其进行渲染时有一段比较夸张的longtask,耗时高达600ms左右。我的电脑配置是16核32G的M2芯片,在我电脑上600ms,按照经验在用户电脑上耗时得接近乘3,那得1.5s左右的耗时了,明显影响使用体验了,需要解决掉这个问题。
一:问题分析
- 对于一个treeSelect组件,渲染耗时较长,大概率就是因为数据太多了。所以首先我看了下接口返回的筛选项数据,确实是一个比较大的json。接口返回的数据中此筛选项高达900kb(解压缩后的大小)。初步猜测得到确认。
- 我将接口数据mock到本地,然后本地performance录制。mock到本地是因为本地开发由于有sourcemap信息,可以更清楚的看调用栈的文件信息跟函数信息。经过观察performance,发现调用栈中的treeSelect组件下的getDerivedState方法中的needUpdate方法中的isEqual极其耗时,光isEqual部分耗时就400ms左右。此处代码应该是用来判断组件render时,当前props传进来的treeData跟上一次的treeData相比数据是否发生变化,如果数据发生变化了才会reder。如果treeData是一个相当大的对象,那么在进行比对时候就会层层递归比对。看调用栈底部的调用情况,也会发现各种equal相关的递归调用。
- 至此问题基本确认,看起来是大对象在使用lodash的isEqual来进行深度对比时候,耗时较多。
- 可是问题基本定位到之后,我的疑问来了。isEqual真的性能这么差吗?一个900k大小的json,比对在m2的cpu下需要400ms+?不太相信。然后把mock的数据拿出来,简单写了个demo,只使用lodash的isEqual进行两个大对象的比较,果然发现只有20ms左右。为什么在我们这就需要400ms了呢?需要进一步找到这两个场景的不同点。
5. 开始找不同,在我们项目里通过在isEqual处打断点发现,传过来的treeData是经过mobx包装后的带有proxy的对象,而我们写的demo验证里,用的却是用的未经过proxy代理的对象。这是一个很重要的差异,尤其在大对象递归取值的时候。 6. 将对象改为mobx的非可观察对象后,isEqual的耗时直接减半了。从400ms+减少到200ms。 7. 可是200ms依然离着demo里的20ms相差甚远啊。只能看treeSelect的源码了,getDerivedState部分,搜了一下关键词needUpdate('treeData'),这段是用来比较treedata是否发生变化的,发现在getDerivedState里居然调用了三次needUpdate('treeData'),理论上只需要调用一次将结果放到一个变量里,下次直接使用此结果就可以了。 8. 直接在node_modules里的改treeSelect的源码,声明一个变量将needUpdate('treeData')的比对结果存储下来,后续直接使用此结果信息。重新运行longtask这次终于降下来了。此段整体400ms的longtask降到50ms左右了,其中isEqual部分也在20ms左右了,符合预期了。
二:问题解决
此段lontask的由来主要是三个原因:
- 是一个大对象
- 这个大对象经过mobx包装,给对象递归的加了层层代理proxy。取值时候自然多一层。
- ui库的treeSelect组件里在进行treeData的isEqual比较时候,三次调用。
针对以上问题,解决办法也比较简单了:
- 将此筛选项的大对象,设置为不可观察,去掉proxy。
- 组件库对treeSelect组件进行修复,将三次isEqual的调用改为一次调用即可。
三:额外疑问
- 在needUpdate函数里,明明调用了三次isEqual,可是为什么看performance调用栈里只有一个isEqual呢,理论上应该是并排的三个isEqual调用栈才对。而实际performance录屏中。也确实偶然出现过三个isEqual并排的情况。这是什么原因呢。两次performance的录制不同如下。图一是三个isEqual并排,每个耗时大约140ms,图二是只有一个isEqual耗时大约450ms。
2. 如果调用栈信息能保持预期内的三个isEqual并排,我是不是就早就轻易发现isEqual调用了三次的问题啊?这样对于问题排查造成了干扰。 3. 针对此疑问,我在问题解决后找时间搜了一些相关"火焰图生成原理"的文章,得到一个感觉相对合理的答案,能解释的通三个isEqual合并成一个的情况。大概率是由于火焰图数据的采样频率引起的,采样频率一般以赫兹为单位,比如采样频率1000HZ,代表一秒钟采样1000次。假如当前函数中其他的指令的执行时间太短,在执行过程中并没有命中采样,则相当于没有此段数据,则会出现调用栈合并的情况。我自己也写了一个demo验证了下此情况。
bash
3-1:test函数中,先后调用了longtask,shorttask,longtask,如果shorttask执行时间比较短,执行中恰好cpu没进行采样,那么就会出现两段longtask合并为一段的情况。
3-2:如果shorttask的执行时间稍长,尽量让其执行时候肯定会被cpu采样到,那么两段longtask就会被分开了。如下图。
js
<div>
<button onclick="test()">点我</button>
</div>
<script>
function longtask () {
const arr = [];
for (let i = 0; i < 1000 * 10000; i++) {
arr.push(i);
}
}
function shorttask () {
const arr = [];
for (let i = 0; i < 10 * 10; i++) {
arr.push(i);
}
}
function test() {
longtask();
shorttask();
longtask();
}
</script>