getComputedStyle 方法与性能问题
前言
最近老是在项目中发现一些由getComputedStyle方法引起的性能问题,无非就是一不小心引起了大量的样式计算(Recalculate Style)和重排(Layout),从而引起了线程阻塞,造成页面卡顿;
getComputedStyle 方法的原理
getComputedStyle方法(以下简称为gCS)的语法就不多说了,可以移步:Window.getComputedStyle() - Web API 接口参考 | MDN;
其实疑惑最大的地方就在于为何gCS方法获取的计算样式一定会触发新的样式计算甚至是布局计算呢?熟悉浏览器渲染管线的人应该都清楚,样式计算环节处于构建DOM tree和构建layout tree之间,按理说绘制当前帧时每个DOM结点的计算样式(Computed Style)应该是已知的,为何不能从已经计算好的计算样式里面取值呢?

以上疑惑的答案就在规范里:
Return a live CSS declaration block with the following properties[1]
关键就在于这个live,即实时;由于其设计目的就是要返回一个实时的CSS声明块(实际上是一个只读的CSSStyleDeclaration对象),而执行gCS的当前并不能保证之前的计算样式一定和当前最新的计算样式是一致的,所以在获取相应属性的最新计算值时不得不重新计算样式(Recalculate Style),而某些CSS属性受到布局的影响也就不得不重新计算布局;
Recalculate Style 的细节
-
单纯地调用
gCS方法并不会引起任何的Recalculate Style和重排;1
2
3
4
5
6
7<template id="test" rule="v-">
<div id="app">
<ul v-for="ul in list">
<li v-for="item in list">{{ item }}</li>
</ul>
</div>
</template>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16const num = 100
const list = new Array(num).fill(0).map((val, idx) => idx + 1)
const res = test.render({
list
})
document.body.appendChild(res.content) // 渲染100个相对复杂的DOM
const app = document.getElementById('app')
const doms = app.querySelectorAll('ul')
function relayoutTest (el) {
const style = getComputedStyle(el) // 单纯地触发gCS
}
console.time('getComputedStyle')
for(let i = 0; i < num; i++) {
relayoutTest(doms[i])
}
console.timeEnd('getComputedStyle') // 计算同步执行时间

通过上述测试可以看到,单纯地调用
gCS方法并没有产生样式计算和重排,仅仅是一个普通的函数调用开销; -
只有通过
gCS方法返回的CSSStyleDeclaration对象访问某个具体的属性值时才会同步地引起进一步Recalculate Style或重排;1
2
3
4function relayoutTest (el) {
const style = getComputedStyle(el)
return style.height // 仅仅是访问height属性
}

可以看到,同样的测试条件,仅仅是进一步访问了
CSSStyleDeclaration对象的height属性,就会立即触发重排并引发了一定时间的线程阻塞; -
至于说访问属性会引起重排还是普通的重新计算样式,这里推荐一篇博文的总结[2]:

一个案例
最近发现element-plus里面的dialog组件在某些场景下(子结点数量多时)存在着严重的卡顿,仔细一看源码才发现是一处很典型的引发重排和样式计算的使用:

好家伙,一行代码出现了两个问题点,gCS方法的问题就不用多说了,然而访问这个offsetParent属性也会引起重排;要命的是,这行代码对于dialog组件内的几乎所有子结点会调用该方法,所以子结点一多就不可避免地卡顿,然而源码上也注释了这行代码存在潜在的风险,需要一些优化建议;
所以这就引申出一个问题:对于gCS方法有没有更高效的使用和优化方法?
优化思考
计算样式DOM化
所谓地计算样式DOM化,就是将每次DOM结点的计算样式结果存放到DOM接口中,而不再需要通过类似gCS方法来强制计算或重排,相当于是将最新的计算样式缓存到DOM上,只有真正的重排、重绘或组合才会引起计算样式地改变;
可以思考一下我们使用gCS方法的一些场景,很多时候我们之所以用gCS方法来获取样式属性值,是因为DOM的style属性只会返回当前节点显式声明的样式属性(即通过样式文件,样式声明或内联样式所匹配到的样式),所以不得不用gCS方法来获取那些默认样式的值;很明显,这种情况下并不符合规范中所预想的gCS方法使用背景(即由于某些操作导致样式发生变化,且想要立即同步地获取到最新的计算样式),我们明明只是想要获取一些压根就没有变化的计算样式,为何还要强制地去重排或样式计算;
目前还不清楚各大浏览器引擎内部有没有缓存最新的计算样式,不过确实没有看到W3C有相关的规范从已有的计算样式中获取值的方法;考虑到缓存所有DOM的计算样式确实需要一定的内存开销,毕竟现在计算样式的属性数量已经高达300+了,所以不知道这种内存开销是否在可接受范围内;

fastdom
这是一个专门用来批量读/写DOM属性的库,用于解决读/写DOM属性所带来布局抖动或其它类似的开销;
其原理很简单,代码也很少,推荐直接阅读源码;fastdom通过将读和写任务分别存到不同的任务队列中,并通过rAF(requestAnimationFrame)函数在下一帧执行相应的任务,且先批量执行读任务再批量执行写任务(这个顺序很重要);

很明显,这种操作会把DOM读写操作改成异步任务(其实也就是下一帧),而不会阻塞当前帧的操作;不过如果批量的读写次数过多时,在下一帧执行时仍然会造成线程阻塞……
减少同时操作的 DOM 数量
在目前的条件下,如果同时使用gCS方法影响DOM的数量过多(100+,其实还跟DOM所影响的范围有关),会不可避免地阻塞到线程;哪怕是通过fastdom那种通过将操作异步化,其阻塞无非只是被延后,在这种情况下解决不了本质的问题;
结合 style 和默认属性值
上面也提到过:
很多时候我们之所以用
gCS方法来获取样式属性值,是因为DOM的style属性只会返回当前节点显式声明的样式属性(即通过样式文件,样式声明或内联样式所匹配到的样式),所以不得不用gCS方法来获取那些默认样式的值;
这不就是提供了一种思路吗?既然那些默认样式style属性上并不会有,但是默认样式的属性值规范中是有规定的(即已知的),不过考虑到由于某些样式可以继承,所以对于那些不可继承的CSS属性,如果style属性得到的相应属性值为空,那不就是代表其计算属性就是默认值!
以上面的element-plus提到的问题为例,其通过gCS方法来访问position属性,由规范[3]可知,这个属性正好是不可继承的属性!

所以之前的代码完全可以优化为:
1 | export const isVisible = (element: HTMLElement) => { |
可以通过测试对比一下两种方式获取position属性值的时间消耗:
-
gCS:1
2
3
4function relayoutTest (el) {
const style = getComputedStyle(el)
return style.position
}
-
style+ 默认值:1
2
3function relayoutTest (el) {
return el.style.position || 'static'
}
显然,如果遇到其他的不可继承属性,也可以尝试用这种方式来优化性能;
相关文档
- 为什么getComputedStyle挂在了window上而不是Element.prototype? - 贺师俊的回答 - 知乎
- 【CSS进阶】原生JS getComputedStyle等方法解析 - 程序猿的生活的文章 - 知乎
- What forces layout/reflow. The comprehensive list.:引起重排的行为,包括了getComputedStyle方法引起重排的几种情况。
- Window.getComputedStyle() - Web API 接口参考 | MDN
- https://blog.lisong.hn.cn/前端优化/2018/06/05/前端性能优化之-force-reflow/:里面有关于getComputedStyle耗时的测试
- 获取元素CSS值之getComputedStyle方法熟悉 « 张鑫旭-鑫空间-鑫生活
- Avoid Large, Complex Layouts and Layout Thrashing | Web Fundamentals:一篇关于布局抖动优化的文章
- 使用 fastdom 解决布局抖动 | Nx-website-component