浏览器的工作方式

现代网络浏览器幕后揭秘

Paul Irish
Tali Garsiel
Tali Garsiel

序言

这是一篇全面介绍 WebKit 和 Gecko 内部操作的入门文章,是以色列开发者 Tali Garsiel 大量研究的成果。在过去的几年里,她查阅了所有已发布的有关浏览器内部构件的数据,并花了大量时间来阅读网络浏览器的源代码。她写道:

作为 Web 开发者,学习浏览器的内部运作机制有助于您做出更明智的决策,并理解开发最佳做法背后的正当理由。虽然此文档比较长,但我们建议您花点时间深入了解。您会很高兴这样做。

Paul Irish,Chrome 开发者关系团队

简介

网络浏览器是使用最广泛的软件。在这份入门指南中,我介绍了它们的幕后运作方式。我们将了解当您在地址栏中输入 google.com 直到您在浏览器屏幕上看到 Google 页面之前,会发生什么。

我们要介绍的浏览器

目前,桌面设备上使用的主要浏览器有五种:Chrome、Internet Explorer、Firefox、Safari 和 Opera。移动设备的主要浏览器包括 Android 浏览器、iPhone、Opera Mini 和 Opera Mobile、UC 浏览器、Nokia S40/S60 浏览器和 Chrome。除 Opera 浏览器外,其他所有浏览器都基于 WebKit。我会举例说明开源浏览器 Firefox 和 Chrome 以及 Safari(部分开源)。根据 StatCounter 统计数据(截至 2013 年 6 月),Chrome、Firefox 和 Safari 在全球桌面浏览器使用量中约占 71%。在移动设备上,Android 浏览器、iPhone 和 Chrome 占了总使用量的 54% 左右。

浏览器的主要功能

浏览器的主要功能就是向服务器请求您选择的网络资源并在浏览器窗口中显示该资源。 资源通常是 HTML 文档,但也可能是 PDF、图片或其他类型的内容。 资源的位置由用户使用 URI(统一资源标识符)指定。

浏览器解读和显示 HTML 文件的方式是在 HTML 和 CSS 规范中指定的。 这些规范由网络标准组织 W3C(万维网联盟)进行维护。多年来,各浏览器都完全没有遵从这些规范的一部分,并自行开发了扩展程序。这给网络开发者带来了严重的兼容性问题。如今,大多数浏览器或多或少符合规范。

各个浏览器界面有许多共同点。常见的界面元素包括:

  1. 用于插入 URI 的地址栏
  2. “后退”和“前进”按钮
  3. 书签选项
  4. 用于刷新或停止加载当前文档的刷新和停止按钮
  5. 用于转到首页的“主页”按钮

奇怪的是,浏览器的界面并没有在任何正式的规范中明确指定,而是源于多年来积累的良好实践以及各浏览器相互模仿的结果。HTML5 规范没有定义浏览器必须具有的界面元素,只列出了一些常见元素。例如地址栏、状态栏和工具栏。当然,特定浏览器具有独特的功能,例如 Firefox 的下载管理器。

概要基础架构

浏览器的主要组件包括:

  1. 界面:包括地址栏、前进/后退按钮、书签菜单等。界面上除了显示所请求的网页的窗口外,它还会包括浏览器界面的各个部分。
  2. 浏览器引擎:在界面和呈现引擎之间编组操作。
  3. 渲染引擎:负责显示请求的内容。例如,如果请求的内容是 HTML,呈现引擎会解析 HTML 和 CSS,并在屏幕上显示解析后的内容。
  4. 网络:对于 HTTP 请求等网络调用,针对位于平台独立接口后面的不同平台使用不同的实现。
  5. 界面后端:用于绘制基本 widget,如组合框和窗口。此后端公开了与平台无关的通用接口。在底层使用操作系统界面方法。
  6. JavaScript 解释器。用于解析和执行 JavaScript 代码。
  7. 数据存储。这是一个持久性层。浏览器可能需要在本地保存各种数据,例如 Cookie。浏览器还支持 localStorage、IndexedDB、WebSQL 和 FileSystem 等存储机制。
浏览器组件
图 1:浏览器组件

请务必注意,Chrome 等浏览器会运行多个呈现引擎实例:每个标签页对应一个实例。每个标签页都在单独的进程中运行。

渲染引擎

渲染引擎负责渲染...渲染,也就是在浏览器屏幕上显示请求的内容。

默认情况下,渲染引擎可显示 HTML 和 XML 文档及图片。它可以通过插件或扩展程序显示其他类型的数据;例如,使用 PDF 查看器插件显示 PDF 文档。不过,在本章中,我们将着重介绍主要用例:显示使用 CSS 格式的 HTML 和图片。

不同的浏览器使用不同的渲染引擎:Internet Explorer 使用 Trident,Firefox 使用 Gecko,Safari 使用 WebKit。Chrome 和 Opera(从 15 版开始)都使用了 Blink,它是 WebKit 的一个分支。

WebKit 是一个开源渲染引擎,起初是作为 Linux 平台的引擎,后来经过 Apple 修改,支持 Mac 和 Windows。

主流程

渲染引擎将开始从网络层获取所请求文档的内容。此操作通常以 8 kB 数据块的形式完成。

之后,以下是渲染引擎的基本流程:

渲染引擎基本流程
图 2:渲染引擎的基本流程

呈现引擎会开始解析 HTML 文档,并将元素转换为名为“内容树”的树中的 DOM 节点。引擎会解析外部 CSS 文件和样式元素中的样式数据。HTML 中的样式信息和直观指令将用于创建另一树:渲染树。

呈现树包含具有视觉属性(如颜色和尺寸)的矩形。这些矩形都是按正确顺序在屏幕上显示的顺序。

呈现树构建完成后,进入“布局”流程。 这意味着需要为每个节点提供确切的坐标显示在屏幕上的位置。下一阶段是绘制 - 系统将遍历渲染树,并使用界面后端层来绘制每个节点。

需要注意的是,这是一个渐进的过程。为了提供更好的用户体验,渲染引擎会尝试尽快在屏幕上显示内容。它不必等到所有 HTML 内容解析完毕之后,就会开始构建呈现树和设置布局。 系统会解析并显示部分内容,同时继续处理来自网络的其余内容。

主流程示例

WebKit 主流程。
图 3:WebKit 主流程
Mozilla Gecko 呈现引擎的主流程。
图 4:Mozilla 的 Gecko 渲染引擎主流程

从图 3 和图 4 可以看出,尽管 WebKit 和 Gecko 使用的术语略有不同,但该流程基本相同。

Gecko 将视觉格式化元素树称为“框架树”。每个元素都是一个框架。 WebKit 使用术语“呈现树”,它由“呈现对象”组成。WebKit 使用术语“布局”来表示元素的放置,而 Gecko 称之为“自动重排”。“附加”是 WebKit 的术语,表示连接 DOM 节点和视觉信息以创建呈现树。 有一个细微的非语义差别,就是 Gecko 在 HTML 和 DOM 树之间还有一个额外的层。它被称为“内容接收器”,是创建 DOM 元素的工厂。我们将分别介绍该流程的每个部分:

解析 - 常规

解析是呈现引擎中非常重要的一个环节,因此我们要进行更深入的解析。 我们先来简单介绍一下解析。

解析文档意味着将其转换为可供代码使用的结构。解析的结果通常是表示文档结构的节点树。这称为解析树或语法树。

例如,解析表达式 2 + 3 - 1 可能会返回以下树:

数学表达式树节点。
图 5:数学表达式树节点

语法

解析基于文档所遵循的语法规则(编写文档时使用的语言或格式)。所有可以解析的格式都必须包含由词汇和语法规则组成的确定性语法。这称为与上下文无关的语法。人类语言不属于这种语言,因此无法通过常规的解析技术进行解析。

解析器和词法分析器的组合

解析可以分为两个子过程:词法分析和语法分析。

词法分析是将输入内容分解成多个词元的过程。标记是语言词汇,即有效组成要素的集合。在人类语言中,它包含该语言的字典中出现的所有单词。

语法分析是指应用语言语法规则。

解析器通常会将工作划分到两个组件之间:词法分析器(有时称为标记生成器),它负责将输入内容分解为有效标记,而解析器则负责根据语言语法规则分析文档结构,进而构建解析树。

词法分析器知道如何删除不相关的字符,例如空格和换行符。

从源文档到解析树
图 6:从源文档到解析树

解析过程是一个迭代过程。通常,解析器会向词法分析器请求一个新标记,并尝试将该标记与某条语法规则进行匹配。如果规则匹配,则会将与令牌对应的节点添加到解析树中,然后解析器会请求另一个令牌。

如果没有规则匹配,解析器会在内部存储令牌,并不断请求令牌,直到找到与内部存储的所有令牌都匹配的规则。如果找不到任何规则,解析器就会引发异常。这意味着文档无效,包含语法错误。

翻译

在许多情况下,解析树不是最终产品。解析通常在翻译过程中使用:将输入文档转换为另一种格式。编译就是这样一个示例。将源代码编译为机器代码的编译器会先将源代码解析为解析树,然后将解析树转换为机器代码文档。

编译流程
图 7:编译流程

解析示例

在图 5 中,我们通过一个数学表达式构建了一个解析树。我们尝试定义一种简单的数学语言,看看解析过程。

语法:

  1. 语言语法构建块是表达式、术语和运算。
  2. 我们的语言可以包含任意数量的表达式。
  3. 表达式的定义是:一个“项”后跟一个“运算”,然后再跟另一个“项”
  4. 操作是加号或减号标记
  5. 字词是一个整数标记或表达式

我们分析输入 2 + 3 - 1

与规则匹配的第一个子字符串是 2:根据第 5 条规则,它是一个字词。第二个匹配项是 2 + 3:这符合第三条规则:一个项接一个运算符,然后再接一个项。 系统仅会在输入的末尾匹配下一个匹配项。 2 + 3 - 1 是一个表达式,因为我们已经知道 2 + 3 是一个项,所以我们有一个项后跟一个运算符,然后再接另一个项。“2 + +”不会与任何规则匹配,因此是无效的输入。

词汇和语法的正式定义

词汇通常由正则表达式表示。

例如,我们的语言定义如下:

INTEGER: 0|[1-9][0-9]*
PLUS: +
MINUS: -

如您所见,整数是由正则表达式定义的。

语法通常以名为 BNF 的格式定义。 我们的语言定义如下:

expression :=  term  operation  term
operation :=  PLUS | MINUS
term := INTEGER | expression

我们已经说过,如果语言的语法是与上下文无关的语法,就可以由常规解析器解析。 与上下文无关的语法的直观定义就是可以完全用 BNF 表示的语法。 如需了解正式定义,请参阅维基百科中有关与上下文无关的语法的文章

解析器类型

解析器分为两种类型:自上而下解析器和自下而上解析器。一个直观的解释是,自上而下解析器检查语法的高级结构,并尝试查找规则匹配项。自下而上的解析器从低层规则开始,从低层规则开始,将其逐步转换为语法规则,直到满足高层规则。

让我们来看看这两种解析器如何解析我们的示例。

自上而下的解析器会从更高级别的规则开始:它会将 2 + 3 标识为表达式。然后,它会将 2 + 3 - 1 标识为表达式(识别表达式的过程会不断演变,匹配其他规则,但起点是最高级别的规则)。

自下而上的解析器将扫描输入内容,直到匹配规则。然后将匹配的输入内容替换为规则。此过程会一直持续到输入内容的结尾。 部分匹配的表达式会放在解析器的堆栈中。

堆栈 输入
2 + 3 - 1
term + 3 - 1 个
项运算 3 - 1 个
表达式 - 1
表达式运算 1
表达式 -

这种自下而上的解析器称为移位归约解析器,因为输入会向右移(假设指针首先指向输入的开头,然后向右移动),并逐渐归纳为语法规则。

自动生成解析器

有一些工具可以生成解析器。您只要向他们提供您所用语言的语法(词汇和语法规则),它们就会生成可正常使用的解析器。 创建解析器需要对解析有深入的了解,而手动创建经过优化的解析器并非易事,因此解析器生成器非常实用。

WebKit 使用两种广为人知的解析器生成器:用于创建词法分析器的 Flex 和用于创建解析器的 Bison(您可能遇到过 Lex 和 Yacc 这两个名称)。Flex 输入是包含令牌的正则表达式定义的文件。Bison 的输入是 BNF 格式的语言语法规则。

HTML 解析器

HTML 解析器的任务是将 HTML 标记解析为解析树。

HTML 语法

HTML 的词汇和语法在 W3C 组织创建的规范中进行定义。

正如我们在解析简介中所见,可以使用 BNF 等格式正式定义语法语法。

遗憾的是,所有传统解析器都不适用于 HTML(我并不是为了好玩而提到它们,它们用于解析 CSS 和 JavaScript)。HTML 无法用解析器所需的与上下文无关的语法轻松定义。

有一种可以定义 HTML 的正式格式,即 DTD(文档类型定义),但它不是一种与上下文无关的语法。

这初看起来很奇怪;HTML 与 XML 非常接近。有很多 XML 解析器可供使用。HTML 有一个 XML 变体,即 XHTML。那么,最大的区别是什么呢?

不同之处在于 HTML 处理方法更为“宽容”:通过该方法,您可以省略某些标记(然后隐式添加),有时也可以省略开始或结束标记,等等。 总体而言,它是一种“软”语法,与 XML 既僵硬又要求严苛的语法相反。

这些看似微不足道的细节会给世界带来很大的影响。一方面,这是 HTML 如此流行的主要原因:它能包容您的错误,简化网络作者的工作。 另一方面,这使得编写正式语法变得困难。总而言之,常规解析器无法轻松解析 HTML,因为其语法与上下文无关。XML 解析器无法解析 HTML。

HTML DTD

HTML 定义采用 DTD 格式。此格式用于定义 SGML 系列的语言。该格式包含所有允许使用的元素及其属性和层次结构的定义。如前所述,HTML DTD 不会构成与上下文无关的语法。

DTD 存在一些变体。严格模式完全符合相关规范,但其他模式可支持以前的浏览器所使用的标记。这样做是为了向后兼容旧内容。当前的严格 DTD 位于:www.w3.org/TR/html4/strict.dtd

DOM

输出树(“解析树”)是由 DOM 元素和属性节点组成的树。DOM 是文档对象模型 (Document Object Model) 的简称。 它是 HTML 文档的对象呈现方式,也是 HTML 元素与 JavaScript 等外部环境之间的接口。

树的根是“Document”对象。

DOM 与标记之间几乎是一对一的关系。例如:

<html>
  <body>
    <p>
      Hello World
    </p>
    <div> <img src="example.png"/></div>
  </body>
</html>

此标记将转换为以下 DOM 树:

示例标记的 DOM 树
图 8:示例标记的 DOM 树

与 HTML 一样,DOM 也是由 W3C 组织指定的。 请参阅 www.w3.org/DOM/DOMTR。 这是关于文档操作的通用规范。某个模块描述特定于 HTML 的元素。有关 HTML 的定义,请访问:www.w3.org/TR/2003/REC-DOM-Level-2-HTML-20030109/idl-definitions.html

我所说的树包含 DOM 节点,是指树是由实现某个 DOM 接口的元素构成的。浏览器使用的具体实现具有浏览器在内部使用的其他属性。

解析算法

正如我们在前面部分介绍的,无法使用常规的自上而下或自下而上的解析器解析 HTML。

原因如下:

  1. 语言的宽容本质。
  2. 事实上,浏览器具有传统的容错能力,可以支持常见的 HTML 无效情况。
  3. 解析过程会反复进行。对于其他语言,源代码在解析过程中不会更改,但在 HTML 中,动态代码(例如包含 document.write() 调用的脚本元素)可能会添加额外的令牌,因此解析过程实际上会修改输入。

由于无法使用常规解析技术,浏览器创建了用于解析 HTML 的自定义解析器。

解析算法由 HTML5 规范详细说明。 该算法包含两个阶段:标记化和树构建。

词元化是词法分析,将输入内容解析成词元。HTML 标记包括开始标记、结束标记、属性名称和属性值。

标记生成器识别标记,将其提供给树构造函数,然后消耗下一个字符以识别下一个标记,依此类推,直到输入的末尾。

HTML 解析流程(取自 HTML5 规范)
图 9:HTML 解析流程(取自 HTML5 规范)

标记化算法

该算法的输出结果是 HTML 标记。该算法表示为状态机。 每个状态都会使用输入流的一个或多个字符,并根据这些字符更新下一个状态。当前的标记化状态和树结构状态会影响该决定。这意味着,即使接收的字符相同,后续出现的正确状态也会产生不同的结果,具体取决于当前状态。 该算法太复杂,无法进行全面介绍,所以我们通过一个简单的示例来帮助我们理解其原理。

基本示例 - 对以下 HTML 进行标记化:

<html>
  <body>
    Hello world
  </body>
</html>

初始状态为“数据状态”。 如果遇到 < 字符,状态将更改为“代码打开状态”。 消耗 a-z 字符会导致创建“开始标记令牌”,状态将更改为“标记名称状态”。 我们会保持此状态,直至消耗 > 字符。每个字符都会附加到新令牌名称。在本例中,创建的令牌是 html 令牌。

达到 > 标记时,系统会发出当前令牌,且状态会改回“数据状态”。 系统会按照相同的步骤处理 <body> 标记。到目前为止,htmlbody 标记均已发出。现在,我们回到“数据状态”。 接收 Hello worldH 字符将创建并发出字符令牌,直到达到 </body>< 为止。我们将为 Hello world 的每个字符发出一个字符标记。

现在,我们回到了“代码打开状态”。 使用下一个输入项 / 时,系统会创建 end tag token 并进入“标记名称状态”。我们会再次保持此状态,直到达到 >。然后,系统会发出新的标记令牌,并返回到“数据状态”</html> 输入的处理方式与上一种情况相同。

对示例输入进行词元化处理
图 10:对示例输入进行词元化处理

树构建算法

创建解析器时,也会创建 Document 对象。在树构建阶段,以 Document 为根部的 DOM 树将被修改,并且向其中添加元素。标记生成器发出的每个节点都将由树构造函数进行处理。对于每个词法单元,规范都会定义与其相关的 DOM 元素,并将为这个词元创建这些元素。该元素随即会添加到 DOM 树以及开放元素的堆栈中。此堆栈用于更正嵌套错误和未关闭的标记。该算法也可描述为状态机。这些状态称为“插入模式”。

我们来看一下示例输入的树构建过程:

<html>
  <body>
    Hello world
  </body>
</html>

树构建阶段的输入是来自标记化阶段的一系列词元。第一种模式是“初始模式”。收到“html”令牌后,系统会进入“before html”模式,并在该模式下重新处理令牌。 这样会创建 HTMLHTMLElement 元素,该元素将附加到 Document 根对象。

状态将更改为“before head”。然后,会收到“body”令牌。虽然我们没有“head”标记,但会隐式创建 HTMLHeadElement,并将它添加到树中。

现在,我们进入了“in head”模式,然后进入“after head”模式。系统重新处理 body 标记,创建并插入 HTMLBodyElement,同时模式转变为“in body”

现在,接收“Hello world”字符串的字符标记。第一个字符用于创建和插入“Text”节点,其他字符将附加到该节点。

接收正文结束标记将转换为 "after body" 模式。 现在,我们将接收 HTML 结束标记,从而进入“after after body”模式。 接收文件结束标记将会结束解析。

示例 HTML 的树结构。
图 11:示例 html 的树构建

解析完成时的操作

在此阶段,浏览器会将文档标记为交互式,并开始解析处于“推迟”模式的脚本,即那些应在解析文档之后执行的脚本。 然后,文档状态将设置为“完成”,并且会触发“加载”事件。

您可以参阅 HTML5 规范,了解用于标记化和树构建的完整算法

浏览器的容错能力

您在浏览 HTML 网页时从来不会看到“语法无效”的错误。 浏览器会修正所有无效内容,然后继续运行。

以下面的 HTML 为例:

<html>
  <mytag>
  </mytag>
  <div>
  <p>
  </div>
    Really lousy HTML
  </p>
</html>

我一定违反了大约一百万条规则(“mytag”不是标准标记,“p”和“div”元素嵌套有误等等),但浏览器仍然会正确显示,而不会抱怨。 有大量解析器代码会纠正 HTML 作者的错误。

各个浏览器的错误处理机制非常一致,但令人称奇的是,这种机制并不属于 HTML 规范。 就像书签和前进/后退按钮一样,它也是浏览器在多年发展中的产物。许多网站上存在已知的无效 HTML 构造,浏览器会尝试通过与其他浏览器兼容的方式修复这些错误。

HTML5 规范定义了其中一些要求。(WebKit 在 HTML 解析器类开头的注释中对此做了很好的总结。)

解析器将标记化的输入解析到文档中,从而构建文档树。如果文档的格式正确,就直接进行解析。

遗憾的是,我们必须处理许多格式不正确的 HTML 文档,所以解析器必须容忍错误。

我们必须至少处理以下错误情况:

  1. 明确禁止在某个外部标记中添加的元素。在此例中,我们应关闭所有标记直至禁用该元素的标记,并在之后添加该元素。
  2. 我们不能直接添加元素。这可能是因为撰写文档的人忘记了其中的一些标记(或者中间的标记是可选的)。使用以下代码即可出现这种情况:HTML HEAD BODY TBODY TR TD LI(我有忘了吗?)。
  3. 我们想在 inline 元素内添加一个 block 元素。关闭所有内嵌元素,直到出现下一个更高级的 block 元素。
  4. 如果这样没有帮助,请关闭元素,直到我们能够添加元素,或者忽略该标记。

我们来看一些 WebKit 容错示例:

</br> 代替 <br>

部分网站使用的是 </br>,而不是 <br>。为了与 IE 和 Firefox 兼容,WebKit 将这视为 <br>

代码:

if (t->isCloseTag(brTag) && m_document->inCompatMode()) {
     reportError(MalformedBRError);
     t->beginTag = true;
}

请注意,错误处理是内部进行的,不会向用户显示。

离散表

离散表格是指位于另一个表格内,但不属于某个表格单元格内的表格。

例如:

<table>
  <table>
    <tr><td>inner table</td></tr>
  </table>
  <tr><td>outer table</td></tr>
</table>

WebKit 会将层次结构更改为两个同级表格:

<table>
  <tr><td>outer table</td></tr>
</table>
<table>
  <tr><td>inner table</td></tr>
</table>

代码:

if (m_inStrayTableContent && localName == tableTag)
        popBlock(tableTag);

WebKit 使用一个堆栈来保存当前元素内容:它会将内部表格从外部表格堆栈中弹出。这些表现在是同级关系。

嵌套表单元素

如果用户将一个表单放入另一个表单,第二个表单将被忽略。

代码:

if (!m_currentFormElement) {
        m_currentFormElement = new HTMLFormElement(formTag,    m_document);
}

代码层次结构过深

评论已经说得很清楚了。

bool HTMLParser::allowNestedRedundantTag(const AtomicString& tagName)
{

unsigned i = 0;
for (HTMLStackElem* curr = m_blockStack;
         i < cMaxRedundantTagDepth && curr && curr->tagName == tagName;
     curr = curr->next, i++) { }
return i != cMaxRedundantTagDepth;
}

放错位置的 HTML 或 body 结束标记

同样,注释已经说清楚了。

if (t->tagName == htmlTag || t->tagName == bodyTag )
        return;

因此,网络作者要注意,除非您想在 WebKit 容错代码段中显示为示例,否则请编写格式正确的 HTML。

CSS 解析

还记得简介中的解析概念吗?与 HTML 不同,CSS 是一种与上下文无关的语法,可以使用简介中描述的各种解析器进行解析。 事实上,CSS 规范定义了 CSS 的词法和语法

我们来看一些示例:

词法语法(词汇)由每个词法单元的正则表达式定义:

comment   \/\*[^*]*\*+([^/*][^*]*\*+)*\/
num       [0-9]+|[0-9]*"."[0-9]+
nonascii  [\200-\377]
nmstart   [_a-z]|{nonascii}|{escape}
nmchar    [_a-z0-9-]|{nonascii}|{escape}
name      {nmchar}+
ident     {nmstart}{nmchar}*

“ident”是标识符 (identifier) 的缩写,例如类名称。 “name”是元素 ID(以“#”表示)

语法语法使用 BNF 进行描述。

ruleset
  : selector [ ',' S* selector ]*
    '{' S* declaration [ ';' S* declaration ]* '}' S*
  ;
selector
  : simple_selector [ combinator selector | S+ [ combinator? selector ]? ]?
  ;
simple_selector
  : element_name [ HASH | class | attrib | pseudo ]*
  | [ HASH | class | attrib | pseudo ]+
  ;
class
  : '.' IDENT
  ;
element_name
  : IDENT | '*'
  ;
attrib
  : '[' S* IDENT S* [ [ '=' | INCLUDES | DASHMATCH ] S*
    [ IDENT | STRING ] S* ] ']'
  ;
pseudo
  : ':' [ IDENT | FUNCTION S* [IDENT S*] ')' ]
  ;

说明:

规则集的结构如下:

div.error, a.error {
  color:red;
  font-weight:bold;
}

div.errora.error 是选择器。大括号内的部分包含由此规则集应用的规则。该结构的正式定义如下:

ruleset
  : selector [ ',' S* selector ]*
    '{' S* declaration [ ';' S* declaration ]* '}' S*
  ;

这意味着规则集是一个选择器,或者多个由英文逗号和空格(S 代表空格)分隔的多个选择器。 规则集包含大括号,其中包含一个声明,或者多个由英文分号分隔的声明(可选)。“声明”和“选择器”将在下面的 BNF 定义中定义。

WebKit CSS 解析器

WebKit 使用 Flex 和 Bison 解析器生成器,根据 CSS 语法文件自动创建解析器。 正如我们在解析器简介部分介绍的那样,Bison 会创建一个自下而上的移位归约解析器。Firefox 使用手动编写的自上而下解析器。 这两种方式都会将每个 CSS 文件解析成一个 StyleSheet 对象。每个对象都包含 CSS 规则。CSS 规则对象包含选择器和声明对象,以及与 CSS 语法对应的其他对象。

解析 CSS。
图 12:解析 CSS

脚本和样式表的处理顺序

脚本

Web 的模型是同步的。作者希望解析器遇到 <script> 标记时,能立即解析并执行脚本。 文档解析将停止,直到脚本执行完毕。如果脚本是外部的,则必须先从网络中提取资源 - 此操作也是同步完成的,解析会停止,直到提取资源为止。 此模型已经使用了多年,并且还在 HTML4 和 HTML5 规范中进行了指定。 作者可以向脚本中添加“defer”属性,这样它就不会停止文档解析,而是在文档解析完成后执行。HTML5 添加了一个选项,用于将脚本标记为异步,以便由其他线程解析和执行。

推测解析

WebKit 和 Firefox 均会执行此项优化。在执行脚本时,另一个线程会解析文档的其余部分,找出并加载需要从网络加载的其他资源。通过这种方式,可以在并行连接上加载资源,从而提高整体速度。注意:推测性解析器仅解析对外部资源(如外部脚本、样式表和图片)的引用:它不会修改 DOM 树,而 DOM 树留给主解析器的引用。

样式表

另一方面,样式表采用不同的模型。从概念上讲,由于样式表不会更改 DOM 树,因此似乎没有必要等待样式表并停止文档解析。但存在一个问题,那就是脚本在文档解析阶段会请求样式信息。如果样式尚未加载和解析,脚本将获得错误答案,这显然导致了很多问题。 这似乎属于极端案例,但其实很常见。如果存在仍在加载和解析的样式表,Firefox 会阻止所有脚本。 只有当脚本尝试访问可能会受未加载的样式表影响的特定样式属性时,WebKit 才会阻止脚本。

渲染树构建

在 DOM 树构建期间,浏览器会构建另一个树,即渲染树。该树状图中的视觉元素按照显示顺序排列。它是文档的可视化表示。 这种树旨在确保按正确的顺序绘制内容。

Firefox 将呈现树中的元素称为“框架”。WebKit 使用“渲染程序”或“渲染对象”一词。

渲染程序知道如何布置和绘制自身及其子项。

WebKit 的 RenderObject 类(渲染程序的基类)具有以下定义:

class RenderObject{
  virtual void layout();
  virtual void paint(PaintInfo);
  virtual void rect repaintRect();
  Node* node;  //the DOM node
  RenderStyle* style;  // the computed style
  RenderLayer* containgLayer; //the containing z-index layer
}

每个渲染程序表示一个矩形区域,通常对应于节点的 CSS 框(如 CSS2 规范所述)。它包含宽度、高度和位置等几何信息。

框类型受与节点相关的样式属性的“display”值的影响(请参阅样式计算部分)。 以下 WebKit 代码用于决定应根据显示属性为 DOM 节点创建哪种类型的渲染程序:

RenderObject* RenderObject::createObject(Node* node, RenderStyle* style)
{
    Document* doc = node->document();
    RenderArena* arena = doc->renderArena();
    ...
    RenderObject* o = 0;

    switch (style->display()) {
        case NONE:
            break;
        case INLINE:
            o = new (arena) RenderInline(node);
            break;
        case BLOCK:
            o = new (arena) RenderBlock(node);
            break;
        case INLINE_BLOCK:
            o = new (arena) RenderBlock(node);
            break;
        case LIST_ITEM:
            o = new (arena) RenderListItem(node);
            break;
       ...
    }

    return o;
}

系统还会考虑元素类型:例如,表单控件和表格具有特殊的框架。

在 WebKit 中,如果某个元素需要创建特殊的渲染器,它将替换 createRenderer() 方法。渲染程序指向包含非几何信息的样式对象。

渲染树与 DOM 树的关系

渲染程序与 DOM 元素相对应,但并非一对一关系。非可视化 DOM 元素不会插入渲染树中。例如“head”元素。此外,显示值为“none”的元素也不会显示在树中(而可见性为“隐藏”的元素则会显示在树中)。

DOM 元素对应于多个视觉对象。这些元素通常是结构复杂的元素,无法用单个矩形来描述。例如,“select”元素有三个渲染程序:一个用于显示区域,一个用于下拉列表框,一个用于按钮。 此外,当文本因宽度不足以容纳一行时分为多行时,新行将作为额外的渲染程序添加。

另一个关于多呈现器的例子是损坏的 HTML。根据 CSS 规范,inline 元素只能包含 block 元素或 inline 元素。 对于混合内容,系统会创建匿名块呈现器来封装内嵌元素。

某些渲染对象对应于 DOM 节点,但在树中所在的位置与 DOM 节点不同。浮点数和绝对定位的元素在流之外,放置在树的其他部分,并映射到真实的帧。 占位符框架是应该显示的位置。

渲染树和对应的 DOM 树。
图 13:渲染树和对应的 DOM 树。初始容器代码块是“视口”。在 WebKit 中,这将是“RenderView”对象

构建树的流程

在 Firefox 中,将演示文稿注册为 DOM 更新的监听器。呈现方式将帧创建工作委托给 FrameConstructor,而构造函数会解析样式(请参阅样式计算)并创建帧。

在 WebKit 中,解析样式和创建渲染程序的过程称为“附加”。每个 DOM 节点都有一个“attach”方法。附加是同步的,将节点插入 DOM 树会调用新的节点“attach”方法。

处理 html 和 body 标记会构建呈现树根。 根渲染对象对应于 CSS 规范所说的容器 block:最顶层的 block,其中包含所有其他 block。它的尺寸就是视口:浏览器窗口显示区域的尺寸。Firefox 将其称为 ViewPortFrame,WebKit 将其称为 RenderView。 这是文档所指向的渲染对象。 该树的其余部分以 DOM 节点插入的形式构造而成。

请参阅有关处理模型的 CSS2 规范

样式计算

构建渲染树需要计算每个渲染对象的视觉属性。这是通过计算每个元素的样式属性来完成的。

样式包括不同来源的样式表、内嵌样式元素和 HTML 中的视觉属性(例如“bgcolor”属性)。后者将转换为匹配的 CSS 样式属性。

样式表的来源包括浏览器的默认样式表、网页作者提供的样式表以及由浏览器用户提供的用户样式表(浏览器允许您定义自己喜欢的样式,例如,在 Firefox 中,可通过将样式表放入“Firefox Profile”文件夹来完成此操作)。

样式计算带来了一些难题:

  1. 样式数据是一个非常大的结构,存储了无数的样式属性,这可能会导致内存问题。
  2. 如果不进行优化,为每个元素查找匹配的规则可能会导致性能问题。要找出匹配元素,遍历整个规则列表是一项艰巨的任务。选择器可能具有复杂的结构,可能会导致匹配过程从看似有希望的路径开始,但事实证明这一路径是无效的,必须尝试另一条路径。

    例如,下面这个复合选择器:

    div div div div{
    ...
    }
    

    也就是说,这些规则适用于作为 3 个 div 的后代的 <div>。假设您想要检查该规则是否适用于给定的 <div> 元素。您应选择树上的一条向上路径进行检查。您可能需要向上遍历节点树,结果发现只有两个 div,而且规则并不适用。然后,您需要尝试树中的其他路径。

  3. 应用这些规则涉及到相当复杂的级联规则(用于定义规则的层次)。

让我们来看看浏览器是如何面对这些问题的:

共享样式数据

WebKit 节点会引用样式对象 (RenderStyle)。 在某些情况下,这些对象可以由节点共享。这些节点是同级子节点,并且:

  1. 这些元素必须处于相同的鼠标状态(例如,一个元素不能是 :hover 状态,而另一个不是)
  2. 两个元素都不能有 ID
  3. 代码名称应保持一致
  4. 类属性应匹配
  5. 映射的属性集必须完全相同
  6. 关联状态必须一致
  7. 焦点状态必须一致
  8. 两个元素都不应受到属性选择器的影响,这里的“受影响的”是指,在选择器中的任何位置都使用属性选择器的任何选择器匹配
  9. 元素中不得包含内嵌样式属性
  10. 不能使用任何同级选择器。当遇到任何同级选择器时,WebCore 只会抛出一个全局开关,并停用整个文档的样式共享(如果存在)。这包括 + 选择器以及 :first-child 和 :last-child 等选择器。

Firefox 规则树

为了简化样式计算,Firefox 还采用了另外两种树:规则树和样式上下文树。WebKit 也有样式对象,但它们并不存储在样式上下文树这样的树中,只有 DOM 节点指向其相关样式。

Firefox 样式上下文树。
图 14:Firefox 样式上下文树。

样式上下文包含结束值。这些值的计算方法如下:按正确顺序应用所有匹配规则,并将其从逻辑值转换为具体值。例如,如果逻辑值是屏幕空间的百分比,则会计算此值并将其转换为绝对单位。 规则树的点子真的很巧妙。它支持在节点之间共享这些值,以避免重复计算。这也可以节省空间。

所有匹配的规则都存储在树中。路径中底层节点的优先级较高。规则树包含找到的所有规则匹配的路径。规则的存储是延迟进行的。树状结构不会在开始时就每个节点进行计算,但每当需要计算节点样式时,系统都会将计算的路径添加到树中。

这个想法相当于将树状路径视为词典中的单词。假设我们已经计算了此规则树:

计算的规则树
图 15:计算得出的规则树。

假设我们需要匹配内容树中另一个元素的规则,并发现匹配的规则(按正确顺序)为 B-E-I。由于我们已经计算了路径 A-B-E-I-L,因此在树中已有此路径。我们现在可执行的操作会减少。

我们来看看这棵树是如何使我们的工作成果的。

结构体划分

样式上下文可分为结构体。这些结构体包含特定类别(如 border 或 color)的样式信息。结构体中的所有属性都是继承的或非继承的。继承的属性是指除非由相应元素定义,否则将继承其父项的属性。非继承的属性(称为“重置”属性)如果未定义,则使用默认值。

该树通过缓存整个结构体(包含计算出的结束值)来帮助我们实现这一目标。具体思路是,如果底层节点没有提供结构体的定义,则可以使用上层节点中的缓存结构体。

使用规则树计算样式上下文

在计算特定元素的样式上下文时,我们首先计算规则树中的路径或使用现有路径。然后,我们开始在路径中应用规则,以填充新样式上下文中的结构体。我们从路径的底层节点(优先级最高的节点(通常是最具体的选择器))开始,并向上遍历树,直到结构体填满。 如果该规则节点中没有结构体规范,我们可以进行大幅度优化 - 我们沿树状结构向上爬,直到找到一个完全指定该结构的节点并指向它,这是最好的优化方法 - 整个结构体会共享。 这可以节省结束值的计算量和内存。

如果我们找到部分定义,就会向上遍历结构树,直到结构体被填充为止。

如果我们未找到结构体的任何定义,那么如果该结构体是“继承”类型,我们会在上下文树中指向父结构的结构。在本例中,我们还成功共享了结构体。如果是重置结构体,将使用默认值。

如果最具体的节点确实添加了值,那么我们需要进行一些额外的计算,才能将其转换为实际值。然后,我们会将结果缓存在树节点中,以供子节点使用。

如果某个元素有指向同一树节点的同级或同级元素,它们之间就可以共享整个样式上下文

让我们看一个示例: 假设我们有如下的 HTML 代码:

<html>
  <body>
    <div class="err" id="div1">
      <p>
        this is a <span class="big"> big error </span>
        this is also a
        <span class="big"> very  big  error</span> error
      </p>
    </div>
    <div class="err" id="div2">another error</div>
  </body>
</html>

以及以下规则:

div {margin: 5px; color:black}
.err {color:red}
.big {margin-top:3px}
div span {margin-bottom:4px}
#div1 {color:blue}
#div2 {color:green}

为了简化操作,假设我们只需填写两个结构体:color 结构和 margin 结构。color 结构体仅包含一个成员:color;margin 结构体包含四条边。

生成的规则树将如下所示(节点以节点名称(即它们所指向的规则的编号)标记):

规则树
图 16:规则树

上下文树将如下所示(节点名称:它们指向的规则节点):

上下文树。
图 17:上下文树

假设我们解析 HTML 并得到第二个 <div> 标记,我们需要为此节点创建样式上下文,并填充其样式结构。

匹配规则后,我们发现 <div> 的匹配规则为 1、2 和 6。 这意味着树中已有一个路径可供我们的元素使用,我们只需为规则 6(规则树中的节点 F)再添加一个节点。

我们将创建一个样式上下文,并将其放入上下文树中。新的样式上下文将指向规则树中的节点 F。

现在需要填充样式结构体。首先,填充 margin 结构。 由于最后一个规则节点 (F) 没有添加到 margin 结构,我们可以向上访问树,直到找到在先前的节点插入操作中计算过的缓存结构,然后使用该结构。 我们将在节点 B 上找到它,节点是指定外边距规则的最高节点。

我们已经有了 color 结构的定义,因此无法使用缓存的结构。 由于 color 有一个属性,我们不需要上到树中填充其他属性。我们将计算结束值(将字符串转换为 RGB 等),并将经过计算的结构体缓存到此节点上。

第二个 <span> 元素处理起来更加轻松。我们匹配规则并得出结论,它指向规则 G,就像之前的 span 一样。 由于我们有指向同一节点的同级,因此我们可以分享整个样式上下文,并且只需指向上一个 span 的上下文。

对于包含从父节点继承的规则的结构,缓存在上下文树上进行(颜色属性实际上是继承的,但 Firefox 将它视为重置,并将其缓存在规则树上)。

例如,如果我们在一个段落中添加了字体规则:

p {font-family: Verdana; font size: 10px; font-weight: bold}

然后,该段落元素(上下文树中 div 的子项)可能会共享与其父项相同的字体结构。如果未为该段落指定任何字体规则,就会出现这种错误。

在 WebKit 中,由于没有规则树,因此匹配的声明会被遍历四次。首先应用不重要的高优先级属性(由于其他属性依赖于这些属性,因此应该先应用的属性,例如 display),然后是高优先级重要规则,然后是普通优先级非重要规则,最后是普通优先级重要规则。 这意味着多次出现的属性会根据正确的级联顺序进行解析。最后一方获胜。

总而言之:共享样式对象(整个对象或对象中的部分结构体)可以解决问题 1 和 3。Firefox 规则树还有助于以正确的顺序应用属性。

处理规则以实现轻松匹配

样式规则有多个来源:

  1. CSS 规则(在外部样式表或样式元素中)。 css p {color: blue}
  2. 内嵌样式属性,例如 html <p style="color: blue" />
  3. HTML 视觉属性(映射到相关样式规则) html <p bgcolor="blue" /> 后两项很容易与元素匹配,因为元素拥有样式属性,而 HTML 属性可以使用元素作为键进行映射。

如之前的问题 2 中所述,CSS 规则匹配可能会比较棘手。 为了解决这一难题,系统会操纵规则以简化访问。

样式表解析完毕后,系统会根据选择器将规则添加到某个哈希映射中。 其中包括按 ID、类名称、标记名称划分的映射,以及不属于上述类别的任何内容的通用映射。如果选择器是 ID,规则将添加到 ID 映射;如果选择器是类,则将添加到类映射中,以此类推。

这种处理可以大大简化规则匹配。无需查看每个声明:我们可以从映射中提取元素的相关规则。这种优化可以排除 95% 以上的规则,因此在匹配过程中甚至不需要考虑这些规则(4.1)。

我们以下面的样式规则为例:

p.error {color: red}
#messageDiv {height: 50px}
div {margin: 5px}

第一条规则将插入类映射中。将第二个插入 ID 映射,将第三个放入标签映射。

对于以下 HTML 片段:

<p class="error">an error occurred</p>
<div id=" messageDiv">this is a message</div>

我们首先会尝试为 p 元素寻找规则。类映射将包含一个“error”键,在下面可以找到“p.error”的规则。div 元素在 ID 映射(键为 ID)和标记映射中具有相关规则。 因此,剩下的工作就是找出由键提取的哪些规则真正匹配。

例如,如果 div 的规则是:

table div {margin: 5px}

它仍会从标记映射中提取,因为键是最右边的选择器,但是它与没有表祖先的 div 元素不匹配。

WebKit 和 Firefox 均会执行这一操作。

样式表级联顺序

样式对象具有与每个视觉属性相对应的属性(所有 CSS 属性,但更通用)。 如果该属性未由任何匹配的规则定义,则某些属性可由父元素样式对象继承。其他属性具有默认值。

当有多个定义时,问题就开始了 - 这里使用级联顺序来解决问题。

一个样式属性的声明可能会出现在多个样式表中,也可能在一个样式表中出现多次。这意味着应用这些规则的顺序非常重要。这称为“级联”顺序。 根据 CSS2 规范,级联顺序为(从低到高):

  1. 浏览器声明
  2. 用户常规声明
  3. 作者常规声明
  4. 作者重要声明
  5. 用户重要声明

浏览器声明是最不重要的,仅当声明被标记为重要时,用户才会替换作者的声明。 具有相同顺序的声明将按特异性排序,然后按指定顺序排序。 HTML 可视化属性会转换为匹配的 CSS 声明。它们被视为低优先级的作者规则。

特异性

选择器的特异性由 CSS2 规范定义,如下所示:

  1. 如果来源声明是“style”属性而不是带有选择器的规则,则记为 1,否则记为 0 (= a)
  2. 统计选择器中 ID 属性的数量 (= b)
  3. 统计选择器中其他属性和伪类的数量 (= c)
  4. 统计选择器中元素名称和伪元素的数量 (= d)

将四个数 a-b-c-d(在有大数进制的数值系统中)串联起来,即可确定特异性。

您使用的基数取决于某个类别中的最高计数。

例如,如果 a=14,您可以使用十六进制。在极少数情况下,当 a=17 时,您需要使用 17 位数字。 如果采用如下的选择器,可能会出现后一种情况: html body div div p...(选择器中有 17 个标记...不太可能)。

一些示例:

 *             {}  /* a=0 b=0 c=0 d=0 -> specificity = 0,0,0,0 */
 li            {}  /* a=0 b=0 c=0 d=1 -> specificity = 0,0,0,1 */
 li:first-line {}  /* a=0 b=0 c=0 d=2 -> specificity = 0,0,0,2 */
 ul li         {}  /* a=0 b=0 c=0 d=2 -> specificity = 0,0,0,2 */
 ul ol+li      {}  /* a=0 b=0 c=0 d=3 -> specificity = 0,0,0,3 */
 h1 + *[rel=up]{}  /* a=0 b=0 c=1 d=1 -> specificity = 0,0,1,1 */
 ul ol li.red  {}  /* a=0 b=0 c=1 d=3 -> specificity = 0,0,1,3 */
 li.red.level  {}  /* a=0 b=0 c=2 d=1 -> specificity = 0,0,2,1 */
 #x34y         {}  /* a=0 b=1 c=0 d=0 -> specificity = 0,1,0,0 */
 style=""          /* a=1 b=0 c=0 d=0 -> specificity = 1,0,0,0 */

规则排序

匹配规则后,它们会根据级联规则进行排序。WebKit 对小型列表使用冒泡排序,对大型列表使用合并排序。WebKit 通过替换规则的 > 运算符来实现排序:

static bool operator >(CSSRuleData& r1, CSSRuleData& r2)
{
    int spec1 = r1.selector()->specificity();
    int spec2 = r2.selector()->specificity();
    return (spec1 == spec2) : r1.position() > r2.position() : spec1 > spec2;
}

逐步处理

WebKit 使用一个标记来标记是否所有顶级样式表(包括 @imports)均已加载完毕。如果在添加样式时样式未完全加载,则会使用占位符并在文档中对其进行标记,并在样式表加载完毕后重新计算占位符。

布局

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

HTML 使用基于流的布局模型,这意味着大多数情况下,只需一次遍历即可计算出几何图形。“流中”后期的元素通常不会影响更早“流中”元素的几何形状,因此布局可以按从左到右、从上到下的顺序遍历文档。也有一些例外情况:例如,HTML 表格可能需要多次遍历。

坐标系是相对于根框架而言的。使用上坐标和左坐标。

布局是一个递归的过程。它从根呈现器(对应于 HTML 文档的 <html> 元素)开始。继续以递归方式遍历部分或全部帧层次结构,从而为每个需要的渲染程序计算几何信息。

根渲染程序的位置为 (0,0),其尺寸为视口(即浏览器窗口的可见部分)。

所有渲染程序都有“布局”或“自动重排”方法,每个渲染程序都会调用需要布局的子项的布局方法。

Dirty 位系统

为避免对所有细微更改都进行完整的布局,浏览器使用“脏位”系统。如果渲染程序发生更改或添加,则会将其自身及其子项标记为“脏”:需要布局。

有两个标志:“dirty”和“children are dirty”,表示尽管渲染程序本身没有问题,但它至少有一个子项需要布局。

全局布局和增量布局

可以在整个渲染树上触发布局,这就是“全局”布局。 这种情况可能是由以下原因导致的:

  1. 影响所有渲染程序的全局样式更改,例如字体大小更改。
  2. 屏幕大小调整。

布局可以是增量式,仅布局 dirty 渲染程序(这可能会造成一些损坏,导致需要额外的布局)。

当渲染程序处于脏状态时,会(异步)触发增量布局。例如,当额外内容来自网络并添加到 DOM 树之后,新的渲染程序才附加到渲染树。

增量布局。
图 18:增量布局 - 仅布局脏渲染器及其子项

异步布局和同步布局

增量布局是异步执行的。Firefox 将增量布局的“reflow 命令”加入队列,而调度器会触发这些命令的批量执行。 WebKit 还有一个用于执行增量布局的计时器:对布局树进行遍历,并对“脏”渲染程序进行布局。

请求样式信息(例如“offsetHeight”)的脚本可以同步触发增量布局。

全局布局通常会被同步触发。

有时,由于某些属性(例如滚动位置)发生了变化,布局会在初始布局之后作为回调触发。

优化

当“调整大小”或渲染程序位置(而非大小)的变化触发布局时,系统会从缓存中获取渲染大小,而不会重新计算...

在某些情况下,只有子树会被修改,布局不是从根节点开始的。如果更改是局部的,并且不影响其周围环境,就会发生这种情况,例如插入文本字段的文本(否则每次按键都会触发从根开始的布局)。

布局流程

布局通常具有以下模式:

  1. 父级渲染器会自行确定宽度。
  2. 父级会处理子级,并执行以下操作:
    1. 放置子渲染程序(设置其 x 和 y)。
    2. 根据需要调用子布局 - 它们是脏的,或者我们处于全局布局,或出于某种其他原因 - 这会计算子项的高度。
  3. 父级使用子级的累计高度以及外边距和内边距的高度来设置自己的高度,此值将由父级渲染器的父级使用。
  4. 将其脏位设置为 false。

Firefox 使用“state”对象 (nsHTMLReflowState) 作为布局参数(称为“reflow”)。状态包括父项宽度等。

Firefox 布局的输出为“metrics”对象(nsHTMLReflowMetrics)。它将包含计算出的渲染程序高度。

宽度计算

渲染程序的宽度根据容器块的宽度、渲染程序的样式“width”属性、外边距和边框计算得出。

例如,以下 div 的宽度:

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

WebKit 的计算公式如下(RenderBox 类中的 calcWidth 方法):

  • 容器宽度是容器 availableWidth 和 0 中的最大值。在本例中, availableWidth 是 contentWidth,计算公式如下:
clientWidth() - paddingLeft() - paddingRight()

clientWidth 和 clientHeight 表示一个对象的内部(不包括边框和滚动条)。

  • 元素的宽度是“width”样式属性。系统会通过计算容器宽度的百分比来计算出绝对值。

  • 现在添加了水平边框和内边距。

到目前为止,这是“首选宽度”的计算结果。 现在将计算最小和最大宽度。

如果首选宽度大于最大宽度,则使用最大宽度。 如果宽度小于最小宽度(最小的不可破坏单位),则使用最小宽度。

在需要布局但宽度不变的情况下,系统会缓存这些值。

换行

当处于布局中间的渲染程序确定需要中断该渲染程序时,它会停止,并告知布局的父项需要中断该渲染程序。 父组件会创建额外的渲染程序,并对其调用布局。

绘画

在绘制阶段,系统会遍历渲染树,并调用渲染程序的“paint()”方法,以在屏幕上显示内容。绘制会使用界面基础架构组件。

全球性和增量

与布局一样,绘制也可以是全局(绘制整个树)或增量绘制。在增量绘制中,部分渲染程序发生了更改,但不会影响整个树。更改后的渲染程序会使其在屏幕上的矩形失效。这会导致操作系统将其视为“脏区域”并生成“绘制”事件。 操作系统很巧妙地将多个区域合并成了一个区域。在 Chrome 中,情况要更复杂一些,因为渲染器的进程与主进程不同。Chrome 会在某种程度上模拟操作系统的行为。 展示层会监听这些事件,并将消息委托给呈现根节点。系统会遍历呈现树,直到找到相关的渲染程序。该呈现器会自行重绘(通常是其子代)。

绘制顺序

CSS2 定义了绘制过程的顺序。 这实际上是元素在堆叠上下文中的堆叠顺序。这些堆栈会从后往前绘制,因此这个顺序会影响绘制。块渲染程序的堆叠顺序如下:

  1. 背景颜色
  2. 背景图片
  3. 边框
  4. 孩子
  5. Outline

Firefox 显示列表

Firefox 遍历渲染树,为绘制的矩形构建一个显示列表。 它包含与矩形相关的呈现器,按正确的绘制顺序(呈现器的背景,然后是边框等)排列。

这样一来,在重新绘制时,只需遍历一次树,而不是多次遍历 - 绘制所有背景,然后绘制所有图片,再绘制所有边框等等。

Firefox 对处理过程进行了优化,它不添加隐藏的元素,比如完全位于其他不透明元素下方的元素。

WebKit 矩形存储

在重新绘制之前,WebKit 会将旧的矩形保存为位图。然后只绘制新旧矩形之间的增量。

动态变化

在发生变化时,浏览器会尽可能地做出尽可能少的操作。因此,元素的颜色改变后,只会对该元素进行重绘。 更改元素位置将导致元素及其子元素(可能还有同级元素)的布局和重绘。 添加 DOM 节点将导致对该节点进行布局和重新绘制。重大更改(例如增大“html”元素的字体大小)会导致缓存失效、对整个树进行重新布局和重新绘制。

渲染引擎的线程

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

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

事件循环

浏览器的主线程是事件循环。它是一个无限循环,可以使进程保持活跃状态。它会等待事件(如布局和绘制事件)并处理这些事件。以下是用于主事件循环的 Firefox 代码:

while (!mExiting)
    NS_ProcessNextEvent(thread);

CSS2 可视化模型

画布

根据 CSS2 规范,“画布”一词是指“呈现格式结构的空间”,即浏览器绘制内容的位置。

画布的空间尺寸是无限的,但浏览器会根据视口的尺寸选择初始宽度。

根据 www.w3.org/TR/CSS2/zindex.html,画布如果包含在另一个画布中,则是透明的,如果不包含,则由浏览器定义颜色。

CSS 框模型

CSS 框模型描述的是为文档树中的元素生成并根据可视化格式模型进行布局的矩形框。

每个框都有一个内容区域(例如文本、图片等)以及可选的周围内边距、边框和外边距区域。

CSS2 框模型
图 19:CSS2 框模型

每个节点会生成 0...n 个这样的框。

所有元素都有一个“display”属性,用于确定生成的框类型。

示例:

block: generates a block box.
inline: generates one or more inline boxes.
none: no box is generated.

默认值是内嵌的,但浏览器样式表可以设置其他默认值。 例如:“div”元素的默认显示方式为 block。

您可以在这里找到默认样式表示例:www.w3.org/TR/CSS2/sample.html

定位方案

有三种方案:

  1. 常规:根据其在文档中的位置放置对象。这意味着它在渲染树中的位置类似于它在 DOM 树中的位置,并根据其框类型和尺寸进行布局
  2. 浮动:对象首先按正常流程布局,然后尽可能向左或向右移动
  3. 绝对:对象放在渲染树中的位置与 DOM 树中不同

定位方案由“position”属性和“float”属性设置。

  • 静态和相对会导致正常流
  • 绝对定位和固定原因绝对定位

静态定位时,未定义位置,使用默认定位。在其他方案中,作者指定位置:上、下、左、右。

盒子的布局方式取决于:

  • 方框类型
  • 框尺寸
  • 定位方案
  • 外部信息,例如图片大小和屏幕尺寸

框类型

Block 框:形成一个 block,在浏览器窗口中拥有自己的矩形。

“屏蔽”框。
图 20:block 框

inline 框:没有自己的块,但位于容器块内。

内嵌框。
图 21:内嵌框

块会一个接一个地垂直设置格式。内嵌是水平格式。

块和内嵌格式。
图 22:块和内嵌格式

inline 框位于行中或“行框”中。这些线条至少与最高的框一样高,但可以更高,当框对齐“基准”时,这意味着元素的底部对齐到另一个框以外的位置,而不是底部。 如果容器宽度不足,则内嵌会分多行显示。这通常是在段落中发生的。

线条。
图 23:行

Positioning

相关

相对定位 - 按照常规方式定位,然后按所需的增量移动。

相对定位。
图 24:相对定位

浮点数

浮动框会移动至线条的左侧或右侧。有趣的特征是其他框围绕着它。HTML:

<p>
  <img style="float: right" src="images/image.gif" width="100" height="100">
  Lorem ipsum dolor sit amet, consectetuer...
</p>

显示效果如下:

浮点型。
图 25:浮点

绝对定位和固定定位

无论正常流程如何,布局都是精确定义的。元素不参与常规流程。尺寸是相对于容器的尺寸而言的。 在固定中,容器就是视口。

固定位置。
图 26:固定位置

分层表示

这由 z-index CSS 属性指定。 它表示框的第三个维度:在“z 轴”上的位置。

这些框分为多个堆栈(称为堆叠上下文)。在每个堆栈中,会首先绘制后面的元素,然后在顶部绘制前面的元素,以便更靠近用户。如果重叠,则最前面的元素会隐藏前一个元素。

堆栈根据 Z-index 属性进行排序。具有“Z-index”属性的框构成本地堆栈。视口具有外部堆栈。

例如:

<style type="text/css">
  div {
    position: absolute;
    left: 2in;
    top: 2in;
  }
</style>

<p>
  <div
    style="z-index: 3;background-color:red; width: 1in; height: 1in; ">
  </div>
  <div
    style="z-index: 1;background-color:green;width: 2in; height: 2in;">
  </div>
</p>

结果如下所示:

固定位置。
图 27:固定位置

尽管红色 div 在标记中位于绿色 div 之前,并且之前应在常规 flow 中绘制,但 Z-index 属性更高,因此它在根框的堆栈中更靠前。

资源

  1. 浏览器架构

    1. Graskurth、Alan。网络浏览器参考架构 (pdf)
    2. 古普塔、维内特。浏览器的工作原理 - 第 1 部分 - 架构
  2. 解析

    1. Aho, Sethi, Ullman, Compilers: Principles, Techniques and Tools(也称为“Dragon book”), Addison-Wesley, 1986
    2. Rick Jelliffe。The Bold and the Beautiful: 2 个新的 HTML 5 草稿版本。
  3. Firefox

    1. L. David Baron,Faster HTML and CSS: Layout Engine Internals for Web Developers。
    2. L. David Baron,快速 HTML 和 CSS:面向 Web 开发者的布局引擎内部原理(Google 技术讲座视频)
    3. L. David Baron,Mozilla 的布局引擎
    4. L. David Baron,Mozilla Style System 文档
    5. Chris Waterson,关于 HTML 重排的说明
    6. Chris Waterson,Gecko 概览
    7. Alexander Larsson,HTML HTTP 请求的生命周期
  4. WebKit

    1. David Hyatt,实现 CSS(第 1 部分)
    2. David Hyatt,WebCore 概览
    3. David Hyatt,WebCore Rendering
    4. David Hyatt,The FOUC Problem
  5. W3C 规范

    1. HTML 4.01 规范
    2. W3C HTML5 规范
    3. 层叠样式表第 2 级修订版 1 (CSS 2.1) 规范
  6. 浏览器构建说明

    1. Firefox:https://developer.mozilla.org/Build_Documentation
    2. WebKit:http://webkit.org/building/build.html

翻译

此网页已两次翻译为日语:

您可以查看外部托管的韩语土耳其语翻译。

感谢大家!