2. 绘制图层
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制,下面就来看看渲染引擎是怎么实现图层绘制的。
渲染引擎在绘制图层时,会把一个图层的绘制分成很多绘制指令,然后把这些指令按照顺序组成一个待绘制的列表:
可以看到,绘制列表中的指令就是一系列的绘制操作。通常情况下,绘制一个元素需要执行多条绘制指令,因为每个元素的背景、边框等属性都需要单独的指令进行绘制。所以在图层绘制阶段,输出的内容就是绘制列表。
在Chrome浏览器的开发者工具中,通过Layer标签可以看到图层的绘制列表和绘制过程:
绘制列表只是用来记录绘制顺序和绘制指令的列表,而绘制操作是由渲染引擎中的合成线程来完成的。当图层绘制列表准备好之后,主线程会把该绘制列表提交给合成线程。
注意:合成操作是在合成线程上完成的,所以,在执行合成操作时并不会影响到主线程的执行。
很多情况下,图层可能很大,比如掘金的一篇长文,需要滚动很久才能到底,但是用户只能看到视口的内容,所以没必要把整个图层都绘制出来。因此,合成线程会将图层划分为图块,这些图块的大小通常是 256x256 或者 512x512。合成线程会优先将视口附近的图块生成位图。实际生成位图的操作是在光栅化阶段来执行的,所谓的光栅化就是按照绘制列表中的指令生成图片。
当所有的图块都被光栅化之后,合成线程就会生成一个绘制图块的命令,浏览器相关进程收到这个指令之后,就会将其页面内容绘制在内存中,最后将内存显示在屏幕上,这样就完成了页面的绘制。
至此,整个渲染流程就完成了,其过程总结如下:
- 将HTML内容构建成DOM树;
- 将CSS内容构建成CSSOM树;
- 将DOM 树和 CSSOM 树合成渲染树;
- 根据渲染树进行页面元素的布局;
- 对渲染树进行分层操作,并生成分层树;
- 为每个图层生成绘制列表,并提交到合成线程;
- 合成线程将图层分成不同的图块,并通过栅格化将图块转化为位图;
- 合成线程给浏览器进程发送绘制图块指令;
- 浏览器进程会生成页面,并显示在屏幕上。
六、其他
1. 重排和重绘
说完浏览器引擎的渲染流程,再来看两个重要的概念:重排(Reflow)和重绘(Repaint)。
我们知道,渲染树是动态构建的,所以,DOM节点和CSS节点的改动都可能会造成渲染树的重新构建。渲染树的改动就会造成页面的重排或者重绘。下面就来看看这两个概念,以及它们触发的条件和减少触发的操作。
(1)重排
当我们的操作引发了 DOM 树中几何尺寸的变化(改变元素的大小、位置、布局方式等),这时渲染树里有改动的节点和它影响的节点都要重新计算。这个过程就叫做重排,也称为回流。在改动发生时,要重新经历页面渲染的整个流程,所以开销是很大的。
以下操作都会导致页面重排:
- 页面首次渲染;
- 浏览器窗口大小发生变化;
- 元素的内容发生变化;
- 元素的尺寸或者位置发生变化;
- 元素的字体大小发生变化;
- 激活CSS伪类;
- 查询某些属性或者调用某些方法;
- 添加或者删除可见的DOM元素。
在触发重排时,由于浏览器渲染页面是基于流式布局的,所以当触发回流时,会导致周围的DOM元素重新排列,它的影响范围有两种:
- 全局范围:从根节点开始,对整个渲染树进行重新布局;
- 局部范围:对渲染树的某部分或者一个渲染对象进行重新布局。
(2)重绘
当对 DOM 的修改导致了样式的变化、但未影响其几何属性(比如修改颜色、背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(会跳过重排环节),这个过程叫做重绘。简单来说,重绘是由对元素绘制属性的修改引发的。
当我们修改元素绘制属性时,页面布局阶段不会执行,因为并没有引起几何位置的变换,所以就直接进入了绘制阶段,然后执行之后的一系列子阶段。相较于重排操作,重绘省去了布局和分层阶段,所以执行效率会比重排操作要高一些。
下面这些属性会导致回流:
- color、background 相关属性:background-color、background-image 等;
- outline 相关属性:outline-color、outline-width 、text-decoration;
- border-radius、visibility、box-shadow。
注意: 当触发重排时,一定会触发重绘,但是重绘不一定会引发重排。
相对来说,重排操作的消耗会比较大,所以在操作中尽量少的造成页面的重排。为了减少重排,可以通过以下方式进行优化:
- 在条件允许的情况下尽量使用 CSS3 动画,它可以调用 GPU 执行渲染。
- 操作DOM时,尽量在低层级的DOM节点进行操作
- 不要使用
table
布局, 一个小的改动可能会使整个table
进行重新布局 - 使用CSS的表达式
- 不要频繁操作元素的样式,对于静态页面,可以修改类名,而不是样式。
- 使用absolute或者fixed,使元素脱离文档流,这样他们发生变化就不会影响其他元素
- 避免频繁操作DOM,可以创建一个文档片段
documentFragment
,在它上面应用所有DOM操作,最后再把它添加到文档中 - 将元素先设置
display: none
,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。 - 将DOM的多个读操作(或者写操作)放在一起,而不是读写操作穿插着写。这得益于浏览器的渲染队列机制。
浏览器针对页面的回流与重绘,进行了自身的优化——渲染队列, 浏览器会将所有的回流、重绘的操作放在一个队列中,当队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会对队列进行批处理。这样就会让多次的回流、重绘变成一次回流重绘。
2. JavaScript对DOM的影响
最后我们再看看看JavaScript脚本对DOM的影响。当解析器解析HTML时,如果遇到了
来看一段代码:
<html> <body> <div>hello juejin</div> <script> document.getElementsByTagName('div')[0].innerText = 'juejin yyds' </script> <p>hello world</p> </body> </html> 复制代码
这里,当解析完div标签后,就会解析script标签,这时的DOM结构如下:
这时,HTML解析器就会暂停工作,JavaScript引擎就会开始工作,并执行script标签中的脚本内容。由于这段脚本修改了第一个div的内容,所以执行完这个脚本之后,div中的文本就变成了“juejin yyds”,当脚本执行完成之后,HTML解析器就会恢复解析过程,继续解析后面的内容,直至生成最终的DOM。
上面我们说的JavaScript脚本是通过script标签直接嵌入到HTML中的。当在页面中引入JavaScript脚本时,情况就会变得复杂。比如:
<html> <body> <div>hello juejin</div> <script type="text/javascript" src='./index.js'></script> <p>hello world</p> </body> </html> 复制代码
其实这里的执行流程和上面时一样的,当遇到script标签时,HTML解析器都会暂停解析并去执行脚本文件。不过这里执行 JavaScript 脚本时,需要先下载脚本。脚本的下载过程会阻塞 DOM 的解析,而通常下载又是非常耗时的,会受到网络环境、JavaScript 脚本文件大小等因素的影响。
经过上面的分析可知,JavaScript 线程会阻塞 DOM 的解析,我们可以通过CDN、压缩脚本等方式来加速 JavaScript 脚本的加载。如果脚本文件中没有操作DOM的相关代码,就可以将JavaScript脚本设置为异步加载,可以给script标签添加 async 或 defer 属性来实现脚本的异步加载。两者的使用方式如下:
<script async type="text/javascript" src='./index.js'></script> <script defer type="text/javascript" src='./index.js'></script> 复制代码
下图可以直观的看出异步加载和直接加载的区别:
defer 和 async属性都是去异步加载外部的JS脚本文件,它们都不会阻塞页面的解析,其区别如下:
- 执行顺序: 多个带async属性的标签,不能保证加载的顺序;多个带defer属性的标签,按照加载顺序执行;
- 脚本是否并行执行: async属性,表示后续文档的加载和执行与js脚本的加载和执行是并行进行的,即异步执行;defer属性,加载后续文档的过程和js脚本的加载(此时仅加载不执行)是并行进行的(异步),JavaScript 脚本需要等到文档所有元素解析完成之后才执行,DOMContentLoaded事件触发执行之前。
再来看另外一种情况,示例代码如下:
<html> <head> <style src='./style.css'></style> </head> <body> <div>hello juejin</div> <script> const ele = document.getElementsByTagName('div')[0]; ele.innerText = 'juejin yyds'; // 操作DOM ele.style.color = 'skyblue'; // 操作CSSOM </script> <p>hello world</p> </body> </html> 复制代码
上面的代码中,第9行是操作DOM的,而第10行是操作CSSOM的,所以在执行 JavaScript 脚本之前,还需要先解析 JavaScript 语句之上所有的 CSS 样式。所以如果代码里引用了外部的 CSS 文件,那么在执行 JavaScript 之前,还需要 等待外部的 CSS 文件下载完成,并解析生成 CSSOM 对象之后,才能执行 JavaScript 脚本。
而 JavaScript 引擎在解析 JavaScript 之前,是不知道 JavaScript 是否操纵了 CSSOM 的,所以渲染引擎在遇到 JavaScript 脚本时,不管该脚本是否操纵了 CSSOM,都会执行 CSS 文件下载,解析操作,再执行 JavaScript 脚本。
所以,JavaScript 会阻塞 DOM 生成,而样式文件又会阻塞 JavaScript 的执行,我们在开发时需要格外注意这一点。
最后再来看一种情况,示例代码如下:
<html> <head> <style src='./style.css'></style> </head> <body> <div>hello juejin</div> <script type="text/javascript" src='./index.js'></script> <p>hello world</p> </body> </html> 复制代码
这段HTML代码中包含了CSS外部引用和JavaScript外部文件,在接收到 HTML 数据之后的预解析过程中,HTML 预解析器识别出来了有 CSS 文件和 JavaScript 文件需要下载,就会同时发起两个文件的下载请求。