创建DOM树

DOM(文档对象模型)是 HTML 文档的对象表示,同时也是外部内容(例如 JavaScript)与 HTML 元素之间的接口。

HTML 解析器将 HTML 文件解析为 DOM树,DOM 树的根节点是 Document 对象。

DOM 树的结构与 HTML 的内容几乎是一一对应的,但 DOM 是保存在内存中数据结构,可以通过 JavaScript 进行读写操作,而 HTML 本质上就是一堆字符串。

样式计算

样式计算阶段的目的是为了计算出 DOM 节点中每个元素的具体样式(计算样式)

需要完成的步骤:

  1. 把 CSS 转换为浏览器能够理解的结构,便于后序的查询和修改(可以称其为 CSSOM)

    通过 document.styleSheets 可以得到页面上所有 linkstyle 所定义的样式表(styleSheets 类型)

  2. 将样式表中的属性值标准化,由于 CSS 中的写法多样,所以需要转换为统一的值

  3. 使用 CSS 规则计算出 DOM 树中每个节点的具体样式,通过 DevTools 的 computed 可以看到具体信息

布局

有 DOM 树和 DOM 树中元素的样式,这还不足以显示页面,因为还不知道 DOM 元素的几何位置信息。

那么接下来就需要计算出 DOM 树中可见元素的几何位置,这个计算过程叫做布局,布局是一个寻找元素几何形状的过程。

Chrome 在布局阶段需要完成两个任务:创建布局树和布局计算。

布局树

主线程遍历 DOM 树利用每个节点的计算样式创建布局树,布局树中会包含坐标和边界框大小等信息。

布局树与 DOM 树的结构相似,但是布局树和 DOM 树中的元素并不一定一一对应,布局树中只包含和页面可见有关的内容。

  • 布局树中不包含非可视化元素display: none 的元素,但是包含 visibility: hidden 的元素
  • 具有内容的伪元素 p::before{content:"Hi!"} 会包含在布局树中,但是不会存在于 DOM 树中

布局计算

拥有一棵完整的布局树之后就要计算布局树节点的坐标位置,布局的计算过程非常复杂。

布局计算就是读取布局树中的内容,并计算机布局信息重新写入布局树。

在执行布局操作的时候,会把布局运算的结果重新写回布局树中,所以布局树既是输入内容也是输出内容,这是布局阶段一个不合理的地方,因为在布局阶段并没有清晰地将输入内容和输出内容区分开来。针对这个问题,Chrome 团队正在重构布局代码,下一代布局系统叫 LayoutNG,试图更清晰地分离输入和输出,从而让新设计的布局算法更加简单。

分层与合成

浏览器知道了文档的结构、每个元素的样式、页面的几何形状和绘制顺序,将此信息转换为屏幕上的像素称为光栅化

页面中的所有动画效果都是由于渲染引擎通过渲染流水线生成新的图片并发送到显卡的后缓冲区。

如果在一次动画过程中,渲染引擎生成某些帧的时间过久,用户就会感受到卡顿,要解决卡顿问题,就要解决每帧生成时间过久的问题。

为了提高每一帧的渲染效率,Chrome 引入了分层合成的机制,分层和合成机制代表了当今最先进的渲染技术。

分层

可以把网页想象成是由很多个图片叠加在一起的,每个图片就对应一个图层,Chrome 最终将这些图层合成了用于显示页面的图片。

每个图层都可以设置透明度、边框阴影,可以旋转或者设置图层的上下位置,将这些图层叠加在一起后,就呈现出最终的图片

现在的网页中具有很多复杂的效果,实际上页面被分成了很多图层,这些图层叠加后合成了最终的页面。

在 Chrome 的 DevTools 的 Layers 中可以很清楚的看到一个网页的分层情况。

合成

合成是一种将页面的各个部分分成多个层、单独光栅化它们并在合成线程中合成为一个页面的技术。

例如:如果发生滚动,因为图层已经被光栅化,它所要做的就是合成一个新的帧

分层和合成的好处就是无需触发重排重绘,通过移动图层并合成新的一帧即可完成动画:

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。但不管怎样,最终每一个节点都会直接或者间接地从属于一个层。

  • 拥有层叠上下文属性的元素会被提升为单独的一层
  • 需要剪裁(clip)的地方也会被创建为图层
  • 滚动条也会被提升为单独的层
  • 通过 will-change 可以告诉浏览器将某个元素提升到单独的层,使用这个可以优化动画
  • 不能滥用分层,在过多的图层上进行合成可能会导致操作更慢

层树

为了找出哪些元素需要在哪些层中,主线程遍历布局树以创建层树,这部分在 DevTools 性能面板中称为“更新层树”

图层绘制

在完成图层树的构建之后,渲染引擎会对图层树中的每个图层进行绘制

绘制是填充像素的过程,它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。

图层的绘制分为两个阶段:创建绘图调用的列表、填充像素。

生成绘制列表

渲染进程主线程会把一个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表。

绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。

而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。

在开发者工具的 Layers 部分可以清楚的看到一个页面的绘制列表:

光栅化

当图层的绘制列表准备好之后,主线程会把该绘制列表提交给合成线程,合成线程会光栅化(根据绘制列表)每一个图层。

一个图层往往很大,合成线程会将图层划分为若干个图块,并将每个图块发送到光栅线程

光栅线程会光栅化每个图块并将它们存储到 GPU 内存中。

  • 合成线程往往会将视口附近的图块优先执行栅格化
  • 渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行
  • 通常栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化

合成显示

一旦所有图块都被光栅化,合成线程就会收集图块信息创建合成帧,合成线程将合成帧通过 IPC 传递给浏览器进程,接着合称帧会被传递到 GPU 中显示到页面上。

如果出现滚动事件,合成器线程会创建另一个合成器帧以发送到 GPU 而无需主线程的参与。

合成的好处是它是在不涉及主线程的情况下完成的,合成线程不需要等待样式计算或 JavaScript 执行。

渲染流程概述

一个完整的渲染流程大致可总结为:

  1. 渲染引擎将 HTML 内容解析为 DOM 树
  2. 渲染引擎将根据 CSS 得到每个 DOM 节点的计算样式
  3. 创建布局树,并计算元素的布局信息
  4. 对布局树进行分层,并生分层树
  5. 为每个图层生成绘制列表,并将其提交到合成线程
  6. 合成线程将图层分成图块,并在栅化线程池中将图块转换成位图(像素)
  7. 合成线程将图块合称为一帧,并通过主进程发送给 GPU 显示到屏幕上

回流和重绘

回流

通过 JavaScript 或者 CSS 修改元素的几何位置属性,浏览器会触发重新布局之后的一系列阶段,这个过程就叫回流。

回流需要更新完整的渲染流水线,所以开销是最大的。

reflow

重绘

当更新了元素的绘制属性(如:背景颜色),浏览器不会重新布局,直接从绘制阶段开始执行之后的一系列阶段。

直接合成

更改一个既不要布局也不要绘制的属性,渲染引擎将跳过布局和绘制,只执行后续的合成操作,我们把这个过程叫做合成。

使用了 CSS 的 transform 来实现动画效果,这可以避开重排和重绘阶段,直接在非主线程上执行合成动画操作。

因为是在非主线程上合成,并没有占用主线程的资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

减少回流和重绘

  • 使用 transform 替代 top
  • 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流
  • 不要把节点的属性值放在一个循环里当成循环里的变量
  • 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局
  • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame
  • CSS 选择符从右往左匹配查找,避免节点层级过多
  • 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点
    • 比如浏览器会自动将 video 节点变为图层
    • 使用 will-change 属性可以提示浏览器将某个节点提升为新的一层

脚本和样式表

脚本

当浏览器遇到 <script> 时会立即加载并执行脚本,此时DOM树的构建将暂停,直到该脚本执行完毕。

一般脚本放在 body 的最底部,避免阻塞页面解析,如果脚本中没有操作 DOM 相关代码,就可以将该脚本设置为异步加载。

异步加载方案

  1. defer
    1. 要等到 DOM 全部解析完(DCL事件之前)才会被执行
    2. 该脚本执行完触发 DCL 事件
    3. 特性仅适用于外部脚本
  2. async
    1. 加载完就异步执行,不会阻塞页面
    2. 其他脚本不会等待 async 脚本加载完成,同样,async 脚本也不会等待其他脚本
    3. DCL 和异步脚本不会彼此等待:DCL 和该脚本执行先后不确定,看该脚本加载的快慢
    4. 异步脚本就是就是一种它们不依赖于我们的脚本,我们的脚本也不应该等待它们的脚本

样式表

解析样式表不会更改 DOM 树,所以请求样式表无需停止文档解析,可以并行处理。

CSS 不会阻塞 DOM树的生成,只有一种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<html>
<head>
<link type="text/css" src="theme.css" />
</head>
<body>
<p>xxxxxxx</p>
<script>
const p = document.getElementsByTagName('p')[0]
p.style.color = 'blue'
</script>
<p>xxxxxxx</p>
<p>xxxxxxx</p>
</body>
</html>

JavaScript中访问了某个元素的样式,当时还没有加载和解析样式,就需要等待样式的加载和解析完毕。所以在这种情况下,CSS也会阻塞DOM的解析。

预解析

网络进程接收数据之后,会和渲染进程之间会建立一个共享数据的管道,网络进程将接收到数据往这个管道里面放,而渲染进程则从管道的另外一端不断地读取数据,并同时将读取的数据给 HTML 解析器,HTML 解析器动态接收字节流,并将其解析为 DOM。

一般来说,当DOM的解析遇到了脚本会暂停整个 DOM 的解析,加载并执行脚本之后才会继续解析。

不过 Chrome 浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染线程收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,会提前下载这些文件。

参考文章