渲染流程
一个页面呈现通过浏览器呈现到出来,会经过以下步骤
- 解析页面内容
- 构建DOM树
- 构建CSSOM树
- 合并DOM树和CSSOM树,生成Render-Tree(渲染树)
- 基于当前的viewport计算出每个元素的位置和尺寸等几何信息(Layout)
- 将元素信息转换成屏幕上的像素(Painting)
- 显示(Display)
构建DOM树
DOM树是对html文档结构的描述,它存储了html文档标签的属性和关系,每一个html标签都会存在一个对应的DOM元素,元素的嵌套层级决定了他们的上下层关系,最终所有DOM元素会构成一颗DOM树。
以下是一个简单的html页面:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="style.css" rel="stylesheet">
<title>Critical Path</title>
</head>
<body>
<p>Hello <span>web performance</span> students!</p>
<div><img src="awesome-photo.jpg"></div>
</body>
</html>
它的构造过程如下:
- 编码:浏览器处理接收到的HTML原始字节数据,根据指定的编码将字节转换成字符(Bytes —> Characters)。
- 提取标签:浏览器根据W3C HTML5 标准从第一步中得到的字符串中提取出各种html标签,例如,“<html>”、“<body>”,以及其他尖括号内的字符串。每个令牌都具有特殊含义和一组规则(Characters —> Tokens)。
- 词法分析: 根据第二部中得到的标签,根据html元素的语言、规则、属性,将其转换成一个个元素对象。
- DOM构建: 步骤三中创建的每一个对象都会链接在一个数据结构内,该结构会捕获原始标记中定义的父项-子项关系,如:HTML 对象是 body 对象的父项,body 是 paragraph 对象的父项,依此类推。利用链接结构,构建出DOM树
CSSOM树
CSS定义了html文档可以应用的样式表,CSSOM树存储了CSS的对象模型结构。在浏览器为页面的html元素计算样式时,可以通过遍历CSSOM来查找匹配的样式表。
以以下CSS为例:
body { font-size: 16px }
p { font-weight: bold }
span { color: red }
p span { display: none }
img { float: right }
CSSOM树产生的过程与DOM树的构建过程类似,具体如下:
浏览器会根据CSS的语法标准,解析CSS文件,并生成相应的对象,最后构成CSSOM树,上面的CSS最终构成的CSSOM树如下:
body是所有显示元素的根节点,它定义了font-size:16px
,由于所有的元素都是body的子项,所以他们会继承body的样式(CSS的级联规则)。
注意:
以上树并非完整的 CSSOM 树,它只显示了我们决定在样式表中替换的样式。每个浏览器都提供一组默认样式(也称为“User Agent 样式”),即我们不提供任何自定义样式时所看到的样式,我们的样式只是替换这些默认样式(例如默认 IE 样式)。
构建渲染树
渲染树通过合并DOM和CSSOM得到,它只包含渲染网页所需的(需要显示的)节点,以上面的DOM树和CSSOM树为例,他们合并后如下:
具体步骤如下:
-
从 DOM 树的根节点开始遍历每个可见节点。
- 某些节点不可见(例如脚本标记、元标记等),因为它们不会体现在渲染输出中,所以会被忽略。
-
对于每个可见节点,为其找到适配的 CSSOM 规则并应用它们。
- 如果一个DOM节点通过 CSS进行了隐藏,那么它在渲染树中也会被忽略,例如,上例中的p标签下的span 节点就不会出现在渲染树中,因为有一个显式规则在该节点上设置了“display: none”属性(visible属性不会)
Note:
visibility: hidden
与display: none
是不一样的。前者隐藏元素,但元素仍占据着布局空间(即将其渲染成一个空框),而后者 (display: none
) 将元素从渲染树中完全移除,元素既不可见,也不是布局的组成部分。 生成渲染树,得到最终要显示的所有元素和它的样式。
注意:
只有同时具有 DOM 和 CSSOM 才能开始构建渲染树
布局
布局阶段会根据渲染树中得到的元素及其样式信息去计算元素在当前设备上的具体的大小、位置,所有相对测量值都转换为屏幕上的绝对像素。
以下面的代码为例:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Critial Path: Hello world!</title>
</head>
<body>
<div style="width: 50%">
<div style="width: 50%">Hello world!</div>
</div>
</body>
</html>
head中的meta标签声明了当前页面的宽度应该与设备宽度相等width=device-width
,不进行缩放initial-scal=1
. Body 下的第一个div采用默认布局,它的宽度为其父容器的50%,它下面的div同样采用默认布局,宽度又为它父亲节点的50%。
假如当前设备的宽度为320px,则第一个div的宽度就是160px,第二个div的宽度就是80px,最终效果如下
绘制
在这个阶段,浏览器会根据在Layout阶段计算出的所有节点的几何信息(大小、位置),将其绘制到屏幕。
至此,页面已经可以在屏幕显示出来。
DOM & CSSOM & JS
浏览器将不会渲染任何已处理的内容,直至 CSSOM 构建完毕
同时具有 DOM 和 CSSOM 才能构建渲染树
当浏览器遇到一个 script 标记时,DOM 构建将暂停,直至脚本完成执行
如果浏览器尚未完成 CSSOM 的下载和构建,此时有Javscript脚本需要运行,浏览器将延迟脚本执行和 DOM 构建,直至其完成 CSSOM 的下载和构建
图像不会阻止页面的首次渲染
下面是一个具体的例子:
1# <!DOCTYPE html>
2# <html>
3# <head>
4# <meta name="viewport" content="width=device-width,initial-scale=1">
5# <link href="style.css" rel="stylesheet">
6# <title>Critical Path: Script External</title>
7# </head>
8# <body>
9# <p>Hello <span>web performance</span> students!</p>
10# <div><img src="awesome-photo.jpg"></div>
11# <script src="app.js"></script>
12# </body>
13# </html>
在这个例子中,整个页面的加载流程如下:
- 浏览器解析html文档(编码—>提取标签—>词法分析)
- 然后开始构建DOM
- 第5行,发现link标签,开始加载CSS文件并创建CSSOM
- 第11行,发现script标签,开始加载外部脚本,阻塞DOM的构建
- 脚本加载完毕,开始执行脚本,此时如果
- CSSOM已经构建完毕,则直接执行脚本
- CSSOM并没有构建完毕,则等待CSSOM构建完毕,然后执行脚本
- 脚本执行完毕,继续DOM构建
- 利用构建好的DOM+CSSOM,穿件渲染树
- 绘制并显示
渲染过程性能优化
优化关键渲染路径
关键渲染路径指的是 HTML 标记、CSS 和 JavaScript,图像不会阻止页面的首次渲染。通过优化关键渲染路径,可以缩短首次渲染页面的时间。
可以通过以下维度进行优化:
-
减少资源数量、字节大小。
如通过压缩css和js文件来减小他们的字节数,通过合并文件来减少http请求的数量,以此来提高资源加载的速度
-
尽早加载CSS资源
由于CSSOM会阻塞渲染,只有DOM和CSSOM加载完毕后渲染才开始执行,因此应该尽量早的加载CSS资源
-
利用media属性声明CSS的使用场景
<link href="style.css" rel="stylesheet" media="orientation:landscape">
上面的例子中,声明了media的值为
orientation:portrait
,则这个style.css文件只有在横屏状况下才会使用,其他状况都不会阻塞渲染。关于media的更多资料,可以查看这里
-
将脚本标记为异步
默认情况下,所有 JavaScript 都会阻止解析器,向 script 标记添加异步关键字可以指示浏览器在等待脚本加载期间不阻止 DOM 构建,如下
<script src="app.js" async></script>
提升交互性能
交互或者动画都会出发页面的重绘,他会影响用户的操作流畅性体验。当前主流的设备的帧率都是60fps,因此网页应用渲染完一帧的速率就应该低于16ms这样才不会掉帧。在这16ms中,浏览器需要完成以下工作:
执行JavaScript脚本(增删改dom或style) -> 计算节点样式 -> 计算节点的集合信息(位置、大小) -> 像素填充 -> 图层合并
Javascript和CSS是由开发者提供的,其他步骤都完全由浏览器控制,因此,我们的脚本的处理时间应该低于16ms才能保证页面的重绘在16ms内完成,一般来说,留给JavaScript执行的时间大概在10ms左右。
使用 requestAnimationFrame
来实现视觉变化
JavaScript执行的最佳时机是在屏幕开始绘制新一帧的开头。 requestAnimationFrame接受一个函数,并保证该函数在新的一帧的开头被调用,我们可以使用这个方法来处理动画。下面是一个小的示例:
var start = null;
var element = document.getElementById('SomeElementYouWantToAnimate');
element.style.position = 'absolute';
function move(timestamp) {
if (!start) start = timestamp;
var progress = timestamp - start;
element.style.left = Math.min(progress / 10, 200) + 'px';
if (progress < 2000) {
window.requestAnimationFrame(move);
}
}
window.requestAnimationFrame(move);
不推荐使用 setTimeout
或 setInterval
来执行动画之类的视觉变化的原因在于,回调将在帧中的某个时点运行,可能刚好在末尾,而这可能经常会使我们丢失帧,导致卡顿。
减少/避免布局操作
当元素的“layout”属性发生了改变,也就是改变了元素的几何属性(例如宽度、高度、左侧或顶部位置等),那么浏览器将必须检查所有其他元素,然后“自动重排”页面。任何受影响的部分都需要重新绘制,而且最终绘制的元素需进行合成。
常见的几何属性有:
bottom, direction,display,float,font-size,font-weight,height,left,margin,padding,position,right,top,width...
可以通过https://csstriggers.com/查看更多信息。
避免强制同步布局
正常的页面更新流程是JavaScript 运行 -> 计算样式 -> 布局, 但是在以下场景,JavaScript会强制浏览器提前进行布局:
var box = document.getElementById("example-dom");
box.classList.add('fiori'); //修改样式
console.log(box.offsetHeight); //读取高度
在给box节点添加新的样式ful-screen
之前,自上一帧的所有旧布局值是已知的,浏览器为了优化性能,会将更新操作暂缓到队列批量处理;但是,程序立马请求查看节点的高度box.offsetHeight
,为了获取准确的数据,浏览器必须立刻应用样式更改,然后运行布局,这是不必要的,如果有大量的这种操作(处理动画时,往往会不停的修改样式,应该避免写与读操作相间触发),将会带来巨大的开销。
正确的做法是应该先获取高度,然后再进行样式的更改
var box = document.getElementById("example-dom");
console.log(box.offsetHeight); //读取高度
box.classList.add('fiori'); //修改样式
反例:
for(var i=0;i<p.length;i++){
p[i].style.width = d.offsetWidth + 'px'; //获取d的宽度并赋值给p[i]
}
正例:
var w = d.offsetWidth; // 提前获取好d的宽度
for(var i=0;i<p.length;i++){
p[i].style.width = w + 'px'; //通过变量w给p[i]赋值,循环中只有写操作,不会强制同步布局
}
后台运行复杂/耗时逻辑
JavaScript 在浏览器的主线程上运行,恰好与样式计算、布局以及许多情况下的绘制一起运行。如果 JavaScript 运行时间过长,就会阻塞这些其他工作,可能导致帧丢失。对于一些计算密集的操作,建议使用web worker进行处理,不要占用浏览器的主进程
减少绘制区域
绘制并非总是绘制到内存中的单个图像。事实上,在必要时浏览器可以绘制到多个图像或合成器层,类似于Photoshop图层的概念,我们可以创建不同的图层绘制图像,最后将他们合并。
利用这个特性,可以将经常需要重绘的部分单独放在一个图层,避免整个页面收到影响。
使用 will-change CSS 属性可以为元素创建一个新的图层, will-change
为web开发者提供了一种告知浏览器该元素会有哪些变化的方法,这样浏览器可以在元素属性真正发生变化之前提前做好对应的优化准备工作。
.moving-element {
will-change: transform;
}
注意:
不要创建太多图层,因为每层都需要内存和管理开销。
其他
- 不要使用过于复杂的css选择器
- 精简DOM节点数量
工具
性能监控工具
- Chrome DevTool(强力推荐,以后有机会会单独介绍)
- Lighthouse