浏览器渲染全过程以及常遇到的问题

Introduction

我们应该都很清楚浏览器的渲染过程如下:

render flow

具体步骤如下:

  1. HTML -> DOM
  2. CSS -> CSSOM
  3. DOM + CSSOM -> Render Tree
  4. Layout
  5. Paint
    
    对于每个浏览器都会有一个浏览器引擎作为核心去执行这些步骤,火狐有Gecko,Chrome有基于Webkit的Blink。

接下来我们来一步步看上面的几个步骤

发送和接收信息

数据是以“字节”为单位在网络中传输的。当我们在浏览器中打开HTML网页,浏览器是读取在我们硬盘中(或者网络中)HTML的原始字节。

bytes of data

划重点:浏览器读取的是数据的原始字节,而不是我们所写的代码。

浏览器接收数据的字节,但它无法真正做任何事情。
必须将原始字节数据转换为它理解的形式。
这是第一步。
重点-1

DOM 构成

将HTML的原始字节转为DOM

浏览器对象需要的是文档对象模型(DOM)对象,那么DOM对象是怎么来的呢?

实际上由字节到DOM的过程如下:

From Tokens to Nodes and the Dom

假设现在我们有这么一个HTML文件:

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link href="style.css" rel="stylesheet">
<title>Critical Path</title>
</head>
<body>
<p>Hello <span>web performance</span> students!</p>
<div><img src="awesome-photo.jpg"></div>
</body>
</html>

主要的流程如下:

fill process

  1. 转换: 浏览器从硬盘或者网络读取到HTML的原始字节数据之后,就会把它根据文档中指定的编码把它转换成对应字符(例如,UTF-8)
  2. 权杖化: 浏览器会根据W3C HTML5标准把字符串转换为不同的令牌(tokens)。例如<html><body>和其他被尖括号包含的字符串。每个令牌都有特殊的含义和属于它自己的一套规则。
  3. 词法分析: 发出的令牌转换成定义其属性和规则的“对象”
  4. DOM构建: 最后,因为HTML的标记定义了不同标签之间的关系(一些标签会被包含在其他的标签中),创建的对象链接在一个树数据结构内,此结构也会捕获原始标记中定义的父子关系:HTML 对象是 body 对象的父项,body 是 paragraph 对象的父项,依此类推。

整个流程的最终输出是我们这个HTML页面的文档对象模型 (DOM),浏览器对页面进行的所有进一步处理都会用到它。

根据html文件的大小和复杂程度,DOM的构建过程会花费一点时间。无论多少的文件,都会花费响应的时间。具体消耗时间可以看chrome dev tool中看到。
dom timeline

如果您打开 Chrome DevTools 并在页面加载时记录时间线,就可以看到执行该步骤实际花费的时间。在上例中,将一堆 HTML 字节转换成 DOM 树大约需要 5 毫秒。对于较大的页面,这一过程需要的时间可能会显著增加。创建流畅动画时,如果浏览器需要处理大量 HTML,这很容易成为瓶颈。

那么,CSSOM呢?

html文件的CSS链接方式如下所示:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" type="text/css" media="screen" href="main.css" />
</head>
<body>

</body>
</html>

当浏览器接收数据原始字节并构建DOM的时候,遇到link标记,标记引用了外部的CSS样式表,那么它就立即会发出请求来获取链接的main.css样式表。
一旦浏览器开始解析html,在找到css文件的链接标记后,它就会发出获取该请求的请求。之后同样的,会先获取文件的数据原始字节。

由CSS原始字节到CSSOM

大致步骤跟DOM相当的类似,同样会经过Characters -> Tokens -> Node 的步骤。
同样会用树的结构来把Nodes构建成一棵树,称为CSS Object Model,CSSOM。

CSSOM

CSS还有一样东西叫Cascade。这个Cascade是浏览器确定元素使用什么样式的方式。

由于元素样式可能会受到它的父元素影响。例如继承或者在元素本身上去设置,因此CSSOM的树结构就更显得重要。

为什么?

css tree

因为浏览器必须递归遍历CSS树结构并且决定影响特定元素的样式。
以上面的 CSSOM 树为例进行更具体的阐述。span 标记内包含的任何置于 body 元素内的文本都将具有 16 像素字号,并且颜色为红色 — font-size 指令从 body 向下级联至 span。不过,如果某个 span 标记是某个段落 (p) 标记的子项,则其内容将不会显示。

CSSOM构建消耗的时间如下:
css tree

好的,那么现在我们有DOM和CSSOM了,可以开始渲染了。

The render tree (渲染树)构建、Layout(布局)和绘制

有了DOM和CSSOM,如何将两者合并,让浏览器在屏幕上渲染像素呢?

渲染步骤如下:

  • DOM和CSSOM合并后形成Render Tree.
  • 渲染树只包含渲染网页所需要的点
  • 布局计算每个对象的精确位置和大小

渲染树构建

怎么能够把两个看起来没有共同目标的独立树结构结合在一起呢?
DOM和CSSM两棵树有着两个独立的结构。
DOM包含所有页面中的HTML元素的关系,而CSSOM包含的是如何设置元素样式的信息。
然后浏览器需要做的是把它们两棵树合并到一棵中,称为渲染树.
渲染树

渲染树包含页面中所有可视的DOM内容信息,以及不同节点所需要的CSSOM信息。

css tree
为构建渲染树,浏览器大体上完成了下列工作:

  1. 从 DOM 树的根节点开始遍历每个可见节点。
    • 某些节点不可见(例如脚本标记、元标记等),因为它们不会体现在渲染输出中,所以会被忽略。
    • 某些节点通过 CSS 隐藏,因此在渲染树中也会被忽略,例如,上例中的 span 节点—不会出现在渲染树中,—因为有一个显式规则在该节点上设置了display: none属性。
  2. 对于每个可见节点,为其找到适配的 CSSOM 规则并应用它们。
  3. 发射可见节点,连同其内容和计算的样式。

Note: 简单提一句,请注意 visibility: hidden 与 display: none 是不一样的。前者隐藏元素,但元素仍占据着布局空间(即将其渲染成一个空框),而后者 (display: none) 将元素从渲染树中完全移除,元素既不可见,也不是布局的组成部分。

等渲染树构建完毕,浏览器就会进入下一个步骤!Layout(布局)

布局(reflow)

布局阶段,也被称为reflow阶段,其实我们可以把浏览器页面展示本身想成是一个canvas,我们展示指定组件到指定位置就要计算出它的实际位置很大小,同样的概念放在浏览器布局阶段同样合适。

考虑一下下面一个实例:

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Critial Path: Hello world!</title>
</head>
<body>
<div style="width: 50%">
<div style="width: 50%">Hello world!</div>
</div>
</body>
</html>

包含了两个嵌套的div,第一个div的显示宽度设为了视窗宽度的50%,第二个div把宽度设置成了父div的50%,也就是视窗宽度的25%。

布局流程的输出是一个“盒模型”,它会精确地捕获每个元素在视口内的确切位置和尺寸:所有相对测量值都转换为屏幕上的绝对像素。

到这里,我们已经知道了那些节点可见,它们的样式以及几何信息,就可以进行左右一步:把渲染树的每个节点转换成屏幕上的实际像素。

Layout消耗的时间可以在这里查看:
css tree

  • “Layout”事件在时间线中捕获渲染树构建以及位置和尺寸计算。
  • 布局完成后,浏览器会立即发出“Paint Setup”和“Paint”事件,将渲染树转换成屏幕上的像素。

绘制(repaint)

最后一步,根据上面计算到的信息绘制像素点。

阻塞渲染

css block

如上图所示,如果CSSOM未准备就绪前就进行Render Tree构建和布局渲染的话,得到的上图结果其实是毫无意义的(太丑)。因此CSSOM构建的时候会进行阻塞渲染,意思就是CSSOM未构建完毕前,才能构建渲染树。(当然如果DOM未构建完也会有同样问题,所以DOM也会阻塞渲染)

因此,我们必须尽量精简我们的CSS,或者使用medio属性设置阻塞的条件:

1
2
3
<link href="style.css" rel="stylesheet">
<link href="print.css" rel="stylesheet" media="print">
<link href="other.css" rel="stylesheet" media="(min-width: 40em)">

第一个连接样式没有生命media,说明会始终阻塞渲染。
第二条设置了print,就是说在打印内容的时候才会阻塞渲染。
第三条则设置了(min-width: 40em),只有在宽度大于40em的时候才会阻塞渲染。

为什么要把JS放在最后

为什么会使渲染延迟

DOM构建并不是一口气完成的,当 HTML 解析器遇到一个 script 标记时,它会暂停构建 DOM,将控制权移交给 JavaScript 引擎;等 JavaScript 引擎运行完毕,浏览器会从中断的地方恢复 DOM 构建。也就是说:执行我们的内联脚本会阻止 DOM 构建,也就延缓了首次渲染。

同样,如果浏览器尚未完成 CSSOM 的下载和构建,而我们却想在此时运行脚本,会怎样?答案很简单,对性能不利:浏览器将延迟脚本执行和 DOM 构建,直至其完成 CSSOM 的下载和构建。

因此,Javascript在DOM,CSSOM执行之间引入了大量新的依赖关系,会引起浏览器的处理因而在渲染时候出现大幅的延迟:

  • 脚本在文档中的位置很重要。
  • 当浏览器遇到一个 script 标记时,DOM 构建将暂停,直至脚本完成执行。
  • JavaScript 可以查询和修改 DOM 与 CSSOM。
  • JavaScript 执行将暂停,直至 CSSOM 就绪。

如果是外链JS呢?

结果其实跟内联Javascript一样,构建都会停下来先执行JS,然后再继续构建。如果是外部 JavaScript 文件,浏览器必须停下来,等待从磁盘、缓存或远程服务器获取脚本,这就可能给关键渲染路径增加数十至数千毫秒的延迟。

defer和async属性

针对上面的问题,浏览器也提供了deferasync属性来减少这类问题,

1
2
3
4
<script src="app.js" defer></script>
<script src="app.js" async></script>

那么它们之间的区别是?

defer and async

  • 如上图所示,正常的script标签定义的JS,会在主线程中下载完以后马上执行,从而堵塞后面的DOM和CSSOM构建。
  • 加了async属性之后,该script就会并行下载JS文件,并且在下载完后马上执行,执行的时候同样会堵塞后面的DOM和CSSOM构建。而且,使用了async之后,无法确定脚本的执行顺序!!
  • defer的话script也会并行下载JS文件,但是下载完后并不会马上执行,会等到DOM和CSSOM构建完成后再执行。

那么用defer还是把script放在body底部呢

参考文章[3]中Chris给出的一个结论:我们不能相信defer。
理由很简单,不同浏览器标准在打架。不同浏览器对defer实现的标准都存在差异而引起的问题:
1.在某些情况下,某些浏览器会出现导致延迟脚本无序运行的错误。
2.有些浏览器延迟了DOMContentLoaded事件,直到加载了延迟脚本,有些浏览器没有。
3.有些浏览器遵循使用内联代码并且没有src属性的<script>元素的延迟,有些浏览器会忽略它。
当然了,这个已经是12年的回答,近6年的发展也许浏览器的标准都已经统一好了也说不准(喜欢特立独行的IE已经离开了我们),有空的话可以做一次实验。

而Google的开发Guide里面已经建议使用异步加载属性了,至少Chrome中使用是没问题的了。

在此之前,把script放在body底部还是最稳妥的方法。

关于CSS的优化

CSS 是构建渲染树的必备元素,首次构建网页时,JavaScript 常常受阻于 CSS。确保将任何非必需的 CSS 都标记为非关键资源(例如打印和其他媒体查询),并应确保尽可能减少关键 CSS 的数量,以及尽可能缩短传送时间。

此处只列举简单的几种CSS优化方式,会另开一篇文章更详细记录

将 CSS 置于文档 head 标签内

尽早在 HTML 文档内指定所有 CSS 资源,以便浏览器尽早发现 标记并尽早发出 CSS 请求。

避免使用 CSS import

一个样式表可以使用 CSS import (@import) 指令从另一样式表文件导入规则。不过,应避免使用这些指令,因为它们会在关键路径中增加往返次数:只有在收到并解析完带有 @import 规则的 CSS 样式表之后,才会发现导入的 CSS 资源。

内联阻塞渲染的 CSS

为获得最佳性能,您可能会考虑将关键 CSS 直接内联到 HTML 文档内。这样做不会增加关键路径中的往返次数,并且如果实现得当,在只有 HTML 是阻塞渲染的资源时,可实现“一次往返”关键路径长度。

总结

这篇文章主要讲述了浏览器的渲染步骤,以及一些常见的渲染相关问题,优化等。

参考文章

  1. How browser rendering works — behind the scenes
  2. Constructing the Object Model
  3. Why don’t use defer