浏览器是如何工作的 下 布局与绘制

上篇传送门:浏览器是如何工作的 上 解析与呈现

前言

本文主要翻译于Tali Garsiel在2009年10月的一篇介绍Webkit和Gecko内核的经典文章How browsers work。尽管在面试和工作上用不到这么细节,但是学习浏览器的内部原理将让我们可以更好地理解一些最优开发实践的道理。

布局

呈现器在创建完成并添加到呈现树时,并不包含位置和大小信息。计算这些值的过程称为布局或重排。

HTML 采用基于流的布局模型,这意味着大多数情况下只要一次遍历就能计算出几何信息。处于流中靠后位置元素通常不会影响靠前位置元素的几何特征,因此布局可以按从左至右、从上至下的顺序遍历文档。但是也有例外情况,比如HTML表格的计算就需要不止一次的遍历。

布局是一个递归的过程。它从根呈现器(对应于HTML文档的<html>元素)开始,然后递归遍历部分或所有的框架层次结构,为每一个需要计算的呈现器计算几何信息。

根呈现器的位置是0,0,其尺寸为视口(也就是浏览器窗口的可见区域)。所有的呈现器都有一个layout或者reflow方法,每一个呈现器都会调用其需要进行布局的子代的layout方法。

Dirty Bit系统

为避免对所有细小更改都进行整体布局,浏览器采用了一种dirty bit系统。如果某个呈现器发生了更改,或者将自身及其子代标注为dirty,则需要进行布局。

有两种标记:dirtychildren are dirtychildren are dirty表示尽管呈现器自身没有变化,但它至少有一个子代需要布局。

全局与增量布局

全局布局是指触发了整个呈现树范围的布局,触发原因可能包括:

  • 影响所有呈现器的全局样式更改,例如字体大小更改(因此这里提到的自适应JavaScript代码一定要放在body前,否则会有白屏闪动现象出现)。
  • 屏幕大小调整,即resize事件。

增量布局是指只对dirty呈现器进行布局(这样可能存在需要进行额外布局的弊端)。当呈现器为 dirty 时,会异步触发增量布局。例如,当来自网络的额外内容添加到 DOM 树之后,新的呈现器附加到了呈现树中。

增量布局是异步执行的。Firefox 将增量布局的”reflow命令”加入队列,而调度程序会触发这些命令的批量执行。WebKit也有用于执行增量布局的计时器:对呈现树进行遍历,并对dirty呈现器进行布局。请求样式信息(例如offsetHeight)的脚本可同步触发增量布局。

如果布局是由“大小调整”或呈现器的位置(而非大小)改变而触发的,那么可以从缓存中获取呈现器的大小,而无需重新计算。
另外,在某些情况下,只有一个子树进行了修改,因此无需从根节点开始布局。这适用于在本地进行更改而不影响周围元素的情况,例如在文本字段中插入文本(否则每次键盘输入都将触发从根节点开始的布局)。

布局过程

布局过程通常遵守下面的模式:

  1. 父呈现器确定自己的宽度。
  2. 父呈现器依次处理子呈现器,并且:
    1. 放置子呈现器(设置 x,y 坐标)。
    2. 如果有必要,调用子呈现器的布局(如果子呈现器是dirty的,或者这是全局布局,或出于其他某些原因),这会计算子呈现器的高度。
  3. 父呈现器根据子呈现器的累加高度以及边距和补白的高度来设置自身高度,此值也可供父呈现器的父呈现器使用。
  4. 将其 dirty 位设置为 false。

宽度和换行

呈现器宽度是根据容器块的宽度、呈现器样式中的“width”属性以及边距和边框计算得出的。例如,下面有一个div标签:

1
<div style="width:30%"/>

Webkit将像下面一样计算它的宽度(RenderBox类的calcWidth方法):

  1. 容器的宽度取容器的availableWidth和0中的较大值。availableWidth在本例中相当于contentWidth
  2. 元素的宽度是width样式属性。它会根据容器宽度的百分比计算得出一个绝对值。
  3. 然后再加上水平方向的边框和补白。

现在计算得出的是preferred width。然后需要计算最小宽度和最大宽度。如果首选宽度大于最大宽度,那么应使用最大宽度。如果首选宽度小于最小宽度(最小的不可分单位),那么应使用最小宽度。最后,这些值会缓存起来,以用于需要布局而宽度不变的情况。

如果呈现器在布局过程中需要换行,会立即停止布局,并告知其父代需要换行。父代会创建额外的呈现器,并在其上布局。

绘制

在绘制阶段,系统会遍历呈现树,并调用呈现器的paint方法,将呈现器的内容显示在屏幕上。和布局一样,绘制也分为全局(绘制整个呈现树)和增量两种。在增量绘制中,部分呈现器发生了更改,但是不会影响整个树。更改后的呈现器将其在屏幕上对应的矩形区域设为无效,这导致 OS 将其视为一块“dirty 区域”,并生成“paint”事件。OS会很巧妙地将多个区域合并成一个。

CSS2.1规范描述了绘制的顺序,这个顺序实际上也是堆栈上下文的顺序。这些堆栈会从后往前绘制,因此这样的顺序会影响绘制。块呈现器的堆栈顺序如下:

  1. 背景颜色
  2. 背景图片
  3. 边框
  4. 子代元素
  5. 轮廓(outline)

浏览器特点

Firefox会遍历整个呈现树,为绘制的矩形建立一个显示列表。列表中按照正确的绘制顺序(先是呈现器的背景,然后是边框等等)包含了与矩形相关的呈现器。这样等到重新绘制的时候,只需遍历一次呈现树,而不用多次遍历(绘制所有背景,然后绘制所有图片,再绘制所有边框等等)。同时Firefox对此过程进行了优化,也就是不添加隐藏的元素,例如被不透明元素完全遮挡住的元素。

Webkit在重新绘制之前,则会将原来的矩形另存为一张位图,然后只绘制新旧矩形之间的差异部分。

动态变化

在发生变化时,回流(reflow)和重绘(repaint)可能会被触发,浏览器会尽可能做出最小的响应。例如,元素的颜色改变后,只会对该元素进行重绘。元素的位置改变后,只会对该元素及其子元素(可能还有同级元素)进行布局和重绘。添加DOM节点后,会对该节点进行布局和重绘。一些重大变化(例如增大html元素的字体)会导致缓存无效,使得整个呈现树都会进行重新布局和绘制。

无论是回流还是重绘都会增加浏览器的工作负担,带来响应时间。其中回流的计算代价更大,应该尽量避免。有一些方法可以用来减少回流的出现。

渲染引擎的线程

呈现引擎采用了单线程。几乎所有操作(除了网络操作)都是在单线程中进行的。在Firefox和Safari中,该线程就是浏览器的主线程。而在Chrome浏览器中,该线程是标签进程的主线程。

网络操作可由多个并行线程执行。并行连接数是有限的(通常为2至6个,以Firefox为例是6个)。

Event Loop

浏览器的主线程是事件循环(Event Loop)。它是一个无限循环,永远处于接受活动状态,并等待事件(如布局和绘制事件)发生,并进行处理。这是Firefox中关于主事件循环的代码:

1
2
while (!mExiting)
NS_ProcessNextEvent(thread);

CSS2.1可视化模型

注:CSS3是将CSS2模块化并对其中部分模块进行更新的版本。它仍使用CSS2.1规范作为其核心。改进的模块并不会与CSS2.1相冲突。因此这里原文关于CSS2.1的描述并不算过时

盒模型

CSS盒模型描述的是针对文档树中的元素而生成,并根据可视化格式模型进行布局的矩形框。每个框都有一个内容区域(例如文本、图片等),还有可选的周围补白、边框和边距区域。如上图所示。

每一个节点都会生成 0..n 个这样的框。所有元素都有一个“display”属性,决定了它们所对应生成的框类型。默认值是inline,但是浏览器样式表设置了其他默认值。例如,<div>元素的display属性默认值是block。W3C上有全面的默认样式表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
html, address,
blockquote,
body, dd, div,
dl, dt, fieldset, form,
frame, frameset,
h1, h2, h3, h4,
h5, h6, noframes,
ol, p, ul, center,
dir, hr, menu, pre { display: block; unicode-bidi: embed }
li { display: list-item }
head { display: none }
table { display: table }
tr { display: table-row }
thead { display: table-header-group }
tbody { display: table-row-group }
tfoot { display: table-footer-group }
col { display: table-column }
colgroup { display: table-column-group }
td, th { display: table-cell }
caption { display: table-caption }
th { font-weight: bolder; text-align: center }
caption { text-align: center }
body { margin: 8px }
h1 { font-size: 2em; margin: .67em 0 }
h2 { font-size: 1.5em; margin: .75em 0 }
h3 { font-size: 1.17em; margin: .83em 0 }
h4, p,
blockquote, ul,
fieldset, form,
ol, dl, dir,
menu { margin: 1.12em 0 }
h5 { font-size: .83em; margin: 1.5em 0 }
h6 { font-size: .75em; margin: 1.67em 0 }
h1, h2, h3, h4,
h5, h6, b,
strong { font-weight: bolder }
blockquote { margin-left: 40px; margin-right: 40px }
i, cite, em,
var, address { font-style: italic }
pre, tt, code,
kbd, samp { font-family: monospace }
pre { white-space: pre }
button, textarea,
input, select { display: inline-block }
big { font-size: 1.17em }
small, sub, sup { font-size: .83em }
sub { vertical-align: sub }
sup { vertical-align: super }
table { border-spacing: 2px; }
thead, tbody,
tfoot { vertical-align: middle }
td, th, tr { vertical-align: inherit }
s, strike, del { text-decoration: line-through }
hr { border: 1px inset }
ol, ul, dir,
menu, dd { margin-left: 40px }
ol { list-style-type: decimal }
ol ul, ul ol,
ul ul, ol ol { margin-top: 0; margin-bottom: 0 }
u, ins { text-decoration: underline }
br:before { content: "\A"; white-space: pre-line }
center { text-align: center }
:link, :visited { text-decoration: underline }
:focus { outline: thin dotted invert }
/* Begin bidirectionality settings (do not change) */
BDO[DIR="ltr"] { direction: ltr; unicode-bidi: bidi-override }
BDO[DIR="rtl"] { direction: rtl; unicode-bidi: bidi-override }
*[DIR="ltr"] { direction: ltr; unicode-bidi: embed }
*[DIR="rtl"] { direction: rtl; unicode-bidi: embed }
@media print {
h1 { page-break-before: always }
h1, h2, h3,
h4, h5, h6 { page-break-after: avoid }
ul, ol, dl { page-break-before: avoid }
}

定位方案

CSS中有三种定位方案:

  • 普通:根据对象在文档中的位置进行定位,也就是说对象在呈现树中的位置和它在DOM树中的位置相似,并根据其框类型和尺寸进行布局。
  • 浮动:脱离文档流,对象先按照普通流进行布局,然后尽可能地向左或向右移动。
  • 绝对:完全脱离文档流,对象在呈现树中的位置和它在DOM树中的位置不同。

定位方案是由“position”属性和“float”属性设置的。如果值是staticrelative,就是普通流,如果值是 absolutefixed,就是绝对定位。

盒类型

  • block:形成一个block,在浏览器窗口中拥有其自己的矩形区域。
  • inline:没有自己的 block,但是位于容器 block 内。

block一个接一个地垂直排布,而inline则是水平排布。

定位

  • 相对定位:先按照普通方式定位,然后根据所需偏移量进行移动。
  • 浮动:浮动框会移动到行的左边或右边。有趣的特征在于,其他框会浮动在它的周围。
  • 绝对定位和固定定位:这种布局是准确定义的,与普通流无关。元素不参与普通流。尺寸是相对于容器而言的。在固定定位中,容器就是可视区域。

更具体的分析可以参见CSS2.1中对normal flow,float和absolute做的讲解

层级

这是由z-index属性指定的。它代表了框的第三个维度,也就是沿“z 轴”方向的位置。这些框分散到多个堆栈(称为堆栈上下文)中。在每一个堆栈中,会首先绘制后面的元素,然后在顶部绘制前面的元素,以便更靠近用户。如果出现重叠,新绘制的元素就会覆盖之前的元素。

堆栈是按照z-index属性进行排序的。具有z-index属性的框形成了本地堆栈。视口具有外部堆栈。更多描述参考CSS2.1 z-index