浏览器的工作原理

现代 Web 浏览器的幕后

前言

这篇关于 WebKit 和 Gecko 内部操作的综合入门读物是以色列开发者 Tali Garsiel 大量研究的成果。几年来,她回顾了所有关于浏览器内部机制的已发布数据,并花费了大量时间阅读 Web 浏览器源代码。她写道

作为一名 Web 开发者,了解浏览器操作的内部机制有助于您做出更好的决策,并了解开发最佳实践背后的理由。虽然这是一篇相当长的文档,但我们建议您花一些时间深入研究。您会庆幸自己这么做了。

Paul Irish,Chrome 开发者关系

简介

Web 浏览器是使用最广泛的软件。在本入门读物中,我将解释它们在幕后的工作原理。我们将了解当您在地址栏中键入 google.com 直到在浏览器屏幕上看到 Google 页面时会发生什么。

我们将讨论的浏览器

如今,桌面上有五种主要浏览器:Chrome、Internet Explorer、Firefox、Safari 和 Opera。在移动设备上,主要浏览器是 Android Browser、iPhone、Opera Mini 和 Opera Mobile、UC 浏览器、Nokia S40/S60 浏览器和 Chrome,除了 Opera 浏览器外,所有这些浏览器都基于 WebKit。我将提供来自开源浏览器 Firefox 和 Chrome 以及 Safari(部分开源)的示例。根据 StatCounter 统计数据(截至 2013 年 6 月),Chrome、Firefox 和 Safari 约占全球桌面浏览器使用量的 71%。在移动设备上,Android Browser、iPhone 和 Chrome 约占使用量的 54%。

浏览器的主要功能

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

浏览器解释和显示 HTML 文件的方式在 HTML 和 CSS 规范中指定。这些规范由 W3C(万维网联盟)组织维护,该组织是 Web 的标准组织。多年来,浏览器仅遵守规范的一部分,并开发了自己的扩展。这给 Web 作者带来了严重的兼容性问题。如今,大多数浏览器或多或少地符合规范。

浏览器用户界面有很多共同之处。常见的用户界面元素包括

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

奇怪的是,浏览器的用户界面在任何正式规范中都没有指定,它只是来自多年经验形成的良好实践以及浏览器彼此模仿。HTML5 规范没有定义浏览器必须具有的 UI 元素,但列出了一些常见元素。其中包括地址栏、状态栏和工具栏。当然,某些功能是特定于浏览器的,例如 Firefox 的下载管理器。

高级基础设施

浏览器的主要组件是

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

主要流程

渲染引擎将开始从网络层获取请求文档的内容。这通常以 8kB 的块完成。

之后,这是渲染引擎的基本流程

Rendering engine basic flow
图 2:渲染引擎基本流程

渲染引擎将开始解析 HTML 文档,并将元素转换为“内容树”中称为 DOM 节点。引擎将解析样式数据,包括外部 CSS 文件和样式元素。样式信息以及 HTML 中的视觉指令将用于创建另一棵树:渲染树

渲染树包含具有颜色和尺寸等视觉属性的矩形。矩形按照正确的顺序排列以显示在屏幕上。

渲染树构建完成后,它将经历一个“布局”过程。这意味着为每个节点提供它应出现在屏幕上的确切坐标。下一个阶段是 绘制 - 将遍历渲染树,并使用 UI 后端层绘制每个节点。

重要的是要理解这是一个渐进的过程。为了获得更好的用户体验,渲染引擎将尝试尽快在屏幕上显示内容。它不会等到所有 HTML 都解析完才开始构建和布局渲染树。内容的部分将被解析和显示,同时该过程继续处理从网络持续传来的其余内容。

主要流程示例

WebKit main flow.
图 3:WebKit 主要流程
Mozilla's Gecko rendering engine main flow.
图 4:Mozilla 的 Gecko 渲染引擎主要流程

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

Gecko 将视觉格式化元素的树称为“Frame 树”。每个元素都是一个框架。WebKit 使用术语“Render Tree”,它由“Render Objects”组成。WebKit 使用术语“layout”来表示元素的放置,而 Gecko 称之为“Reflow”。“Attachment”是 WebKit 用于连接 DOM 节点和视觉信息以创建渲染树的术语。一个较小的非语义差异是 Gecko 在 HTML 和 DOM 树之间有一个额外的层。它被称为“content sink”,是用于创建 DOM 元素的工厂。我们将讨论流程的每个部分

解析 - 概述

由于解析是渲染引擎中非常重要的过程,我们将更深入地研究它。让我们从对解析的简单介绍开始。

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

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

Mathematical expression tree node.
图 5:数学表达式树节点

语法

解析基于文档遵守的语法规则:编写文档所用的语言或格式。您可以解析的每种格式都必须具有由词汇表和语法规则组成的确定性语法。它被称为 上下文无关语法。人类语言不是此类语言,因此无法使用传统的解析技术进行解析。

解析器 - 词法分析器组合

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

词法分析是将输入分解为标记的过程。标记是语言词汇表:有效构建块的集合。在人类语言中,它将包含该语言字典中出现的所有单词。

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

解析器通常在两个组件之间分配工作:词法分析器(有时称为标记器),负责将输入分解为有效标记;以及 解析器,负责通过根据语言语法规则分析文档结构来构建解析树。

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

From source document to parse trees
图 6:从源文档到解析树

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

如果没有规则匹配,解析器会将标记存储在内部,并继续请求标记,直到找到与所有内部存储的标记匹配的规则。如果找不到规则,则解析器将引发异常。这意味着文档无效并包含语法错误。

翻译

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

Compilation flow
图 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 完全表达的语法。有关正式定义,请参阅 Wikipedia 上关于上下文无关语法的文章

解析器类型

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

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

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

自底向上解析器将扫描输入,直到规则匹配。然后,它将用规则替换匹配的输入。这将一直持续到输入结束。部分匹配的表达式放置在解析器的堆栈上。

堆栈 输入
2 + 3 - 1
+ 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 如此流行的主要原因:它会原谅您的错误,并让 Web 作者的生活变得轻松。另一方面,这使得编写正式语法变得困难。因此,总结一下,HTML 不能轻易地被传统的解析器解析,因为它的语法不是上下文无关的。XML 解析器无法解析 HTML。

HTML DTD

HTML 定义采用 DTD 格式。此格式用于定义 SGML 系列的语言。该格式包含所有允许的元素、它们的属性和层次结构的定义。正如我们之前看到的,HTML DTD 不构成上下文无关语法。

DTD 有一些变体。严格模式完全符合规范,但其他模式包含对过去浏览器使用的标记的支持。目的是向后兼容旧内容。当前的严格 DTD 在这里:www.w3.org/TR/html4/strict.dtd

DOM

输出树(“解析树”)是 DOM 元素和属性节点的树。DOM 是文档对象模型的缩写。它是 HTML 文档的对象表示形式以及 HTML 元素与外部世界(如 JavaScript)的接口。

树的根是“Document”对象。

DOM 与标记几乎具有一对一的关系。例如

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

此标记将转换为以下 DOM 树

DOM tree of the example markup
图 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 parsing flow (taken from HTML5 spec)
图 9:HTML 解析流程(取自 HTML5 规范)

标记化算法

该算法的输出是一个 HTML 标记。该算法表示为状态机。每个状态都使用输入流的一个或多个字符,并根据这些字符更新下一个状态。该决定受当前标记化状态和树构建状态的影响。这意味着相同的已用字符将为正确的下一个状态产生不同的结果,具体取决于当前状态。该算法太复杂,无法完全描述,因此让我们看一个简单的示例,这将有助于我们理解原理。

基本示例 - 标记化以下 HTML

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

初始状态是“数据状态”。当遇到 < 字符时,状态更改为 “标签打开状态”。使用 a-z 字符会导致创建“开始标签标记”,状态更改为 “标签名称状态”。我们将保持此状态,直到使用 > 字符。每个字符都附加到新标记名称。在我们的例子中,创建的标记是 html 标记。

当到达 > 标签时,将发出当前标记,并且状态更改回 “数据状态”<body> 标签将通过相同的步骤处理。到目前为止,已发出 htmlbody 标签。我们现在回到 “数据状态”。使用 Hello worldH 字符将导致创建和发出字符标记,这将一直持续到 </body>< 到达。我们将为 Hello world 的每个字符发出一个字符标记。

我们现在回到 “标签打开状态”。使用下一个输入 / 将导致创建 结束标签标记 并移动到 “标签名称状态”。同样,我们将保持此状态,直到到达 >。然后将发出新的标签标记,我们返回到 “数据状态”</html> 输入将像前面的情况一样处理。

Tokenizing the example input
图 10:标记化示例输入

树构建算法

创建解析器时,将创建 Document 对象。在树构建阶段,将修改以 Document 为根的 DOM 树,并将元素添加到其中。标记器发出的每个节点都将由树构造器处理。对于每个标记,规范定义了与其相关的 DOM 元素,并将为此标记创建该元素。该元素将添加到 DOM 树以及打开元素的堆栈中。此堆栈用于纠正嵌套不匹配和未闭合的标签。该算法也描述为状态机。状态称为“插入模式”。

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

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

树构建阶段的输入是来自标记化阶段的标记序列。第一个模式是 “初始模式”。接收到“html”标记将导致移动到 “html 前” 模式,并在该模式下重新处理标记。这将导致创建 HTMLHtmlElement 元素,该元素将附加到根 Document 对象。

状态将更改为 “head 前”。然后接收到“body”标记。即使我们没有“head”标记,也会隐式创建一个 HTMLHeadElement,并将其添加到树中。

我们现在移动到 “head 中” 模式,然后移动到 “head 后”。“body”标记被重新处理,创建一个 HTMLBodyElement 并插入,模式转移到 “body 中”

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

接收到 body 结束标记将导致转移到 “body 后” 模式。我们现在将接收 html 结束标签,这将使我们移动到 “body 后后” 模式。接收到文件结束标记将结束解析。

Tree construction of example HTML.
图 11:示例 html 的树构建

解析完成时的操作

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

您可以在 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. 我们想在内联元素内添加块元素。关闭所有内联元素,直到下一个更高级别的块元素。
  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;

因此,Web 作者请注意 - 除非您想作为 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 代表空格)。一个规则集包含花括号,花括号内包含一个声明,或者可选择地是由分号分隔的多个声明。“declaration”(声明)和 “selector”(选择器)将在以下 BNF 定义中定义。

WebKit CSS 解析器

WebKit 使用 Flex 和 Bison 解析器生成器,从 CSS 语法文件自动创建解析器。正如您从解析器介绍中回忆的那样,Bison 创建一个自底向上的移位-归约解析器。Firefox 使用手动编写的自顶向下解析器。在这两种情况下,每个 CSS 文件都被解析为一个 StyleSheet 对象。每个对象都包含 CSS 规则。CSS 规则对象包含选择器和声明对象以及其他对应于 CSS 语法对象。

Parsing CSS.
图 12:解析 CSS

脚本和样式表的处理顺序

脚本

Web 的模型是同步的。作者期望脚本在解析器到达 <script> 标签时立即被解析和执行。文档的解析会暂停,直到脚本执行完毕。如果脚本是外部的,则必须首先从网络获取资源 - 这也是同步完成的,并且解析会暂停,直到资源被获取。这是多年的模型,并且也在 HTML4 和 5 规范中指定。作者可以向脚本添加 "defer" 属性,在这种情况下,它不会暂停文档解析,而是在文档解析完成后执行。HTML5 添加了一个选项,可以将脚本标记为异步,以便它将由不同的线程解析和执行。

投机性解析

WebKit 和 Firefox 都进行了此优化。在执行脚本时,另一个线程解析文档的其余部分,并找出还需要从网络加载哪些其他资源并加载它们。通过这种方式,资源可以在并行连接上加载,并且总体速度得到提高。注意:投机性解析器仅解析对外部资源的引用,例如外部脚本、样式表和图像:它不修改 DOM 树 - 这留给主解析器。

样式表

另一方面,样式表具有不同的模型。从概念上讲,似乎由于样式表不更改 DOM 树,因此没有理由等待它们并停止文档解析。但是,存在一个问题,即脚本在文档解析阶段请求样式信息。如果样式尚未加载和解析,则脚本将获得错误的答案,显然这引起了很多问题。这似乎是一个边缘情况,但很常见。当存在仍在加载和解析的样式表时,Firefox 会阻止所有脚本。WebKit 仅在脚本尝试访问可能受未加载样式表影响的某些样式属性时才阻止脚本。

渲染树构建

在构建 DOM 树的同时,浏览器构建另一个树,即渲染树。此树由视觉元素组成,并按它们将被显示的顺序排列。它是文档的可视化表示。此树的目的是使内容能够以正确的顺序绘制。

Firefox 将渲染树中的元素称为 "frames"(帧)。WebKit 使用术语 renderer(渲染器)或 render object(渲染对象)。

渲染器知道如何布局和绘制自身及其子元素。

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 代码,用于根据 display 属性决定应为 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” 元素就是一个例子。此外,display 值被设置为 "none" 的元素也不会出现在树中(而 visibility 为 "hidden" 的元素将出现在树中)。

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

多个渲染器的另一个例子是损坏的 HTML。根据 CSS 规范,内联元素必须仅包含块级元素或仅包含内联元素。在混合内容的情况下,将创建匿名块级渲染器来包裹内联元素。

有些渲染对象对应于 DOM 节点,但不在树中的同一位置。浮动元素和绝对定位元素脱离文档流,放置在树的不同部分,并映射到真实的帧。占位符帧是它们应该在的位置。

The render tree and the corresponding DOM tree.
图 13:渲染树和相应的 DOM 树。“Viewport”(视口)是初始包含块。在 WebKit 中,它将是 "RenderView" 对象。

构建树的流程

在 Firefox 中,presentation 被注册为 DOM 更新的监听器。presentation 将帧创建委托给 FrameConstructor,构造函数解析样式(请参阅 样式计算)并创建一个帧。

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

处理 html 和 body 标签会导致渲染树根的构建。根渲染对象对应于 CSS 规范所称的包含块:包含所有其他块的最顶层块。其尺寸是视口:浏览器窗口显示区域的尺寸。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 style context tree.
图 14:Firefox 样式上下文树。

样式上下文包含最终值。这些值是通过按正确的顺序应用所有匹配的规则并执行将它们从逻辑值转换为具体值的操作来计算的。例如,如果逻辑值是屏幕的百分比,它将被计算并转换为绝对单位。规则树的想法非常聪明。它使节点之间能够共享这些值,从而避免再次计算它们。这也节省了空间。

所有匹配的规则都存储在一棵树中。路径中的底部节点具有更高的优先级。该树包含找到的规则匹配的所有路径。规则的存储是惰性的。树不是在开始时为每个节点计算的,而是每当需要计算节点样式时,计算出的路径都会添加到树中。

这个想法是将树路径视为词汇表中的单词。假设我们已经计算了这棵规则树

Computed rule tree
图 15:计算出的规则树。

假设我们需要为内容树中的另一个元素匹配规则,并发现匹配的规则(按正确的顺序)是 B-E-I。我们已经在树中有了这条路径,因为我们已经计算了路径 A-B-E-I-L。我们现在需要做的工作更少。

让我们看看树如何为我们节省工作量。

分割成结构体

样式上下文被分成结构体。这些结构体包含某个类别的样式信息,例如边框或颜色。结构体中的所有属性要么是继承的,要么是非继承的。继承属性是除非由元素定义,否则从其父元素继承的属性。非继承属性(称为 “reset” 属性)如果未定义,则使用默认值。

树通过在树中缓存整个结构体(包含计算出的最终值)来帮助我们。这个想法是,如果底部节点没有为结构体提供定义,则可以使用上层节点中的缓存结构体。

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

当为某个元素计算样式上下文时,我们首先在规则树中计算一条路径或使用现有路径。然后,我们开始应用路径中的规则来填充新样式上下文中的结构体。我们从路径的底部节点开始 - 优先级最高的节点(通常是最具体的选择器),并向上遍历树,直到我们的结构体被填满。如果该规则节点中没有结构体的规范,那么我们可以大大优化 - 我们向上遍历树,直到找到一个完全指定它的节点并指向它 - 这是最好的优化 - 整个结构体都被共享了。这节省了最终值的计算和内存。

如果我们找到部分定义,我们将向上遍历树,直到结构体被填满。

如果我们没有找到结构体的任何定义,那么如果结构体是 “inherited”(继承)类型,我们将指向父元素在 上下文树 中的结构体。在这种情况下,我们也成功地共享了结构体。如果是 reset 结构体,则将使用默认值。

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

如果一个元素有一个指向同一树节点的兄弟节点或兄弟元素,那么 整个样式上下文 可以在它们之间共享。

让我们看一个例子:假设我们有这个 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 结构体仅包含一个成员:颜色。margin 结构体包含四个边。

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

The rule tree
图 16:规则树

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

The context tree.
图 17:上下文树

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

我们将匹配规则并发现 <div> 的匹配规则是 1、2 和 6。这意味着树中已经存在我们的元素可以使用的路径,我们只需要为规则 6(规则树中的节点 F)向其添加另一个节点。

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

我们现在需要填充样式结构体。我们将从填充 margin 结构体开始。由于最后一个规则节点 (F) 没有添加到 margin 结构体,我们可以向上遍历树,直到找到在先前节点插入中计算出的缓存结构体并使用它。我们将在节点 B 上找到它,节点 B 是指定 margin 规则的最顶层节点。

我们确实有 color 结构体的定义,所以我们不能使用缓存的结构体。由于 color 只有一个属性,我们不需要向上遍历树来填充其他属性。我们将计算最终值(将字符串转换为 RGB 等)并将计算出的结构体缓存在此节点上。

对第二个 <span> 元素的工作甚至更容易。我们将匹配规则并得出结论,它指向规则 G,就像之前的 span 一样。由于我们有指向同一节点的兄弟节点,我们可以共享整个样式上下文,只需指向前一个 span 的上下文即可。

对于包含从父元素继承的规则的结构体,缓存在上下文树上完成(color 属性实际上是继承的,但 Firefox 将其视为 reset 并将其缓存在规则树上)。

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

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

那么段落元素(它是上下文树中 div 的子元素)本可以与他的父元素共享相同的字体结构体。前提是没有为段落指定字体规则。

在没有规则树的 WebKit 中,匹配的声明会被遍历四次。首先应用非重要的、高优先级的属性(应首先应用的属性,因为其他属性依赖于它们,例如 display),然后是高优先级的重要的,然后是正常优先级的非重要的,然后是正常优先级的重要的规则。这意味着多次出现的属性将根据正确的层叠顺序解析。最后出现的规则获胜。

因此,总结一下:共享样式对象(全部或部分内部结构体)解决了问题 1 和 3。Firefox 规则树还有助于以正确的顺序应用属性。

操作规则以便于匹配

样式规则有几个来源

  1. CSS 规则,无论是在外部样式表还是在 style 元素中。 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 元素,因为我们的 div 元素没有 table 祖先。

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,其尺寸是视口 - 浏览器窗口的可见部分。

所有渲染器都有一个 "layout" 或 "reflow" 方法,每个渲染器都会调用其需要布局的子元素的布局方法。

脏位系统

为了不对每个小更改都进行完整布局,浏览器使用 “脏位” 系统。已更改或添加的渲染器将其自身及其子元素标记为 “脏”:需要布局。

有两个标志:“dirty”(脏)和 “children are dirty”(子元素是脏的),这意味着虽然渲染器本身可能没问题,但它至少有一个子元素需要布局。

全局布局和增量布局

布局可以在整个渲染树上触发 - 这就是 “全局” 布局。这可能是由于以下原因造成的

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

布局可以是增量的,只有脏渲染器会被布局(这可能会导致一些损坏,需要额外的布局)。

当渲染器变脏时,增量布局会被触发(异步地)。例如,当来自网络的额外内容被添加到 DOM 树后,新的渲染器被附加到渲染树时。

Incremental layout.
图 18:增量布局 - 仅布局脏渲染器及其子元素

异步布局和同步布局

增量布局是异步完成的。Firefox 为增量布局排队 “reflow commands”(重排命令),调度程序触发这些命令的批量执行。WebKit 也有一个计时器来执行增量布局 - 遍历树并布局 “脏” 渲染器。

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

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

有时布局会在初始布局之后作为回调触发,因为某些属性(例如滚动位置)已更改。

优化

当布局由 “resize”(调整大小)或渲染器位置的更改(而不是大小)触发时,渲染器大小会从缓存中获取,而不会重新计算……

在某些情况下,仅修改子树,并且布局不会从根开始。这可能发生在更改是局部的并且不影响其周围环境的情况下 - 例如文本插入到文本字段中(否则每次击键都会触发从根开始的布局)。

布局过程

布局通常具有以下模式

  1. 父渲染器确定自身的宽度。
  2. 父渲染器遍历子元素并
    1. 放置子渲染器(设置其 x 和 y 坐标)。
    2. 如果需要,调用子元素的布局 - 它们是脏的,或者我们处于全局布局中,或者由于其他一些原因 - 这会计算子元素的高度。
  3. 父元素使用子元素的累积高度以及内外边距的高度来设置自身的高度 - 这将被父元素渲染器的父元素使用。
  4. 将其 dirty bit 设置为 false。

Firefox 使用一个 “state” 对象 (nsHTMLReflowState) 作为布局(称为 “reflow”)的参数。其中,state 包括父元素的宽度。

Firefox 布局的输出是一个 “metrics” 对象 (nsHTMLReflowMetrics)。它将包含渲染器计算的高度。

宽度计算

渲染器的宽度是使用容器块的宽度、渲染器的样式 “width” 属性、内外边距和边框计算得出的。

例如,以下 div 的宽度

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

将由 WebKit 按照以下方式计算(RenderBox 类的 calcWidth 方法)

  • 容器宽度是容器的 availableWidth 和 0 中的最大值。在这种情况下,availableWidth 是 contentWidth,其计算方式为
clientWidth() - paddingLeft() - paddingRight()

clientWidth 和 clientHeight 表示对象的内部,不包括边框和滚动条。

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

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

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

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

这些值会被缓存,以防需要布局,但宽度没有改变。

换行

当布局中间的渲染器决定需要换行时,渲染器会停止并将需要换行的信息传递给布局的父元素。父元素创建额外的渲染器并在其上调用布局。

绘制

在绘制阶段,遍历渲染树,并调用渲染器的 “paint()” 方法以在屏幕上显示内容。绘制使用 UI 基础设施组件。

全局和增量

与布局类似,绘制也可以是全局的 - 绘制整个树 - 或者是增量的。在增量绘制中,某些渲染器的更改不会影响整个树。更改的渲染器使其在屏幕上的矩形无效。这会导致操作系统将其视为 “脏区域” 并生成 “paint” 事件。操作系统会巧妙地执行此操作,并将多个区域合并为一个区域。在 Chrome 中,情况更复杂,因为渲染器与主进程位于不同的进程中。Chrome 在某种程度上模拟了操作系统的行为。presentation 监听这些事件并将消息委托给渲染根。遍历树直到到达相关的渲染器。它将重新绘制自身(通常还有其子元素)。

绘制顺序

CSS2 定义了绘制过程的顺序。这实际上是元素在堆叠上下文中堆叠的顺序。此顺序影响绘制,因为堆叠是从后向前绘制的。块渲染器的堆叠顺序是

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

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 box model
图 19:CSS2 盒模型

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

所有元素都有一个 “display” 属性,该属性决定将生成的框的类型。

示例

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

默认值是 inline,但浏览器样式表可能会设置其他默认值。例如:“div” 元素的默认 display 值为 block。

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

定位方案

有三种方案

  1. Normal(正常):对象根据其在文档中的位置进行定位。这意味着它在渲染树中的位置就像它在 DOM 树中的位置一样,并根据其框类型和尺寸进行布局
  2. Float(浮动):对象首先像正常流一样布局,然后尽可能向左或向右移动
  3. Absolute(绝对):对象在渲染树中的位置与在 DOM 树中的位置不同

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

  • static 和 relative 会导致正常流
  • absolute 和 fixed 会导致绝对定位

在 static 定位中,未定义位置,并且使用默认定位。在其他方案中,作者指定位置:top、bottom、left、right。

框的布局方式由以下因素决定

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

框类型

Block box(块级框):形成一个块 - 在浏览器窗口中具有自己的矩形。

Block box.
图 20:块级框

Inline box(行内框):没有自己的块,但在包含块内。

Inline boxes.
图 21:行内框

块级框垂直排列,一个接一个。行内框水平排列。

Block and Inline formatting.
图 22:块级和行内格式化

行内框放置在行或 “line boxes”(行框)内。行的高度至少与最高的框一样高,但当框 “baseline”(基线)对齐时,可以更高 - 这意味着元素的底部部分与另一个框(而不是底部)的点对齐。如果容器宽度不足,则行内框将放在多行上。这通常发生在段落中。

Lines.
图 23:行

定位

Relative(相对定位)

相对定位 - 像往常一样定位,然后按所需的增量移动。

Relative positioning.
图 24:相对定位

Floats(浮动)

浮动框移至行的左侧或右侧。有趣的特点是其他框会环绕它流动。HTML

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

看起来像

Float.
图 25:浮动

Absolute and fixed(绝对定位和固定定位)

布局是精确定义的,与正常流无关。元素不参与正常流。尺寸相对于容器。在 fixed 中,容器是视口。

Fixed positioning.
图 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>

结果将是这样

Fixed positioning.
图 27:固定定位

尽管红色 div 在标记中位于绿色 div 之前,并且在常规流中会在之前绘制,但 z-index 属性更高,因此它在根框持有的堆叠中更靠前。

资源

  1. 浏览器架构

    1. Grosskurth, Alan. Web 浏览器参考架构 (pdf)
    2. Gupta, Vineet. 浏览器的工作原理 - 第 1 部分 - 架构
  2. 解析

    1. Aho, Sethi, Ullman, 编译器:原理、技术和工具(又名 “龙书”),Addison-Wesley,1986
    2. Rick Jelliffe. 大胆而美丽:HTML 5 的两个新草案。
  3. Firefox

    1. L. David Baron, 更快的 HTML 和 CSS:面向 Web 开发人员的布局引擎内部原理。
    2. L. David Baron, 更快的 HTML 和 CSS:面向 Web 开发人员的布局引擎内部原理(Google 技术讲座视频)
    3. L. David Baron, Mozilla 的布局引擎
    4. L. David Baron, Mozilla 样式系统文档
    5. Chris Waterson, HTML Reflow 注释
    6. Chris Waterson, Gecko 概述
    7. Alexander Larsson, HTML HTTP 请求的生命周期
  4. WebKit

    1. David Hyatt, 实现 CSS(第 1 部分)
    2. David Hyatt, WebCore 概述
    3. David Hyatt, WebCore 渲染
    4. David Hyatt, FOUC 问题
  5. W3C 规范

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

    1. Firefox. https://mdn.org.cn/Build_Documentation
    2. WebKit. http://webkit.org/building/build.html

翻译

此页面已被翻译成日语两次

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

感谢大家!