渲染流程
基础知识
- HTML:超 文本 标记 语言,由标记(标签)和文本组成。
- CSS:层叠样式表,由选择器和属性组成。
- JavaScript:使用它让网页“动”起来。
由于渲染机制过于复杂,所以渲染模块在执行过程中会被划分为很多子阶段,输入的 HTML 经过这些子阶段,最后输出像素。我们把这样的一个处理流程叫做 渲染流水线 ,其大致流程如下图所示:
按照渲染的时间顺序,流水线可分为如下几个子阶段:构建 DOM 树、样式计算、布局阶段、分层、绘制、分块、光栅化和合成 。
构建DOM树
当我们打开一个网页时,都会去获取对应的HTML文件以及其他文件,大都是字符串。
但计算机硬件并不能理解这些字符串,在网络传输中其实都是0和1
的字节数据
,浏览器接收到字节数据之后,会转化为字符串,也就是我们写的代码。
为什么需要构建DOM树,因为浏览器无法直接理解和使用HTML,所以需要 将HTML转化为浏览器能够理解的结构——DOM树 。
当转化为字符数据之后,浏览器会先对其 进行词法分析 ,转化为标记(token)
。这个过程叫做标记化
。
简单来说,标记是构成代码的最小单位,这个过程会将代码拆分为一块块,并作上标记,便于理解这一块代码是什么意思。
结束标记化后,会将这些标记 转化为Node结点 ,最后这些node结点会根据不同结点之间的联系构建成一棵DOM树
当解析器发现有非阻塞资源,如一张图片等,会请求这些资源,然后继续解析,不会阻塞;但对于<script>
标签(特别是没有async
或者defer
属性)会阻塞渲染并停止HTML的解析。
预加载器扫描提供的优化减少了阻塞。 预加载扫描器,将解析可用的内容并请求高优先级的资源,如
CSS
、JavaScript
、web
字体等。我们可以不用等到解析器找到外部资源的引用时再请求资源。它可以再后台检索资源,当HTML解析器到达请求的资源时,它可能已经在运行或被下载。
CSS
资源不会阻塞HTML,但会阻塞JavaScript
,因为JavaScript经常用于查询元素的CSS属性。
当给
<script>
标签添加defer
属性以后,该JS文件会并行下载,但会放在HTML解析完成后顺序执行
,对于这种情况,可以将script
标签放在任何位置,defer
对模块脚本没有作用——他们默认 defer。 加上async
属性,表示JS文件下载和解析不会阻塞渲染,会尽快加载并立即执行,所以多脚本可能会乱序执行
,这对于不依赖任何脚本的脚本是非常合适的。
样式计算
CSS来源
- 通过 link 引用的外部 CSS 文件
- style 标签内的css
- 元素的style属性内嵌的css
1.将CSS转化为浏览器能理解的结构
和 HTML 文件一样,浏览器也是无法直接理解这些纯文本的 CSS 样式,所以当渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构—— styleSheets 。
渲染引擎会把获取到的 CSS 文本全部转换为 styleSheets 结构中的数据,并且该结构同时具备了查询和修改功能,这会为后面的样式操作提供基础。
2.转化样式表中的属性值,使其标准化
body { font-size: 2em }
p {color:blue;}
span {display: none}
div {font-weight: bold}
div p {color:green;}
div {color:red; }
将以上例如bold
、blue
、2em
等不容易被渲染引理解的类型数值,转化为渲染引擎能理解的,标准化过的值,这个过程称为 属性值标准化 。
3.计算出DOM树中每个节点的具体样式
这涉及到CSS样式规则和重叠规则。
首先是CSS继承。每个DOM节点都会包含父节点的样式。在开发者工具
中,我们可以看到样式的元素样式的继承覆盖过程和样式来源。
UserAgent 样式,它是浏览器提供的一组默认样式,如果你不提供任何样式,默认使用的就是 UserAgent 样式。
接下来是CSS重叠规则,定义了如何合并来自多个源的属性值的算法。在CSS中处于核心地位。
总之,样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,在计算过程中需要遵守 CSS 的继承和层叠两个规则。这个阶段最终输出的内容是每个 DOM 节点的样式,并被保存在 ComputedStyle 的结构内。
在开发中,我们应该尽可能避免写过于具体的CSS选择器,对于HTML来说,应该尽可能少的添加无意义标签,保证层级扁平。
构建CSSOM非常快,通常小于一次DNS查找所需的时间。
CSS的求值过程:
- filtering:匹配各个选择器中有效的属性值
- cascading:对比选择器的特异性,生成层叠值
- defaulting:判断层叠值是否为空。如果为空,使用继承或者初始值,生成不为空的指定值
- resolving:将相对值计算成绝对值,(比如em转化成px,相对路径转化成绝对路径等),生成计算值
- formatting:转化如vh,vw,百分比这种,得到使用值。
- 将小数像素值转为整数
- 完毕
如果下载 CSS 文件阻塞了,会阻塞 DOM 树的合成吗?会阻塞页面的显示吗?
CSS 加载不会阻塞 DOM 树解析,但会阻塞 DOM 树渲染。
所以为了避免用户看到长时间的白屏,应该尽快提高 CSS 的加载速度。
1.使用CDN;
2.CSS的压缩(使用webpack、gulp等打包工具);
3、合理使用缓存(设置缓存控制、表达式和e-tag);
DOM解析和CSS解析是两个并行的过程,所以这也解释了为什么CSS加载不会阻塞DOM解析。 但是因为render tree依赖于DOM树和cssom树,所以必须等到cssom树构建完成,也就是CSS资源加载完成。
布局阶段
现在,我们有 DOM 树和 DOM 树中元素的样式,但这还不足以显示页面,因为我们还不知道 DOM 元素的几何位置信息。那么接下来就需要计算出 DOM 树中可见元素的几何位置,我们把这个计算过程叫做 布局 。
阶段一:创建布局树
渲染树只会包含显示的节点和这些节点的样式信息,如果某个节点的样式为display:none
,那么不会显示在渲染树中。
为了构建布局树,浏览器大体上完成了下面这些工作:
- 遍历DOM树中所有可见节点,并把这些节点加到布局树上。
- 将不可见节点忽略掉
阶段二:布局计算
在浏览器生成布局树以后,就会根据渲染树来进行布局(也可以叫做回流
,重排
),计算每个元素的几何坐标位置,并将这些信息保存在布局树中。然后用GPU绘制,合成图层,像是在屏幕上。
分层
有了布局树,并不是就开始绘制页面了。
因为在页面中,还包含着很多复杂的效果,如3D
变换、页面滚动、设置了z-index
等,为了方便显示这些效果,渲染引擎还需要为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree)。
通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。
那么需要满足什么条件,渲染引擎才会为特定的节点创建新的图层呢?通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。
- 拥有层叠上下文属性的元素
- 层叠上下文属性
- 明确定位属性的元素
- 定义透明属性的元素
- 使用css滤镜元素
- 设置z-index属性的元素
- 层叠上下文属性
- 需要裁剪的地方
- 文字内容太多溢出,会产生裁切:渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。
图层绘制
在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制。
渲染引擎会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表。
从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。
栅格化(raster)操作
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程来完成的。
当图层的绘制列表准备好之后,主线程会把该绘制列表提交(commit)给 合成线程 。
什么是视口:通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport)。
如果图层比较大,但用户只会看到视口部分的内容,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。基于这个问题,合成线程会把图层划分成图块,大小通常是256x256或者512x512。
合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。
而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的。
通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。这过程就涉及到了跨进程操作。
合成和显示
一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,然后将该命令提交给浏览器进程。
浏览器进程里面有一个叫 viz 的组件,用来接收合成线程发过来的 DrawQuad 命令,然后根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。
总结
结合上图,一个完整的渲染流程大致可总结为如下:
- 渲染进程将 HTML 内容转换为能够读懂的 DOM 树结构。
- 渲染引擎将 CSS 样式表转化为浏览器可以理解的 styleSheets,计算出 DOM 节点的样式。
- 创建布局树,并计算元素的布局信息。
- 对布局树进行分层,并生成分层树。
- 为每个图层生成绘制列表,并将其提交到合成线程。
- 合成线程将图层分成图块,并在光栅化线程池中将图块转换成位图。
- 合成线程发送绘制图块命令 DrawQuad 给浏览器进程。
- 浏览器进程根据 DrawQuad 消息生成页面,并显示到显示器上。
为什么操作DOM慢
因为DOM是属于渲染引擎的东西,而JS又是JS引擎的东西,当我们用JS去操作DOM时,必定涉及到两个线程之间的通信,势必会带来一些性能上的损耗;操纵的次数一多,等同于一直在进行两个线程之间的通信,并且操作DOM可能还会带来重绘重排的情况,从而导致性能上的问题。
经典面试题:插入几万个 DOM,如何实现⻚面不卡顿?
解决问题的重点是:如何分批次地部分渲染DOM。
方案一:通过 requestAnimationFrame 的方式去循环的插入 DOM
方案二:虚拟列表,只渲染可视区域内的内容,非可视区域的完全不渲染,当用户在滚动时,实时地替换渲染的内容。
重绘、重排和合成
重排(回流):布局或者几何属性需要改变
重绘:改变节点的外观而不影响布局
合成:更改了一个既不用布局也不用绘制的属性
重绘和重排会在我们设置节点样式时频繁出现,同时也会很大程度上影响性能。
以下几个动作可能会导致性能问题:
- 改变window大小
- 改变字体
- 添加或者删除样式
- 文字改变
- 定位或者浮动
- 盒模型
重绘重排也和Event Loop
有关:
- 当执行完微任务时,会判断
document
是否需要更新,因为浏览器是60Hz
的刷新率,所以16.6ms才会更新一次 - 然后判断是否有
resize
或者scroll
事件,有的话触发事件,同样至少16ms
才会触发一次,自带节流 - 判断是否触发了媒体查询
- 更新动画并且发送事件
- 判断是否有全屏操作
- 执行
requestAnimationFrame
回调 - 执行
IntersectionObserver
回调,该方法用于判断元素是否可见,可以用于懒加载,但兼容性不好 - 更新界面
- 以上为一帧内可能会做的事情,如果还有空闲时间,会去执行
requestIdleCallback
回调
减少重排重绘的手段:
- 使用
transform
代替top
- 使用
visibility
替换display:none
,前者会引发重绘,但后者会引发 - 不要将节点的属性作为循环中的变量
- 不要使用
table
布局,一个小的改动可能会导致整个table重新布局 - 动画的实现速度越快,重排次数越多,可以选择使用
requestAnimationFrame
- CSS选择符
从右往左
匹配查找,避免节点层级过多 - 将频繁重绘重排的节点设置为图层,图层可以阻止该节点的渲染行为影响别的节点。
- 例如
video
、iframe
标签 - 将css设置为
will-change:xxx
(实验性功能)
- 例如
如何提高页面渲染的速度
(在不考虑缓存和优化网络协议的前提下)
- 从文件大小上考虑
- 从script标签的使用上考虑
- 从CSS、HTML的代码书写上考虑
- 从下载的内容是否需要的首屏使用上考虑