浏览器的工作方式

现代网络浏览器的幕后

序言

这本关于 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%。

浏览器的主要功能

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

HTML 和 CSS 规范中指定了浏览器解读和显示 HTML 文件的方式。 这些规范由 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。

主要流程

渲染引擎将开始从网络层获取请求的文档内容。这通常会以 8KB 的块进行。

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

渲染引擎基本流程
图 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 表示的语法。如需了解正式定义,请参阅 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 的语法不是无上下文的,因此传统解析器无法轻松解析 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 树
图 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 对象。在树构建阶段,系统会修改根目录中包含文档的 DOM 树,并向其中添加元素。分词器发出的每个节点都将由树构造函数处理。对于每个令牌,规范会定义哪些 DOM 元素与其相关,以及将为此令牌创建哪些 DOM 元素。该元素会添加到 DOM 树和打开的元素堆栈中。此堆栈用于更正嵌套不匹配和未闭合标记。 该算法还可描述为状态机。这些状态称为“插入模式”。

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

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

树构建阶段的输入是令牌化阶段的一系列令牌。第一种模式是“初始模式”。收到“html”令牌将导致系统切换到“html 之前”模式,并在该模式下重新处理令牌。这将导致创建 HTMLHtmlElement 元素,该元素将附加到根 Document 对象。

状态将更改为“在 head 之前”。然后,系统会收到“body”令牌。系统会隐式创建 HTMLHeadElement,即使我们没有“head”令牌,它也会被添加到树中。

现在,我们将进入“在头部前面”模式,然后进入“在头部后面”模式。系统会重新处理正文令牌,创建并插入 HTMLBodyElement,并将模式转换为“in body”

现在,系统会收到“Hello world”字符串的字符令牌。第一个字符会导致创建并插入“文本”节点,其他字符会附加到该节点。

收到正文结束令牌后,系统会转换为“正文后”模式。现在,我们将收到 html 结束标记,这会将我们转换到“body 后”模式。收到文件结束令牌后,解析将结束。

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

解析完成后的操作

在此阶段,浏览器会将文档标记为交互式,并开始解析处于“延迟”模式的脚本:这些脚本应在文档解析后执行。然后,文档状态将设为“complete”,并触发“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”是标识符(例如类名称)的简称。“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 和 5 规范中也有说明。作者可以向脚本添加“defer”属性,在这种情况下,脚本不会停止文档解析,而是会在文档解析完毕后执行。HTML5 添加了一个选项,用于将脚本标记为异步,以便由其他线程解析和执行。

推测解析

WebKit 和 Firefox 都会执行此优化。在执行脚本时,另一个线程会解析文档的其余部分,找出需要从网络加载的其他资源,并加载这些资源。这样,系统就可以在并行连接上加载资源,从而提高整体速度。注意:推测性解析器仅解析对外部资源(例如外部脚本、样式表和图片)的引用:它不会修改 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 代码,用于根据 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”元素。此外,如果将显示值分配为“none”,则相应元素不会显示在树中(而具有“hidden”可见性的元素会显示在树中)。

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

多渲染程序的另一个示例是损坏的 HTML。根据 CSS 规范,内嵌元素必须仅包含块元素或仅包含内嵌元素。对于混合内容,系统会创建匿名块渲染程序来封装内嵌元素。

某些渲染对象与 DOM 节点相对应,但在树中的位置不同。浮动元素和绝对定位元素不在流中,放置在树的其他部分,并映射到真实框架。占位符框应该是它们原本所在的位置。

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

构建树的流程

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

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

处理 html 和 body 标记会导致构建渲染树根。根渲染对象对应于 CSS 规范中所称的“包含块”:包含所有其他块的顶级块。其尺寸为视口:浏览器窗口显示区域尺寸。 Firefox 将其称为 ViewPortFrame,WebKit 将其称为 RenderView。这是文档指向的渲染对象。树的其余部分构建为 DOM 节点插入。

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

样式计算

构建渲染树需要计算每个渲染对象的视觉属性。具体方法是计算每个元素的样式属性。

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

样式表的起源是浏览器的默认样式表、网页作者提供的样式表和用户样式表,这些是浏览器用户提供的样式表(浏览器允许您定义自己喜欢的样式。例如,在 Firefox 中,只需将样式表放入“Firefox 个人资料”文件夹即可。

样式计算会带来一些困难:

  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。现在,我们要做的工作会更少。

我们来看看树形图如何帮助我们节省工作量。

拆分为结构体

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

该树会在树中缓存整个结构体(包含计算的结束值),从而帮助我们。其基本思想是,如果底部节点未为结构体提供定义,则可以使用上层节点中的缓存结构体。

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

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

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

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

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

如果某个元素具有指向同一树节点的兄弟或姐妹元素,则它们之间可以共享整个样式上下文

我们来看一个示例: 假设我们有以下 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 结构体包含四个边。

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

规则树
图 16:规则树

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

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

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

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

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

现在,我们需要填充样式结构体。我们先填充 margin 结构体。由于最后一个规则节点 (F) 不会添加到边距结构体中,因此我们可以向上遍历树,直到找到在之前的节点插入中计算的缓存结构体并使用它。我们将在节点 B 上找到它,该节点是指定边距规则的顶部节点。

我们确实有一个颜色结构体定义,因此无法使用缓存的结构体。 由于 color 只有一个属性,因此我们无需向上遍历树来填充其他属性。我们将计算结束值(将字符串转换为 RGB 等),并在此节点上缓存计算的结构体。

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

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

例如,如果我们为段落中的字体添加了规则:

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

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

在 WebKit(没有规则树)中,系统会四次遍历匹配的声明。系统会先应用不重要的高优先级属性(应先应用的属性,因为其他属性依赖于它们,例如显示屏),然后应用重要的高优先级属性,然后应用不重要的正常优先级属性,最后应用重要的正常优先级属性。这意味着,重复出现的媒体资源将按正确的级联顺序解析。最后一个胜出。

总而言之:共享样式对象(全部或其中的某些结构体)可解决问题 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,其尺寸为视口(浏览器窗口的可见部分)。

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

脏位系统

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

有两个标志:“脏”和“子项脏”,这意味着,虽然渲染程序本身可能没问题,但它至少有一个子项需要布局。

全局布局和增量布局

布局可在整个渲染树上触发,这称为“全局”布局。造成这种情况的原因可能是:

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

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

当渲染程序脏时,系统会触发增量布局(异步)。例如,当额外内容从网络传入并添加到 DOM 树后,将新的渲染程序附加到渲染树中时。

增量布局。
图 18:增量布局 - 仅排列脏渲染程序及其子项

异步布局和同步布局

增量布局是异步完成的。Firefox 会为增量布局队列“重新流式传输命令”,并且调度程序会触发这些命令的批量执行。WebKit 还有一个用于执行增量布局的计时器,它会遍历树并布局“脏”渲染程序。

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

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

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

优化

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

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

布局流程

布局通常采用以下模式:

  1. 父级渲染程序确定自己的宽度。
  2. 父级会检查子级,并执行以下操作:
    1. 放置子渲染程序(设置其 x 和 y)。
    2. 在需要时调用子布局(如果它们已脏或我们处于全局布局中,或出于其他原因),以计算子项的高度。
  3. 父级使用子项的累计高度以及边距和内边距的高度来设置自己的高度,父级渲染程序的父级将使用此高度。
  4. 将其脏位设置为 false。

Firefox 使用“状态”对象 (nsHTMLReflowState) 作为布局(称为“重新流式传输”)的参数。该状态包括父级宽度在内的其他信息。

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”元素的默认显示方式为“块”。

您可以点击以下网址查看默认样式表示例:www.w3.org/TR/CSS2/sample.html

定位方案

有三种方案:

  1. 正常:对象会根据其在文档中的位置进行定位。这意味着,其在渲染树中的位置与其在 DOM 树中的位置一样,并会根据其盒子类型和尺寸进行布局
  2. 浮动:对象会先按正常流程排列,然后尽可能向左或向右移动
  3. 绝对:对象在渲染树中的位置与在 DOM 树中的位置不同

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

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

在静态定位中,系统不会定义任何位置,而是使用默认定位。在其他方案中,作者指定位置:顶部、底部、左侧、右侧。

框的布局方式取决于:

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

Box 类型

版块框:用于构成版块,在浏览器窗口中具有自己的矩形。

屏蔽框。
图 20:分块框

内嵌盒:没有自己的块,但位于包含块内。

内嵌框。
图 21:内嵌框

区块的格式为垂直排列。内嵌内容采用水平格式。

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

内嵌框放置在线条或“线条框”内。 如果这些框采用“基准”对齐方式(即元素的底部与另一个框的非底部对齐),线条的高度至少与最高的框一样高,但可以更高。 如果容器宽度不足,内嵌内容将被放置在多行中。这通常是段落中发生的情况。

线条。
图 23:线条

定位

相对

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

相对定位。
图 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 之前,并且在常规流程中会先于后者绘制,但其 z-index 属性更高,因此在根盒子所持的堆栈中位于更靠前的位置。

资源

  1. 浏览器架构

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

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

    1. L. David Baron,Faster HTML and CSS: Layout Engine Internals for Web Developers(更快的 HTML 和 CSS:面向 Web 开发者的布局引擎内部)。
    2. L. David Baron,Faster HTML and CSS: Layout Engine Internals for Web Developers(面向 Web 开发者的布局引擎内部机制)(Google 技术演讲视频)
    3. L. David Baron,Mozilla 的布局引擎
    4. L. David Baron,Mozilla 样式系统文档
    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 渲染团队
    4. David Hyatt,FOUC 问题
  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

翻译

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

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

谢谢大家!