最近公司项目longtask卡顿治理中,大部分卡点都在Recalculate Style耗时过长,经常各种500ms以上的longtask,甚至各种1s以上的longtask。虽然连猜都蒙的最后都治理了,但是理解的还是不够深入,还需要再进一步通过阅读chrome源码blink渲染引擎render部分,来找到最终的解释。
先介绍一下公司的项目特点以及卡顿时候的performance表现。
项目特点:
1.dom节点数比较多,差不多大几千甚至上万。
2.style标签多,差不多400~600之间。主要原因还是,内部各种业务npm包或者模块联邦,然后呢UI库的共享做的不太好,导致各种style标签。
3.style标签多了,css规则就多,差不多3w~5w之间。
卡顿时候的表现:Recalculate Style 耗时特别长,Elements affected 数目特别多。
卡顿时候的几个操作场景
1.rnd的拖拽组件,在拖动开始时跟结束时。拖动开始时候在body元素上添加了dragging的class
2.Spin组件,加载中跟加载完成时。加载时候给Spin对应的dom加了一个.semi-spin-hidden的class
3.浮层类组件popver,在body元素中append一个portial浮层div。
简单分析下:可以看到这几个卡顿的场景,要么是改了class,要么是改了dom,导致需要重新计算样式的节点数特别多,或者是说需要Recalculate Style的Elements affected特别多。而我们项目的css-rules数又是几万,这么多的节点需要重新跟css-rule逐个重新匹配下,那么耗时过长也是难免的了。而我这次要研究的就是Elements affected是怎么算出来的,为何这么多呢?我在一个div上新增或者删除了一个class,为什么会回流这么多节点呢?
chrome是通过失效集来标识需要回流哪些节点的,可以看一下chrome的源码中关于失效集的介绍 :
下面举几个简单的例子
示例1:
.a span{ color: red}
对应的失效集
.a { span}
解释:假如.a元素删除了class a的话,那么这么元素下的所有后代span元素都需要回流。
示例2:
.a .b span{color: red}
对应的失效集
.a { span}
.b { span}
解释:
.a元素删除了class a的话,那么这么元素下的所有后代span元素都需要回流。
.b元素删除了class a的话,那么.b元素下的所有后代span元素也都需要回流。
注意.a删除class后,需要回流的元素并不是 .a 后代然后.b后代的span元素,而是跟示例1一样.a的所有后代span元素。
可以看出,选择器的最右侧的选择器span 是失效集中要失效的元素,选择器前面的路径中的.a .b 作为失效集中的匹配部分。
示例3:
.a ~ .b {}
对应的失效集合
.a { *}
PS:如果出现相邻选择器,则直接将 wholeSubtreeInvalid 置为 true,即所有后代元素需要重新计算样式。不过是当前元素父元素的所有后代,或者说是当前元素的所有兄弟元素
示例4:
.a :not(.b) { }
对应的失效集合
.a { * }
PS::not选择器目前在失效集构建算法中被跳过。直接所有。
实验代码:上面几个小例子我用下面一份简单的小代码试验验证过了。大家可以自己改一下验证一些自己的想法。
<!DOCTYPE html>
<html>
<head></head>
<style>
div {
width: 100px;
height: 200px;
border: 1px solid green;
}
.a ~ .b{
width: 100px;
height: 200px;
border: 2px solid black;
}
</style>
<body>
<div>
<div id="test" class="a">
<div class="b"></div>
</div>
<div class="b"></div>
<div class="b"></div>
<div class="b"></div>
<div class="b"></div>
</div>
<script>
function test() {
longtask();
document.getElementById('test').classList.remove('a');
}
setTimeout(() => {
test();
}, 500)
function longtask(){
const tmp = [];
for (let i = 0; i < 10000 * 1000; i++) {
tmp.push(i)
}
}
</script>
</body>
</html>
回头再来看,我们这一个卡顿的场景:rnd拖拽组件卡顿
rnd在拖拽时,会给body添加一个react-draggable-transparent-selection,而结束时候则去掉了这个class。此class是为了控制body所有子元素在拖动过程中不能进行文字拖选的。
此段css规则为
.react-draggable-transparent-selection *::selection {all: inherbet}
根据前面说的失效集规则为
.react-draggable-transparent-selection { * }
所以在拖动开始跟结束时候,body下面的所有子元素都需要Recalculate Style。所以需要计算的元素特别多,而我们css规则也一样多的夸张,一段比较长的longtask就不可避免了。
总结:
1.我们的项目在回流时候,并不像各种八股文里提到的layout耗时长,而是Recalculate Style耗时长,感觉主要原因还是css规则太多了,几万条css规则,一旦需要回流的元素比较多,那么重新匹配就是M*N的关系,回流元素数*css规则数,所以还是要尽量减少css规则数,尤其这种微应用+各种内部npm包+模块联邦的项目。
2.css规则尽量要简单,匹配路径要尽量短,尽量减少使用兄弟选择器、not()、nth-child()这类的复杂选择器,这种选择如果blink引擎在构建失效集时做的优化并不彻底或者未做优化,会导致大量节点的回流。
项目中有这样一段css规则,有了这段css规则后,在某些场景下,任何浮层类操作(向body元素append一个div),都会导致回流很多节点。到现在也没搞明白为何会这样,最终只是去掉这段css规则,改用简单的class选择器来解决的。
div:nth-child(2)>div { margin: 2px 0; color: var(--color-text-0)}
3.chrome源码中的很多readme文件,比市面上的各种八股文要有深度的多,虽然源码读起来比较费劲,但是readme文件读一读是可以的。