渲染优化
不管我们是写的Css样式代码还是JavaScript脚本代码,最后都是靠浏览器渲染出来的,要想浏览器渲染的更快更稳点,针对渲染优化是必不可少的。
浏览器如何渲染页面
浏览器将域名通过网络通信从服务器拿到html文件后,将会执行以下操作:
构建DOM树及CSSOM树
根据html文件构建DOM树和CSSOM树。构建DOM树期间,如果遇到JS,阻塞DOM树及CSSOM树的构建,优先加载JS文件,加载完毕,再继续构建DOM树及CSSOM树。
构建DOM树
HTML 文档中的所有内容皆是节点,各节点之间拥有层级关系,如父子关系、兄弟关系等,彼此相连,构成DOM树。最常见的几种节点有:文档节点、元素节点、文本节点、属性节点、注释节点。
DOM节点树中节点与HTML文档中内容一一对应,DOM树构建过程:读取html文档,将字节转换成字符,确定tokens(标签),再将tokens转换成节点,以节点构建 DOM 树。如下图所示:
构建CSSOM树
CSS文档中,所有元素皆是节点,与HTML文件中的标签节点一一对应。CSS中各节点之间同样拥有层级关系,如父子关系、兄弟关系等,彼此相连,构成CSSOM树。
在构建DOM树的过程中,在 HTML 文档的 head 标签中遇到 link 标签,该标签引用了一个外部CSS样式表。由于预见到需要利用该CSS资源来渲染页面,浏览器会立即发出对该CSS资源的请求,并进行CSSDOM树的构建。
CSSOM树构建过程与DOM树构建流程一致:读取CSS文档,将字节转换成字符,确定tokens(标签),再将tokens转换成节点,以节点构建 CSSOM 树。如下图所示:
构建渲染树
渲染树(Render Tree)由DOM树、CSSOM树合并而成,但并不是必须等DOM树及CSSOM树加载完成后才开始合并构建渲染树。三者的构建并无先后条件,亦非完全独立,而是会有交叉,并行构建。因此会形成一边加载,一边解析,一边渲染的工作现象。
构建渲染树,根据渲染树计算每个可见元素的布局(Layout),并输出到绘制流程(Paint),将像素发送给GPU,渲染展示到屏幕上。
关键渲染路径 (Critical Rendering Path)
现代浏览器的渲染流程大致如下:
JavaScript->Style->Layout->Paint->Composite
- JavaScript : 一切能改变元素相关属性发生变化的操作,例如脚本修改元素的宽高,或者是animation
- Style:计算样式
- Layout:根据生成的渲染树,计算他们在设备视口(viewport)的确切位置和大小。
- Paint:根据渲染树和Layout阶段得到的几何信息,绘制额外的属性(比如color,box-shadow等等),得到节点绝对像素。
- Composite:复合图层(类似于PS的图层一样,合成输出)。
现在可以得知现代浏览器到底是如何渲染HTML文件和中间经历了哪些过程。知道了这些我们就可以针对性的作出渲染优化。
重排和重绘
什么是重排和重绘,在面试的时候经常会被面试官所问到,简单地介绍以下:
重排(reflow):也有称回流,当渲染树节点发生改变,影响了节点的几何属性(如宽、高、内边距、外边距、或是float、position、display:none;等等),导致节点位置发生变化,此时触发浏览器重排(reflow),需要重新生成渲染树。譬如JS为某个p标签节点添加新的样式:“display:none;”。导致该p标签被隐藏起来,该p标签之后的所有节点位置都会发生改变。此时浏览器需要重新生成渲染树,重新布局,即重排(reflow)。
重绘(repaint):屏幕的一部分要重新绘制。渲染树节点发生改变,但不影响该节点在页面当中的空间位置及大小。譬如某个div标签节点的背景颜色、字体颜色等等发生改变,但是该div标签节点的宽、高、内外边距并不发生变化,此时触发浏览器重绘(repaint)。
可以得出重排对应的渲染阶段就是Layout,而重绘对应的渲染阶段就是Paint。两者的关系:重排必将引起重绘,而重绘不一定会引起重排。
看一个简单的例子:
.root{
width: 100px;
height: 200px;
background-color: red;
position: relative;
left: 0;
top: 0;
}
.bgY{
background-color: yellow;
}
.t200{
top: 200px;
}
.y200{
transform: translateY(200px);
}
import { useState } from 'react';
import './Home.css';
export default () => {
const [bg, setBg] = useState(false);
const [t, setT] = useState(false);
const [y, setY] = useState(false);
let classS = '';
if (bg) {
classS = `${classS} bgY`;
}
if (t) {
classS = `${classS} t200`;
}
if (y) {
classS = `${classS} y200`;
}
return (
<>
<div className={`root ${classS}`}>
</div>
<button onClick={() => setBg(true)}>改变背景颜色</button>
<button onClick={() => setT(true)}>改变top</button>
<button onClick={() => setY(true)}>改变Transform</button>
</>
)
}
初始加载效果:
通过Performance查看加载的时候渲染经历了哪些阶段
点击改变背景颜色看看经历了那几个阶段:
可以看出来没有经历Layout阶段,也就是没有引起重排。
点击改变宽度按钮看看经历了哪几个阶段:
看看点击改变Transform:
很明显没有Layout阶段,即没有进行重排,这里实现了跟改变元素的top值,以改变元素的位置的一样操作,但是使用transfrom没有进行重排,加快了渲染。
优化重排
现在知道了重排会经历浏览器渲染的多个阶段,所以日常开发中要尽可能避免或者减少重排操作。首先就就必须要知道哪些操作会引起重排。
- 添加/删除元素
- 操作相关styles:边距、填充、边框、宽度和高度等
- 读取DOM元素的相关位置信息:offsetTop,scrollTop,clientWidth等
- 移动元素位置:display、float、position、overflow等
- 修改浏览器大小,字体大小:resize事件发生时等
- 内容改变:比如文本改变或者图片大小改变而引起的计算值宽度和高度改变等。
一个节点的重排很有可能导致子节点,甚至父节点以及兄弟节点的重绘。在一些高性能的电脑上也许还没什么,但是如果重绘制发生在手机上,那么这个过程是延慢加载和耗电的。重排带来的问题之一就是布局抖动(layout thrashing)
解决方案:
- 直接改变className,如果动态改变样式,则使用cssText(考虑没有优化的浏览器);
- 让要操作的元素进行”离线处理”,处理完后一起更新;
a) 使用DocumentFragment进行缓存操作,引发一次回流和重绘;
b) 使用display:none技术,只引发两次回流和重绘;
c) 使用cloneNode(true or false) 和 replaceChild 技术,引发一次回流和重绘; - 不要经常访问会引起浏览器flush队列的属性(offsetTop,scrollTop,clientWidth等),如果你确实要访问,利用缓存。
- 让元素脱离动画流,减少回流的渲染树的规模;
- 改变元素的位置信息和尺寸等属性,尽可能使用transform来完成。
- 对于需要读取3中介绍的属性来完成相关动画的操作,请保持DOM元素操作的读写分离。推荐使用fastdom。
优化重绘
对于重绘的避免和减少,实际上就是针对重排的优化。设想一下,鼠标放上元素,元素显示高亮这种操作,是肯定会重绘的,基本上除了重排引起的重绘外,都是不可避免的。一句话,搞定重排的问题就好了。
复合线程(conpositor thread)和图层(layers)
在渲染的时候,会有一个复合操作:
这个CompositeLayers操作就是用复合线程来操作的,复合线程所做的事情:将页面拆分图层绘制在进行复合。
所以对DOM元素的相关操作最好弄在不同的图层上进行操作,这样就可以避免不必要的重排和重绘,优化渲染。影响复合的样式操作如下:
- position的方位值操作由transform:translate操作来代替
- 元素的尺寸操作由transform:scale操作来代替
- 旋转操作由transform:rotate操作来代替
- 使用opactiy代替display,visibility操作
通过Chrome Devtools我们可以查看网站的图层分布情况:
通过css设置will-change属性可以新增图层:
新增图层会优化渲染,但是请不要过度使用,毕竟新建图层也是有性能消耗的。
高频事件处理函数(防抖)
注意这里只是防抖,不要去想节流的操作。对于scroll,touchMove,mouseMove等高频事件,在日常开发中是经常使用到的。通常对于这些操作我们都需要进行防抖,网上关于防抖的介绍一大推,这里不做过多介绍,只介绍几个场景,帮助我们合理使用哪一种防抖操作。
- setTimeout:对于执行时间的精确度不太高的场景(页面切换会继续执行)
- requestAnimationFrame:浏览器每一帧执行一次(页面切换会停止执行-会在layout和paint之前执行,考虑兼容性的话,需要使用setTimeout进行polyfill,)
- requestidlecallback (浏览器每一帧任务执行完成以后,还有空闲时间-IDLE,就执行,兼容性不太行,可以参考React源码中使用的scheduler模块进行polyfill)