一、渲染流水线

渲染机制是十分复杂的,因此执行过程被拆分为多个子阶段,每个子阶段有其输入与输出。

阶段一、构建DOM树 | 让浏览器理解HTML文档,知道将构建的文档结构

image.png

简介

DOM树是多种文档结构中一种较为普遍的实现方式,基本要素是“节点”。

节点类型

  1. 文档节点:整个文档
  2. 元素节点:每个HTMl元素(标签)
  3. 文本节点:元素中的文本
  4. 属性结点:元素属性
  5. 注释结点:注释

    构建过程

    image.png

字节流通过解码后变为字符流,后通过词法分析器解释为词语,再通过语法分析器构建成节点,最后组建成DOM树。
词法分析器解释为词语的过程中,浏览器会将其交给单独的线程处理(如Chromium 浏览器),待解释成成语后,Webkit分批次将结果词语传递回渲染进程。

DOM解析中遇到JS脚本,会如何处理?
  1. <html>
  2. <body>
  3. <!-- 情况一 -->
  4. <script>
  5. document.write("--foo")
  6. </script>
  7. <!-- 情况二 -->
  8. <script type="text/javascript" src="foo.js"></script>
  9. <!-- 情况三 -->
  10. <script>
  11. let e = document.getElementsByTagName('p')[0]
  12. e.style.color = 'blue'
  13. </script>
  14. </body>
  15. </html>

情况一:当遇到JS脚本时,DOM解析器会先执行JavaScript脚本,待执行完成后才会继续往下解析。
情况二:当遇到JS外链时会停止DOM解析,下载js文件并执行完后才会继续往下解析DOM。
情况三:此处需要访问到元素的样式,文章继续往下有讲当CSS文件被转换为styleSheets后才具有查询和修改功能,所以此处如果css文件下载被阻塞,那JS脚本需要等待这个样式被下载完成后才能继续往下执行,同样的,DOM解析也会等待JS脚本执行后才继续往下解析。
总结三种情况:JS和CSS都有可能阻塞DOM解析。

那为什么要等待JS脚本运行完后才继续解析呢?

因为 javascript 代码能够改变文档结构。比如 document.write() 改变了整个文档的结构。这就是为什么 HTML 解析器需要等待 javascript 代码执行结束,才能继续进行 HTML 解析的原因。

阶段二、样式计算 | 让浏览器知道每个元素的样式

简介

将浏览器不能直接理解的CSS文件转换成可以理解的结构(styleSheets),同时标准化属性值、计算并输出节点样式,输出会被保存在ComputedStyle中。

第一步、转换为styleSheet结构

像HTML文件一样,浏览器无法直接理解CSS文件,会执行相关转换,将其转换为可以理解的结构——styleSheets。该结构具备查询和修改功能,为后面的样式操作提供基础。

第二步、标准化属性值

image.png
将如同“2em 、 bue、bold”等渲染引擎不易理解的值转换为容易理解、标准化的计算值。

第三步、计算每个节点的具体样式

样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式,计算过程中遵循CSS继承和层叠两个规则,最终输出的是每个DOM节点的样式,被保存在ComputedStyle中。

【CSS中能够继承的常用属性】

字体系列属性

font / font-size / font-weight / font-style …

文本系列属性

line-height / color / text-align / word-spacing / letter-spacing / direction / text-indent / text-transform
注意:vertical-align / text-decoration / white-space 无法继承
注意:a标签的color无法被继承
*注意:h1-h6的大小也不能被继承

表格布局相关属性

caption-side / border-collapse / border-spacing / table-layout / empty-cells

列表属性

list-style / list-style-type / list-style-image / list-style-position

其他属性

cursor / visibility

阶段三、布局阶段 | 让浏览器知道具体元素的展示(绘制)位置

有了DOM树和其元素样式,还不足以显示页面,因为还不知道元素的具体展示位置。所以这一步就是为了计算元素几何位置。

第一步、创建布局树 | DOM树过滤不可见元素

布局树中只含有DOM树中的可见元素。
image.png

构建过程

遍历DOM树中所有节点,将所有可见节点加入布局树中。
不可见节点包括:head标签下全部内容、属性包含display:none的元素。

第二步、布局计算 | 计算元素的页面位置

知道哪些元素需要展示后,就需要知道具体展示位置。这一步就是进行节点坐标位置的计算,这一过程过于复杂,这里不细讲。

阶段四、分层 | 让浏览器知道元素在哪个图层上绘制

知道文档结构,知道元素样式,知道元素绘制位置,那可以开始绘制了吗?

当然不行,我们知道元素是可能出现重叠的,你想象你要画天空,那你需要先画蓝色的背景,然后再在背景上画上白色的云朵,云朵和背景的绘制顺序是不能倒过来的,所以知道元素的绘制前后顺序是绝对必须的。那问题来了,那浏览器怎么知道重叠元素的绘制顺序呢?
将元素分图层绘制就是解决这个问题的关键。

要直观理解什么是图层:

可以打开Chrome的“开发者工具”,选择“Layers”标签,就可以可视化页面的分层情况,如下:
image.png

并不是每个元素都有自己专属图层:

并不是每一个节点都有专属于自己的图层,只有满足以下条件,浏览器才会认为该节点需要专属图层:

情况一、创建了层叠上下文的元素

image.png

(只写常见情况,更多请查看链接

  1. 给一个 HTML 元素定位
  2. z-index 赋值

值得一提的是:层叠上下文中子元素内容发生层叠时,只会在父层叠上下文内部按顺序进行层叠。换句话说,内部元素的层叠顺序如何外部而言都是与其父层叠上下文处于同一层。

  1. 元素的opacity小于1
  2. 使用了transform
  3. 使用了CSS滤镜

而对于没有创建层叠上下文的图层而言,它被包含在父层叠上下文中,如果父亲也没创建,继续往上包含。

情况二、出现裁剪的情况

当设置了overflow属性后,会出现元素需要裁剪展示的情况,如下:
image.png
如果出现了滚动条,滚动条会被提升为单独的层。

生成分层树

知道具体的分层情况后,生成分层树。
image.png

扩展:更新消耗

由于从 DOM & CSS 合成布局树,到布局树生成绘画记录是一系列的过程,在这个过程中,每一步都需要前一步来生成数据。如果 DOMCSS 结构发生改变,那么还需要通过前面的步骤生成受影响部分的绘制记录。

阶段五、绘制 | 还没开始真正绘制,而是为了有条不紊准备了一个待绘制列表

在知道元素所在图层后,渲染引擎为了有条不紊地绘制,它将自己的绘制计划(绘制指令)组成一个待绘制列表,如下:
image.png
在这个阶段输出的内容便是这个待绘制列表,用于记录绘制顺序和绘制指令。当绘制列表准备好后,主线程会将该绘制列表提交给合成线程。

阶段六、分块&光栅化 | 将页面分块并为每个分块生成对应位图

分块 | 将页面分成多个图块

image.png

合成线程很聪明,他知道用户能看到的页面只有浏览器视口大小,是不必将整个页面绘制出来的,免得增加额外的开销,如下:
image.png
所以合成线程会将图层划分为图块,并且为视图附近的图块优先生成位图。而生成位图的操作是由栅格化完成的。

光栅化(栅格化、像素化) | 将图块转换为位图/将几何信息转换为屏幕上的像素

渲染进程中维护着一个专门用于栅格化的线程池,所有图块的栅格化都在线程池中进行,如下图:
image.png

什么是“快速栅格化”

栅格化过程会使用GPU来加速生成,最终生成位图的操作是在GPU中完成。
image.png
你会发现这是两个进程,所以“快速栅格化”会涉及到跨进程操作,会使用到IPC通信技术:
渲染进程发送生成图块位图的指令给GPU,而GPU将生成的图块位图保存在GPU内存中。

阶段七、合成与显示 | 浏览器真正绘制出美丽的界面

一旦元素被光栅化,就会被合成线程收集,在元素都被光栅化后,合成线程就会创建一个合成帧。当所有的图块都被光栅化后,合成线程会朝着浏览器进程大喊一声“DrawQuad(你可以绘制图块啦!)”——一个绘制图块的命令。
浏览器进程中有个叫viz的组件接收到“可以绘制!”的命令后,将页面内容绘制到内存(发送合成帧到GPU中)中,最后将内存显示在屏幕上。

全过程总结

image.png

  1. 渲染进程将HTML转化为DOM树。(为了让浏览器读懂元素结构)
  2. 渲染引擎将CSS样式表转化为styleSheets,计算出节点样式。(为了让浏览器知道节点具体长什么样)
  3. 创建布局树,计算节点在页面的布局位置。(为了让浏览器知道节点要在那里绘制)
  4. 生成分层树。(为了让浏览器知道元素的重叠顺序)
  5. 为每个图层生成待绘制列表,并将列表提交给合成线程。(为了让合成线程知道绘制顺序)
  6. 合成线程将图层分块,并在光栅化线程池中将图块转换为位图。
  7. 光栅化后,合成线程发送绘制命令DrawQuad给浏览器进程。
  8. 浏览器进程生成页面并显示。

二、渲染过程中的相关概念

概念一、重排 | 触发重新布局,即:image.png

image.png
某个元素改变宽高或几何位置会使页面其他元素需要调整位置以适应布局,也这就意味着要页面需要重新计算元素位置(重新布局),这种情况称作“重排”。重排操作执行后会触发后面整个流程的更新,所以是一种开销非常大的操作。

以下情况会触发重排
  1. 页面初始渲染
  2. 添加/删除可见DOM元素
  3. 改变可见元素位置
  4. 改变可见元素尺寸(宽高、边距、边框等)
  5. 改变可见元素内容(文本、图片等,别忘了DOM树中是有文本节点的)
  6. 改变浏览器窗口尺寸
  7. 滑动滚动条(如果你还记得分块和光栅化那块,你会知道这会重排整个页面)

概念二、重绘 | 触发重新绘制,即:image.png

image.png
元素的绘制样式发生变化就会触发重绘,从上一篇文章里我们知道绘制阶段是输出待绘制列表的,所以这里的重绘并非包含内容展示,只是输出新的待绘制列表。
如果元素只是样式改了,几何位置没有改变,那我们可以看作单纯的重绘操作,这时渲染流水线中布局的重新计算就显得多此一举了,所以会直接跳过布局阶段,因此单纯重绘的开销比重排小得多;但如果几何位置发生改变,无论样式是否更改,触发重排必然导致后面整个流程的重新执行,而其中也恰恰包含了重绘,所以:若触发重排,则必然导致重绘;重绘不一定需要重排

以下情况会触发单纯的重绘

主要是更改了以下css属性:color border-style border-radius visibility(可优化点)** **text-decoration background background-image background-position background-repeat background-size outline-color outline outline-style outline-width box-shadow

概念三、重新渲染 | 重新生成布局和重新绘制,即:image.png

当然这也同样会导致后面整个流程的重新执行。

概念四、直接合成 | 合成时才动手脚,改变合成结果,绘制效率远比重排和重绘高

如果不需要改变元素几何位置和样式,而只是要求合成进程在合成时“有意做些手脚”,比如偏移一下下,这时就需要合成了。合成同样是要重走渲染流水线,但是对于它而言,布局的计算和绘制阶段(产出待绘制列表)是多此一举的,所以也是直接跳过这些步骤。同时,因为合成的操作并不在主线程中执行,所以不会占用主线程的资源,相对于重绘和重排能大大提升绘制效率。
所以transform适合做js动画!
image.png


END