如何把HTML,CSS,JS变成可交互的页面? -- 浏览器系列(3)

1,358 阅读22分钟

渲染进程负责标签页中发生的所有事,它的核心任务就是将 HTML,CSS 以及 JS 转化成用户可以交互的页面。在渲染进程中,主线程(main thread)负责处理绝大部分发送给用户的代码。合成线程(compositor)和光栅线程(raster)负责更高效流畅的渲染页面。

整个渲染过程非常复杂,涉及的内容非常多。整个执行过程会被分为多个子阶段。输入的 HTML 经过这些子阶段,会最终输出页面像素信息。我们把整个处理流程叫做渲染流水线或者关键渲染路径(Critial Rendering Path)。大致包括这几个阶段:构建 DOM 树,样式计算,布局计算,分层,绘制,分块,光栅化和合成。负责整个渲染过程的模块叫做渲染引擎。

关键渲染路径(CRP)

构建 DOM 树

当渲染进程收到浏览器主进程提交文档的信息后,便开始接收 HTML 数据。同时主线程也开始解析接收到的数据,并把它们转换为 DOM(Documment Object Model)对象。

DOM 对象既是浏览器对当前页面的内部表示,也是 web 开发者可以使用代码操作的数据结构和 API

渲染引擎中 HTML 解析器(HTML Parser)负责这部分的工作,构造 DOM 树主要过程包括:

dom build process

  • 转换:主线程从网络进程(或者缓存)中获取 HTML数据是字节码,首先要根据文件指定的编码(如 UTF-8)将其转为字符
  • 令牌化:接下来对字符进行词法分析,并转化为一个个 Token,Token 又可以分别 Tag Token(StartTag 和 EndTag)和文本 Token。如<div>text</text>会被分解为三个 Token: StartTag: divtextEndTag: div
  • Node 构建:根据生成的 Token 构造其对应的对象,如div构造为HTMLDivElementspanHTMLSpanElement,所有的这些对象都继承于Node对象
  • DOM 构建:根据不同标记之间的关系,创建一个把所有 Node 对象连接在一起的树形数据结构

以下面例子具体说明该过程:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Renderer Process</title>
  </head>
  <body>
    <div>Text</div>
  </body>
</html>

我们省略第一步转换的过程,在第二步中,首先会把 上述 HTML 代码令牌化,转换成Token:

token process 后续的第三步和第四步其实是同步进行的,HTML 解析器中维护了一个 Token 栈,该栈主要用于计算节点之间的父子关系,会把前一个阶段生成的 Token 按顺序压到栈中。并且:

  • 如果是StartTag Token,解析器会把该 Token 压入栈中,并为该 Token 创建一个 Node 节点,然后把该节点加入 DOM 树中
  • 如果是文本 Token,则会生成一个文本节点,然后把该节点加入到 DOM 树中,文本 Token 不需要压栈
  • 如果是EndTag Token,解析器会查看栈顶是否是对应的StartTag Token,如果是则将StartTag Token从栈中弹出,表示该节点解析完成

在开始这两步之前,解析器会默认创建一个根节点为document的空 DOM 结构,并将StartTag document的 Token 压入栈底: dom start step 0 然后将第一个StartTag html压入栈中,并且创建一个html的 Node 节点,添加到document上: dom start step 1 然后按照相同流程处理StartTag headStartTag titledom start step 2 接下来,解析器会遇到第一个文本 Token,解析器会它创建一个文本节点,并且该节点加入到 DOM 中: dom start step 3 然后遇到第一个EngTag title,这时解析器会判断当前栈顶元素是不是StartTag title,如果是则弹出StartTag titletitle节点解析完成: dom start step 4 后续步骤以此类推,就能得到完整版 DOM 树: dom start step 5 通常情况下,渲染器在解析 HTML 时很少甚至几乎不会报错,这是因为解析器对 HTML 中的语法错误进行容错处理,详解见容错处理

样式计算

样式计算的目的是计算出每一个 DOM 节点的具体样式,光有 DOM 是不够的,HTML 文件中除了会有 dom 节点,还会存在 CSS 样式数据,CSS 数据可能来自外联的 link 标签,内联的 style 标签或者行内 style 属性。与处理 HTML 的过程类似,我们也需要将 CSS 数据转换为浏览器能理解和处理的数据类型,并连接到 CSSOM(CSS Object Model) 的树形结构中:

css build process 以下面代码为例:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Renderer Process</title>
    <style>
      body {
        font-size: 16px;
      }
      div {
        color: royalblue;
        border: 1px solid;
      }

      span {
        font-weight: bold;
      }

      div span {
        display: none;
      }
    </style>
  </head>
  <body>
    <div>Text <span>invisible span</span></div>
    <span>visible span</span>
  </body>
</html>

我们现在只关注 css 部分,解析器会解析 style 标签内的样式代码,并最终生成的 CSSOM:

cssom

其中黑体的 style 是该节点自身的样式,灰色 style 是该节点继承自父节点的样式

当然,实际情况的 CSSOM 远比这个示例要复杂的多,还会涉及到 class 及 id 的样式。并且就算用户未提供任何样式,浏览器也提供了默认的样式表(User Agent),我们可以通过 chrome 的 element 标签页中 Styles 页面查看节点的所有样式来源:

user agent 也可以通过 chrome 的 Elements 标签页中 Computed 页面查看节点的最终的样式:

computed style

布局计算

到现在为止,我们已经有了 DOM 和 CSSOM,渲染进程已经知道了页面的文档结构以及每个节点所拥有的的样式。可这些信息还不足以确定页面的最终样子。因为我们还不知道每个节点具体的大小尺寸和应该在页面上的位置。所以布局计算的工作就是根据 DOM 和 CSSOM 计算出一个布局树(layout tree)。布局树(也被称为渲染树(render tree))包含了每个可视节点的几何信息。布局计算主要分为两个阶段:

  1. 构建布局树
  2. 布局计算

构建布局树

DOM 树中存在许多页面不可见的节点,如head标签中的节点,以及样式为display:none的节点,这些不可见的节点都不需要出现在布局树中。布局树只会把页面可见的节点纳入其中;并且如果一个节点有伪类属性,如div::before{content:"hi"},该伪类节点也会被纳入布局树中:

render tree

注意样式为visible:hidden的节点依旧会出现在布局树中,因为它虽然不可见,但依旧会占据一定空间,对布局有影响。

布局计算

现在,我们已经有一个完整的布局树,接下来就是计算每个节点的坐标,大小等几何信息。由于 CSS十分强大且复杂,它能使某个节点浮动(float)或者改变定位(position)等等。所以即使计算布局树结构十分简单,整个布局计算过程也十分复杂。在执行布局计算时,会把计算结果重新写到布局树中。

layout viewport

分层

到现在为止,我们已经知道了页面上每个节点的几何信息,那么是不是开始开始绘制操作了呢?答案是否定的。因为页面中可能有很多复杂的效果。如果一个节点覆盖在另一个节点之上(z-index),又或者 3D 变换会让该节点的几何坐标一直变动。为了能更好实现这些效果,并让渲染引擎更高效的绘制这些节点。渲染引擎会为特定的节点生成专用的图层,并生成一个图层树(layer tree)。最终页面的效果是这些不同图层叠加在一起得到的,这一点和 PS 中图层概念非常相似。 layer tree 我们可以通过 devtools 中的 Layers 标签页查看一个页面具体有哪些层:

layers 通常情况下,并不是布局树的每个节点都会生成一个图层,如果一个节点没有对应的图层,那该节点从属于父节点的图层。要生成一个单独图层一般要满足:

  • 拥有层叠上下文属性(如 z-index,position,will-change 等)
  • 拥有需要剪裁(clip)的区域

需要注意的是:即使满足上诉条件,渲染引擎也只会在必要时把节点提升为一个单独层。因为形成的层级越多,开销也越大。

以上面代码为例,在出现裁剪时,渲染引擎会为容器和内容分别单独创建一个层;如果有滚动条,滚动条也会被提升为一个单独的层:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Renderer Process</title>
    <style>
      div {
        width: 300px;
        height: 300px;
        border: 1px solid #000;
        background-color: skyblue;
        overflow: scroll;
      }
    </style>
  </head>
  <body>
    <div>Lorem ipsum dolor sit amet consectetur....<!-- 还有很多文字 --></div>
  </body>
</html>

clip area

绘制

在图层树构建完成之后,终于到了图层绘制阶段,尽管这个阶段叫做绘制,但是并不是真正的将各个节点绘制到页面上,而是生成绘制指令。

举个例子:如果要在一张白纸上中央画一个红色圆形和绿色矩形,那么我们可能有以下步骤:

  1. 首先在中央画一个圆形

  2. 把圆形涂成红色

  3. 再在中央画一个矩形

  4. 把矩形涂成绿色

上面这 4 步就是绘制指令,这些绘制过程指令就像是我们使用 canvas 绘图过程中的指令一样。渲染引擎做的事与之类似,会为每个图层分别生成一系列绘制指令,然后把这些指令组成一个绘制列表。这些绘制列表就是绘制阶段的输出结果。

我们同样可以在 Layers 标签页看到这些绘制列表:

paint

光栅化(Raster)

到现在为止,我们已经得到了完整的图层树,知道了所有可视节点的几何信息以及它们的绘制顺序。那么现在就到了真正的绘制阶段。浏览器将这些信息转换为页面实际显示像素的过程就叫做光栅化。需要注意的是光栅化是在合成线程中进行,而不是主线程。

分块

当合成线程线程接收到图层信息时,合成线程会光栅化图层树的每一层。但由于图层树的某一层可能非常大,有可能有整个网页那么大。所以合成线程需要将它们切分成一个个小图块(tiles),这些图块大小通常是256x256或者512x512

光栅化

然后合成线程会将图块发送给一系列光栅线程(raster threads)。光栅线程会栅格每一个图块,并把它们存在 GPU 内存中(光栅化过程都会使用 GPU 加速生成)。合成线程可以赋予不同光栅线程不同的优先级,进而使视口内或者视口附近的页面首先被光栅化。

tiles

合成

一旦所有高优先级图块被栅格化后,合成线程会收到DrawQuad命令,然后根据该命令构建一个合成帧,并将它绘制到内存中。完成绘制后,合成线程会合成帧提交给浏览器主进程,主进程最终将它绘制到页面上。

到这里,经过这一系列的阶段,浏览器就将编写好的 HTML、CSS、JavaScript 等文件转换为漂亮的页面了。

render process 以上所有的内容都没有涉及到 HTML 中存在外部资源的情况,下面我们就会一一讨论 HTML 中存在外部链接时,渲染引擎会如何操作。

下文所有实例代码都在render-process-testing 仓库中。

HTML 解析器并不是等整个文档下载完成后再解析,而是网络进程加载了多少数据,HTML 就解析多少数据,这就意味着 DOM 树构建增量进行的。并且渲染引擎有一个预解析操作,当渲染引擎接受到HTML数据后,会开启一个预解析线程,用来分析 HTML 中包含的外部资源链接,在解析到相关文件后会提前开始下载这些文件。

在下面例子(increment.html文件)中,HTML 文件中包含了 10000 个div节点,并且我们将网络速度调慢到Slow 3G,整个 HTML 文件下载花了 5 秒,页面内容逐步被渲染到页面上。

increment html

CSS 如何影响 CRP

Head 中的 CSS

渲染进程在接收到 HTML 数据之后就会开始解析 HTML 并构建 DOM 树,当预解析线程遇到了外部 CSS 文件<link rel="stylesheet" href="*url*"/>后,会在后台静默下载 CSS 文件。当 HTML 解析器解析到 link标签时,不管此时该 CSS 文件是否下载完成,HTML 会继续解析,不会阻塞 DOM 树的构建。如果 DOM 树构建完成后,CSS 文件还未下载完成,渲染引擎就必须等待该文件下载完成,才能进行布局计算以及后续 CRP 流程,因为布局计算同时依赖于 DOM 和 CSSOM,因此页面不会显示任何内容。此时 CSS 被视为阻塞渲染的资源(Render Blocking)。如果有多个 CSS 文件,那么渲染引擎会等到所有 CSS 下载完成后才开始构建 CSSOM。

在下面例子(render-blocking.html)中,HTML 文件中包含了 2 个外联 CSS 文件。一个需要 1 秒下载完成,另一个需要 3 秒。页面会在 3 秒后才显示内容,因为只有2个 CSS 文件都被下载完成后,才能会开始 CSSOM 构建并继续 CRP 流程。

render blocking 正因为 CSS 会阻塞页面渲染,所以最佳实践把 CSS 的link标签发在head中,让 CSS 尽早被下载完成

前面提到 HTML 解析器对 DOM 树构建是增量进行的,那为什么不能对 CSSOM 树也进行增量构建,而是要等到 CSS 下载完成,再来构建 CSSOM 树呢?这是因为 CSS 独特的层叠属性,这就意味着的样式表前面定义的样式可能会被后续重写或者增加其他样式,如果对 CSSOM 也进行增量,虽然不断接收CSS数据,CSSOM 会被多次改变甚至重写,那么会导致页面被多次渲染,出现样式抖动。这会带来不好的用户体验。

Body 中的 CSS

如果将link放在body中会发生什么呢?下面例子(put-link-in-body.html)中,存在 3 个 CSS 文件,第 1 个需要 1 秒才能下载完成,第 2 个需要 3 秒,第 3 个需要 5 秒。

<body>
  <div></div>
  <div></div>
  <link rel="stylesheet" href="/css/div-red.css?delay=1000" />
  <div></div>
  <div></div>
  <link rel="stylesheet" href="/css/div-yellow.css?delay=3000" />
  <div></div>
  <div></div>
  <link rel="stylesheet" href="/css/div-blue.css?delay=5000" />
  <div></div>
  <div></div>
</body>

put link in body 将所有link标签放在body中,那么link会阻塞 DOM 树的构建(Paser Blocking),直到link对应的 CSS 资源被下载完成后,才会恢复 DOM 树构建。此时 CSS 被视为阻塞解析的资源。

  • 到第 1 秒为止,DOM 解析被暂停到第 1 个link标签,此时 DOM 树包含 2 个div节点,然后会使用已有样式(内联样式或者 User Agent)构建 CSSOM,并完成后续 CRP 流程。所以此时页面只会包含两个div元素,且无背景色。
  • 到第 3 秒为止,DOM 解析被暂停到第 2 个link标签,此时第 1 个 CSS 文件已经下载完成,所以 DOM 树包含 4 个div节点,并且 CSSOM 也因为第1个CSS文件的内容被更新了,所以此时页面包含 4 个红色背景div元素
  • 后续以此类推

如果headbody中同时存在外联CSS又会怎样?下面例子(put-link-in-head-and-body.html)中,head中存在 1 个 CSS 文件,body中存在 2 个 CSS 文件,且head中 CSS 文件能立即下载完成,而body中 CSS 第 1 个需要 3 秒,第 2 个需要 5 秒。

<head>
  <link rel="stylesheet" href="/css/div-red.css" />
</head>
<body>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <link rel="stylesheet" href="/css/div-yellow.css?delay=3000" />
  <div></div>
  <div></div>
  <link rel="stylesheet" href="/css/div-blue.css?delay=5000" />
  <div></div>
  <div></div>
</body>

put link in head and body 可以看到页面 3 秒后才有内容被绘制出来,即在body中第 1 个样式表下载完成后。所以此时body中第 1 个 CSS 文件和在head中效果一致,都是阻塞渲染的资源。而第二个 CSS 文件则是阻塞 DOM 构建,和上一个例子情况一致,是阻塞解析的资源。

虽然link能被放到body中,但是它会造成解析阻塞和样式抖动(Flash Of Unstyled Content)。这是不好的用户体验,所以通常不建议把link能把放到body中。

减少白屏时长

head中的 CSS 默认属于渲染阻塞资源,会造成页面加载时出现白屏,要缩短白屏时长,通常策略是:

  1. 内联 CSS,消除单独 CSS 下载等待时间
  2. 减少 CSS 文件体积,删除不必要的样式或者做代码压缩,减少网络传输所消耗的时间
  3. 使用媒体查询属性,将 CSS 文件按不同用途拆分。这样,只有当前情况下必需的 CSS 文件才成为渲染阻塞资源,其他非必需的 CSS 文件则不会阻塞渲染

前 2 点很容易理解,我们着重看看第 3 点。在下面例子(link-with-media.html)中,head中存在 2 个 CSS 文件,第 1 个会立即被下载完成;第 2 个需要等待 5 秒,但只在页面宽度小于768px才会生效。

<head>
  <link rel="stylesheet" href="/css/div-red.css" />
  <link rel="stylesheet" href="/css/div-yellow.css?delay=5000" media="(max-width:768px)" />
</head>

link with media 此时页面宽度是800px,所以第 2 个 CSS 文件不是当前必需的资源,所以它不会阻塞渲染,所以页面立即就能绘制内容。媒体查询只是告诉浏览器当前 CSS 是不是阻塞渲染资源,不会影响文件下载。如果当前页面宽度是700px,那么第 2 个 CSS 文件就会阻塞渲染,页面在 5 秒后才能绘制内容。

link with media hit 如果 CSS 文件没有媒体查询属性,等价于media="all"

简单总结一下:

  1. head中的 CSS 文件默认是渲染阻塞资源,可以通常媒体查询控制当前非必需的文件不阻塞渲染
  2. 如果只有body存在外联 CSS 文件,则所有的 CSS 文件默认都是解析阻塞资源
  3. 如果在headbody同时存在外联 CSS 文件,则head所有的 CSS 文件以及body中第 1 个 CSS 文件默认都是渲染阻塞资源,body中剩余的 CSS 文件默认都是解析阻塞资源

JS 如何影响 CRP

首先抛出结论,JS 默认是解析阻塞资源。如果 HTML 解析器解析到script标签,会暂停 DOM 树构建;然后 JS 引擎介入并执行script标签中的代码。当脚本执行完毕后,HTML 解析器会继续解析工作。

在下面例子中,内联了一个script脚本,用来获取当前 DOM 中div标签的个数。可以猜猜这里打印的数字。

<body>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <script type="text/javascript">
    const divList = document.getElementsByTagName("div");
    console.log("div count is", divList.length);
  </script>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
</body>

实际打印的结果是4,因为当执行脚本时,DOM 树的构建会停止在script标签,此时 DOM 树中只包含了前 4 个div标签。

如果我们将内联 JS 改为外联 JS 文件,那么当 HTML 解析器解析到script标签时,首先要等待 JS 文件下载完成,再执行脚本代码,然后再继续解析。

在下面例子(parser-blocking.html)中外联 JS 文件需要 3 秒才能下载完成,那么 DOM 树构建就会被阻塞 3 秒。

<body>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <script type="text/javascript" src="/js/log-div-count.js?delay=3000"></script>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
</body>

parser blocking 如果在script标签之前,还有外联 CSS,并且该CSS资源是阻塞渲染的资源。那么渲染引擎会先等待外联 CSS 下载完毕后。才会开始执行脚本代码,如果解析到script标签是,外联 JS 还未下载完成,则继续等待。此时的 CSS 文件被称为阻塞脚本的资源(script blocking)。

在下面例子(script-blocking.html)中,head中的外联 CSS 需要 5 秒才能下载完成,body中的外联 JS 只需要 1 秒。

<head>
  <link rel="stylesheet" href="/css/div-yellow.css?delay=5000" />
</head>
<body>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <script type="text/javascript" src="/js/log-div-count.js?delay=1000"></script>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
</body>

script blocking 可以看到控制台在 CSS 文件被下载完成后,才打印出结果。如果我们给该外联的 CSS 文件加上媒体查询属性,使它变成非渲染阻塞资源,那么该 CSS 便不会阻塞 JS 的执行,所以控制台会在 1 秒后打印结果。

script blocking with unnecessary css 为什么渲染引擎会在执行 JS 时,阻塞 DOM 树的构建呢?因为 JS 具有操纵 DOM 的能力,所以在执行代码的过程中可能会改变 DOM 结构,如果此时 DOM 树构建在并行执行,两者同时对 DOM 进行操作就会出现一个竞态问题。类似于多线程访问共享资源必须加锁一样,所以安全起见,在执行 JS 时才要阻塞解析。

为什么 CSS 能阻塞 JS 执行?因为 JS 操纵 DOM ,这代表JS除了能更改DOM结构还能更改样式。比如我们可以获取和更改某些节点的背景色。如果 CSS 不阻塞 JS 执行,JS 在执行中改变了某个节点样式,之后 CSS 下载完成后也改变了该节点样式同样会出现竞态问题。并且也是为了JS在执行时能获得正确的样式信息,所以最安全的方式是等 CSS 下载完成后,再执行 JS。而非渲染阻塞 CSS 不会阻塞 JS 执行是因为该文件并不会被影响到当前页面样式,所以不存在这些问题,所以 JS 执行不会等待这种类型的 CSS 文件。

减少 JS 阻塞

当 HTML 解析器遇到script标签时,它并不知道该脚本是否改变了 DOM 和CSSOM。所以安全起见,会有上诉的阻塞过程。如果我们的脚本中并没有对 DOM 的操作,或者我们并不希望有类似的阻塞发生。可以通过asyncdefer标记将 JS 脚本设置为异步加载。

虽然asyncdefer都是异步加载,但async标记的 JS 文件一旦下载完成就会立即执行,如果下载完成 DOM 还没构建完成,还是会阻塞 DOM 解析;而defer会等到 DOM 完成构建后,在DOMContentLoaded事件触发前执行。详见MDN 文档

在下面这个例子(async-load-script.html)中,CSS 文件需要 3 秒才能下载完成,第 1 个 JS 文件需要 1 秒且被标记为async,第 2 个 JS 文件需要 4 秒且被标记为defer

<head>
  <link rel="stylesheet" href="/css/div-yellow.css?delay=3000" />
</head>
<body>
  <div></div>
  <div></div>
  <div></div>
  <script type="text/javascript" src="/js/async-script.js?delay=1000" async></script>
  <div></div>
  <div></div>
  <div></div>
  <script type="text/javascript" src="/js/defer-script.js?delay=5000" defer></script>
  <div></div>
  <div></div>
</body>

async script 可以看到此时 JS 的文件不再阻塞 DOM 树的构建,CSS 也不再阻塞 JS 的执行,所以async标记的 JS 文件在第 1 秒完成下载后就被执行。并且在第 3 秒 CSS 下载完成后,页面就可以显示完整的内容。defer标记的 JS 文件会推迟DOMContentLoaded事件,所以在第 5 秒后,JS 文件下载完成执行后,才触发DOMContentLoaded事件。

简单总结一下:

  1. JS 文件默认是解析阻塞资源,如果 HTML 解析器遇到script标签,便会停止解析,然后等在 JS 文件下载完成,JS 引擎执行完脚本代码后,才能恢复解析
  2. 如果在script标签之前还有外联的阻塞渲染的 CSS 文件,则JS引擎必须等到这些CSS文件下载完成,才能开始 JS 的执行
  3. 可以使用asyncdefer关键字异步加载 JS 文件

重排,重绘,合成

如果 JS 和 CSS 改变了页面结构,渲染引擎就会重新触发 CRP 流程。重新触发整个流程的消耗无疑是巨大的,所以渲染引擎会根据页面改变的方式,只重新触发必要的步骤。页面更新方式大体上可以分为重排,重绘和合成三种类型。

重排(Reflow)

如果我们改变了某个节点的尺寸等几何信息,则会触发从样式计算到后续的所有 CRP 流程,这个更新过程就叫做重排。这种更新消耗无疑是最大的。

基本上,只要页面结构发生改变或者节点几何信息发生改变都会触发重排。主要包括:

  • 插入,移除或者移动 DOM 节点
  • 改变页面文本内容或者文本字体等属性,如text-alignfont-sizeline-height
  • 更改 DOM 节点定位属性,如topfloatposition
  • 获取 DOM 节点几何信息,如getComputedStyleoffsetHeight
  • 更改 DOM 节点类名,或者激活其伪类属性,如:hover
  • 更改窗口大小或者滚动页面

reflow

重绘(Repaint)

如果只是更改页面的样式风格,如background-colorcolorvisibility等不涉及到几何信息更改的属性,则不需要重新触发布局计算和分层,而是直接进入绘制阶段。所以执行效率会比重排高一些。

repaint

了解哪些属性改变会触发重排和重绘详见该列表

如果我们通过 JS 改变改变了 DOM 结构,然后又尝试重新获取 DOM 节点属性,为了能够让 JS 获取到正确的属性值,渲染引擎会提前执行布局操作。这被称为强制同步布局。更糟糕的是,如果我们在一个循环中频繁触发强制同步布局,可能会出现布局抖动(layout thrashing)。

以下面代码为例,我们在DOMContentLoaded事件回调中通过脚本创建了 100 个div节点加入到``body`中,并且每添加一个节点后都重新获取页面的高度。

<script type="text/javascript">
  document.addEventListener("DOMContentLoaded", function () {
    console.time("add items");
    for (let i = 0; i < 100; i++) {
      const node = document.createElement("div");
      document.body.appendChild(node);
      console.log("body height", document.body.offsetHeight);
    }

    console.timeEnd("add items");
  });
</script>

layout-thrashing 可以看到整个过程花废了 27 毫秒,我们再通过 Performance 工具查看一下具体执行过程:

layout thrashing 可以看到渲染引擎执行了多次样式计算和布局计算操作,如果我们移除获取页面的高度那一行代码,整个过程只需要 0.4 毫秒,并且也不会再有强制布局同步现象出现。

layout thrashing gone 在我们日常开发中,一定要注意尽量避免出现强制布局同步,否则很有可能会出现性能问题。关于这方面很容易找到可行的方案,所以不再展开。

合成(Composite)

如果是通过transform属性实现动画效果,可以避开重排和重绘阶段,直接在合成线程上执行动画操作,不需要占用主线程资源。这是效率最高的绘制方式。这也是为什么即使在主线程被卡住时,CSS 动画也能流畅运行的原因。

composition 以下面代码为例,页面只包含一个上下移动的div节点。

<style>
  div {
    width: 80px;
    height: 80px;
    border: 1px solid #000;
    animation: bounce 1s infinite alternate;
  }

  @keyframes bounce {
    from {
      transform: translateY(0);
    }

    to {
      transform: translateY(100px);
    }
  }
</style>
<body>
  <div></div>
</body>

css animation 可以看到即使我们通过代码阻塞了主线程,动画也能正常运行。

如果对本文有什么意见和建议,欢迎讨论和指正!