现代网络浏览器的幕后故事
序言
这篇关于 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 文件的方式在 HTML 和 CSS 规范中指定。 这些规范由 W3C(万维网联盟)组织维护,该组织是 Web 标准组织。多年来,浏览器仅遵循部分规范,并开发了自己的扩展。这给 Web 作者带来了严重的兼容性问题。如今,大多数浏览器或多或少都符合规范。
浏览器界面有很多共同点。常见的界面元素包括:
- 用于插入 URI 的地址栏
- “返回”和“前进”按钮
- 书签选项
- 用于刷新或停止加载当前文档的刷新和停止按钮
- 可前往首页的“主页”按钮
奇怪的是,浏览器的界面并未在任何正式规范中指定,它只是来自多年经验和浏览器相互模仿而形成的良好做法。 HTML5 规范并未定义浏览器必须具备的界面元素,但列出了一些常见元素。其中包括地址栏、状态栏和工具栏。 当然,也有一些功能是特定浏览器独有的,例如 Firefox 的内容下载管理器。
高级基础架构
浏览器的主要组件包括:
- 界面:包括地址栏、后退/前进按钮、书签菜单等。除了显示所请求网页的窗口之外,浏览器显示的所有部分都属于界面。
- 浏览器引擎:在界面和渲染引擎之间传送指令。
- 渲染引擎:负责显示请求的内容。例如,如果请求的内容是 HTML,渲染引擎会解析 HTML 和 CSS,并在屏幕上显示解析后的内容。
- 网络:对于 HTTP 请求等网络调用,在平台无关接口后为不同平台使用不同的实现。
- 界面后端:用于绘制组合框和窗口等基本 widget。此后端公开了一个不特定于平台的通用接口。它在底层使用操作系统界面方法。
- JavaScript 解释器。用于解析和执行 JavaScript 代码。
- 数据存储。这是一个持久层。浏览器可能需要在本地保存各种数据,例如 Cookie。浏览器还支持 localStorage、IndexedDB、WebSQL 和 FileSystem 等存储机制。
请务必注意,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 的块为单位完成。
之后,渲染引擎的基本流程如下:
渲染引擎将开始解析 HTML 文档,并将元素转换为名为“内容树”的树中的 DOM 节点。引擎会解析外部 CSS 文件和样式元素中的样式数据。样式信息以及 HTML 中的视觉指令将用于创建另一个树:渲染树。
渲染树包含具有颜色和尺寸等视觉属性的矩形。 这些矩形会按正确的顺序显示在屏幕上。
渲染树构建完成后,会经历“布局”过程。 这意味着为每个节点提供其在屏幕上应显示的确切坐标。 下一个阶段是绘制 - 系统将遍历渲染树,并使用界面后端层绘制每个节点。
请务必了解,这是一个循序渐进的过程。为了提供更好的用户体验,渲染引擎会尝试尽快在屏幕上显示内容。 它不会等到所有 HTML 都解析完毕后才开始构建和布局渲染树。 系统会解析并显示部分内容,同时继续处理来自网络的其余内容。
主流程示例
从图 3 和图 4 可以看出,虽然 WebKit 和 Gecko 使用的术语略有不同,但流程基本相同。
Gecko 将视觉格式化元素组成的树称为“框架树”。每个元素都是一个帧。 WebKit 使用“渲染树”这一术语,它由“渲染对象”组成。 WebKit 使用“布局”一词来表示元素的放置,而 Gecko 则称之为“重排”。 “Attachment”是 WebKit 的术语,指连接 DOM 节点和视觉信息以创建渲染树。 一个细微的非语义差异是,Gecko 在 HTML 和 DOM 树之间有一个额外的层。它被称为“内容接收器”,是用于创建 DOM 元素的工厂。 我们将讨论该流程的每个部分:
解析 - 常规
由于解析在渲染引擎中是一个非常重要的过程,因此我们将更深入地探讨该过程。 我们先来简单介绍一下解析。
解析文档意味着将其转换为代码可以使用的结构。解析结果通常是一个节点树,用于表示文档的结构。这称为解析树或语法树。
例如,解析表达式 2 + 3 - 1 可能会返回以下树:
语法
解析基于文档遵循的语法规则:文档编写所用的语言或格式。 您可以解析的每种格式都必须具有由词汇和语法规则组成的确定性语法。这称为上下文无关文法。人类语言不属于此类语言,因此无法使用传统的解析技术进行解析。
解析器 - 词法分析器组合
解析可以分为两个子过程:词法分析和语法分析。
词法分析是将输入内容拆分为词元的过程。 词元是语言词汇,即有效构建块的集合。在人类语言中,它将包含该语言词典中出现的所有单词。
句法分析是指应用语言句法规则。
解析器通常将工作分为两个组件:词法分析器(有时称为词元化器),负责将输入拆分为有效标记;解析器,负责根据语言语法规则分析文档结构来构建解析树。
词法分析器知道如何去除无关字符,例如空格和换行符。
解析过程是迭代的。解析器通常会向词法分析器请求一个新词元,并尝试将该词元与其中一个语法规则进行匹配。如果匹配到规则,系统会将与该词元对应的节点添加到解析树中,并且解析器会请求另一个词元。
如果没有匹配的规则,解析器会将该词元存储在内部,并继续请求词元,直到找到与所有内部存储的词元匹配的规则。如果未找到任何规则,解析器将引发异常。这意味着文档无效且包含语法错误。
翻译
在许多情况下,解析树并不是最终产品。解析通常用于翻译:将输入文档转换为另一种格式。例如,编译。编译器将源代码编译为机器代码时,会先将其解析为解析树,然后将该树转换为机器代码文档。
解析示例
在图 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 识别为表达式(识别表达式的过程会不断演变,匹配其他规则,但起点是最高级别的规则)。
自下而上解析器会扫描输入,直到找到匹配的规则。然后,它会用规则替换匹配的输入。此过程会一直持续到输入结束。 部分匹配的表达式会放置在解析器的堆栈中。
这种自下而上的解析器称为移位归约解析器,因为输入会向右移位(想象一下,指针首先指向输入开头,然后向右移动),并逐渐归约为语法规则。
自动生成解析器
有些工具可以生成解析器。您向它们提供语言的语法(词汇和语法规则),它们就会生成一个可用的解析器。 创建解析器需要对解析有深入的了解,并且手动创建经过优化的解析器并不容易,因此解析器生成器非常有用。
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 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 树:
与 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 无法使用常规的自上而下或自下而上解析器进行解析。
原因如下:
- 该语言的宽容性。
- 浏览器具有传统的容错能力,可支持众所周知的无效 HTML 情况。
- 解析过程是可重入的。对于其他语言,源代码在解析过程中不会发生变化,但在 HTML 中,动态代码(例如包含
document.write()调用的脚本元素)可以添加额外的标记,因此解析过程实际上会修改输入。
由于无法使用常规解析技术,浏览器会创建自定义解析器来解析 HTML。
HTML5 规范详细介绍了该解析算法。 该算法包含两个阶段:标记化和树构建。
词元化是词法分析,会将输入解析为词元。 HTML 令牌包括开始标记、结束标记、属性名称和属性值。
词元化器识别出该标记,将其交给树构造器,然后消耗下一个字符来识别下一个标记,依此类推,直到输入结束。
词元化算法
该算法的输出是一个 HTML 令牌。 该算法以状态机的形式表示。 每个状态都会消耗输入流中的一个或多个字符,并根据这些字符更新下一个状态。 该决策受当前词法分析状态和树构建状态的影响。这意味着,对于正确的下一个状态,相同的已使用字符会产生不同的结果,具体取决于当前状态。 该算法过于复杂,无法完整描述,因此我们来看一个简单的示例,以便理解其原理。
基本示例 - 对以下 HTML 进行标记化:
<html>
<body>
Hello world
</body>
</html>
初始状态为“数据状态”。
遇到 < 字符时,状态会更改为“标记打开状态”。使用 a-z 字符会导致创建“开始标记令牌”,状态会更改为“标记名称状态”。
我们会一直处于此状态,直到 > 字符被使用。每个字符都会附加到新令牌名称中。在本例中,创建的令牌是 html 令牌。
当到达 > 标记时,系统会发出当前令牌,并且状态会变回“数据状态”。
<body> 标记将通过相同的步骤进行处理。
到目前为止,系统已发出 html 和 body 标记。现在,我们回到了“数据状态”。
使用 Hello world 的 H 字符会导致创建和发出字符 token,这一过程会一直持续到达到 </body> 的 <。我们将为 Hello world 的每个字符发出一个字符令牌。
现在,我们回到了“代码打开状态”。
使用下一个输入 / 将导致创建 end tag token 并移至“标记名称状态”。同样,我们会一直保持此状态,直到达到 >。然后,系统会发出新的标记令牌,并返回到“数据状态”。
</html> 输入将按照上一种情况进行处理。
树构建算法
创建解析器时,系统会创建 Document 对象。在树构建阶段,以 Document 为根的 DOM 树将被修改,并且元素将被添加到其中。 分词器发出的每个节点都将由树构造器处理。 对于每个令牌,规范都会定义哪个 DOM 元素与其相关,并将为此令牌创建该元素。 该元素会被添加到 DOM 树中,也会被添加到打开的元素堆栈中。 此堆栈用于更正嵌套不匹配和未闭合的标记。 该算法也被描述为状态机。这些状态称为“插入模式”。
我们来看看示例输入的树构建过程:
<html>
<body>
Hello world
</body>
</html>
树构建阶段的输入是来自词法分析阶段的词元序列。 第一种模式是“初始模式”。收到“html”标记后,解析器会切换到“html 之前”模式,并在此模式下重新处理该标记。 这将导致创建 HTMLHtmlElement 元素,该元素将附加到根 Document 对象。
状态将更改为“before head”。然后,系统会收到“body”令牌。虽然我们没有“head”令牌,但系统会隐式创建 HTMLHeadElement 并将其添加到树中。
现在,我们转到“in head”模式,然后转到“after head”。系统会重新处理 body 标记,创建并插入 HTMLBodyElement,并将模式切换为“in body”。
现在,系统已收到“Hello world”字符串的字符 token。第一个字符会导致创建并插入“文本”节点,其他字符将附加到该节点。
收到 body 结束标记后,将转移到“body 之后”模式。 现在,我们将收到 html 结束标记,这会将我们移至“after after body”模式。 收到文件结束标记后,解析将结束。
解析完成后的操作
在此阶段,浏览器会将文档标记为可交互,并开始解析处于“延迟”模式的脚本:即应在文档解析后执行的脚本。 然后,文档状态将设置为“完成”,并触发“加载”事件。
您可以在 HTML5 规范中查看完整的词法分析和树构建算法。
浏览器的容错能力
您永远不会在 HTML 页面上收到“语法无效”错误。 浏览器会修正所有无效内容并继续。
以下面的 HTML 为例:
<html>
<mytag>
</mytag>
<div>
<p>
</div>
Really lousy HTML
</p>
</html>
我肯定违反了大约一百万条规则(“mytag”不是标准标记,“p”和“div”元素嵌套错误等),但浏览器仍然正确显示它,并且没有报错。 因此,解析器代码中有很大一部分是在修正 HTML 作者的错误。
错误处理在浏览器中非常一致,但令人惊讶的是,它并未纳入 HTML 规范。 就像书签和后退/前进按钮一样,它只是浏览器多年来发展出的一个功能。许多网站上都重复出现已知的无效 HTML 结构,浏览器会尝试以与其他浏览器一致的方式修复这些结构。
HTML5 规范确实定义了其中一些要求。(WebKit 在 HTML 解析器类开头的注释中对此进行了很好的总结。)
解析器会将经过词法分析的输入解析到文档中,从而构建文档树。如果文档格式正确,则解析起来很简单。
遗憾的是,我们必须处理许多格式不正确的 HTML 文档,因此解析器必须能够容忍错误。
我们至少需要处理以下错误情况:
- 正在添加的元素在某些外部标记中被明确禁止。在这种情况下,我们应该关闭所有标签,直到禁止该元素的标签,然后再添加该元素。
- 我们无法直接添加该元素。可能是文档作者忘记了中间的某个标记(或者中间的标记是可选的)。以下标记可能属于这种情况:HTML HEAD BODY TBODY TR TD LI(我是否遗漏了任何标记?)。
- 我们希望在内嵌元素中添加块级元素。关闭所有内嵌元素,直至下一个更高级别的块元素。
- 如果这不起作用,请关闭元素,直到我们被允许添加元素,或者忽略该标记。
我们来看一些 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”是标识符(如类名称)的缩写。 “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.error 和 a.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 语法对应的其他对象。
脚本和样式表的处理顺序
脚本
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 代码,用于根据 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 节点,但不在树中的同一位置。 浮动元素和绝对定位元素不属于正常文档流,它们会被放置在树的不同部分,并映射到实际框架。 占位帧是它们本应出现的位置。
树的构建流程
在 Firefox 中,演示文稿会注册为 DOM 更新的监听器。呈现会将框架创建委托给 FrameConstructor,而构造函数会解析样式(请参阅样式计算)并创建框架。
在 WebKit 中,解析样式和创建渲染器的过程称为“附加”。 每个 DOM 节点都有一个“attach”方法。 附加是同步的,向 DOM 树插入节点会调用新节点的“attach”方法。
处理 html 和 body 标记会构建渲染树根。
根渲染对象对应于 CSS 规范中称为“包含块”的对象:包含所有其他块的最顶层块。其尺寸即为视口尺寸:浏览器窗口显示区域的尺寸。
Firefox 将其称为 ViewPortFrame,WebKit 将其称为 RenderView。
这是文档指向的渲染对象。
树的其余部分则通过插入 DOM 节点来构建。
请参阅有关处理模型的 CSS2 规范。
样式计算
构建渲染树需要计算每个渲染对象的视觉属性。 为此,需要计算每个元素的样式属性。
样式包括各种来源的样式表、内嵌样式元素和 HTML 中的视觉属性(例如“bgcolor”属性)。后者会转换为匹配的 CSS 样式属性。
样式表的来源包括浏览器的默认样式表、网页作者提供的样式表以及用户样式表(即浏览器用户提供的样式表,浏览器允许您定义自己喜欢的样式)。例如,在 Firefox 中,可以通过将样式表放在“Firefox 配置文件”文件夹中来实现。
样式计算会带来一些困难:
- 样式数据是一个非常大的结构,其中包含众多样式属性,这可能会导致内存问题。
如果未进行优化,为每个元素查找匹配规则可能会导致性能问题。为每个元素遍历整个规则列表以查找匹配项是一项繁重的任务。选择器可能具有复杂的结构,这可能会导致匹配过程从一条看似有希望的路径开始,但事实证明这条路径是徒劳的,必须尝试另一条路径。
例如,以下复合选择器:
div div div div{ ... }表示这些规则适用于作为 3 个 div 的后代的
<div>。假设您想检查该规则是否适用于给定的<div>元素。您选择树中的特定路径进行检查。您可能需要向上遍历节点树,才能发现只有两个 div,并且该规则不适用。然后,您需要尝试树中的其他路径。应用规则涉及相当复杂的级联规则,这些规则定义了规则的层次结构。
我们来看看浏览器如何应对这些问题:
共享样式数据
WebKit 节点引用样式对象 (RenderStyle)。 在某些情况下,这些对象可由节点共享。 这些节点是同级节点或同级节点的子节点,并且:
- 元素必须处于相同的鼠标状态(例如,不能一个处于 :hover 状态,而另一个不处于该状态)
- 两个元素都不应具有 ID
- 标记名称应匹配
- 类属性应匹配
- 映射的特性集必须相同
- 链接状态必须一致
- 焦点状态必须匹配
- 这两个元素都不应受属性选择器的影响,其中“受影响”是指在选择器中的任何位置使用属性选择器的任何选择器匹配
- 元素上不得有内嵌样式属性
- 不得使用任何同级选择器。WebCore 只是在遇到任何同级选择器时抛出一个全局开关,并在这些选择器存在时停用整个文档的样式共享。这包括 + 选择器以及 :first-child 和 :last-child 等选择器。
Firefox 规则树
Firefox 还有两个额外的树,以便于样式计算:规则树和样式上下文树。 WebKit 也有样式对象,但它们不存储在样式上下文树这样的树中,只有 DOM 节点指向其相关样式。
样式上下文包含结束值。这些值是通过按正确顺序应用所有匹配规则并执行操作(将这些值从逻辑值转换为具体值)来计算的。例如,如果逻辑值是屏幕的百分比,则会计算并转换为绝对单位。 规则树的想法非常巧妙。它支持在节点之间共享这些值,以避免再次计算。这也可以节省空间。
所有匹配的规则都存储在树中。路径中的底部节点具有更高的优先级。 该树包含找到的所有规则匹配路径。 规则的存储是延迟执行的。该树并非在开始时为每个节点计算,而是每当需要计算节点样式时,计算出的路径就会添加到该树中。
其思路是将树路径视为词典中的字词。 假设我们已经计算出以下规则树:
假设我们需要匹配内容树中另一个元素的规则,并找出匹配的规则(按正确顺序排列)为 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}
为简单起见,假设我们只需要填充两个结构体:颜色结构体和外边距结构体。 颜色结构体仅包含一个成员:颜色 边距结构体包含四个边。
生成的规则树将如下所示(节点标有节点名称:它们指向的规则编号):
上下文树将如下所示(节点名称:它们指向的规则节点):
假设我们解析 HTML 并到达第二个 <div> 标记。我们需要为此节点创建一个样式上下文,并填充其样式结构体。
我们将匹配规则,并发现 <div> 的匹配规则为 1、2 和 6。
这意味着树中已经存在一个可供我们的元素使用的路径,我们只需为规则 6 添加另一个节点(规则树中的节点 F)即可。
我们将创建一个样式上下文,并将其放入上下文树中。新样式上下文将指向规则树中的节点 F。
现在,我们需要填充样式结构体。我们首先要填充外边距结构体。 由于最后一个规则节点 (F) 不会添加到外边距结构体中,因此我们可以向上遍历树,直到找到在之前的节点插入中计算出的缓存结构体并使用它。 我们会在节点 B 上找到它,节点 B 是指定了外边距规则的最上层节点。
我们确实有颜色结构体的定义,因此无法使用缓存的结构体。 由于颜色只有一个属性,因此我们无需向上遍历树来填充其他属性。 我们将计算最终值(将字符串转换为 RGB 等),并将计算出的结构体缓存在此节点上。
第二个 <span> 元素的工作甚至更简单。我们将匹配规则,并得出结论:它指向规则 G,就像上一个 span 一样。
由于我们有指向同一节点的同级节点,因此可以共享整个样式上下文,只需指向前一个 span 的上下文即可。
对于包含从父级继承的规则的结构体,缓存是在上下文树上完成的(颜色属性实际上是继承的,但 Firefox 将其视为重置并在规则树上缓存)。
例如,如果我们为段落中的字体添加了规则:
p {font-family: Verdana; font size: 10px; font-weight: bold}
然后,段落元素(在上下文树中是 div 的子级)可以与其父级共享相同的字体结构体。这是在未为段落指定字体规则的情况下。
在没有规则树的 WebKit 中,匹配的声明会被遍历四次。首先应用不重要的、高优先级的属性(应首先应用的属性,因为其他属性依赖于它们,例如 display),然后应用高优先级的重要属性,接着应用普通优先级的不重要属性,最后应用普通优先级的重要规则。 这意味着,多次出现的属性将按照正确的级联顺序进行解析。最后一次获胜。
总结一下:共享样式对象(全部或其中的部分结构体)可解决问题 1 和 3。Firefox 规则树还有助于按正确的顺序应用属性。
操纵规则,轻松匹配
样式规则有多个来源:
- CSS 规则,无论是在外部样式表中还是在样式元素中。
css p {color: blue} - 内嵌样式属性,例如
html <p style="color: blue" /> - 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 元素不匹配,因为该元素没有 table 祖先。
WebKit 和 Firefox 都会执行此操作。
样式表级联顺序
样式对象具有与每个视觉属性(所有 CSS 属性,但更通用)相对应的属性。 如果任何匹配的规则都未定义该属性,则某些属性可以由父元素样式对象继承。其他属性具有默认值。
当存在多个定义时,问题就会出现,此时级联顺序可以解决此问题。
样式属性的声明可以出现在多个样式表中,也可以在样式表中出现多次。 这意味着,规则的应用顺序非常重要。这称为“级联”顺序。 根据 CSS2 规范,级联顺序(从低到高)如下:
- 浏览器声明
- 用户正常声明
- 编写常规声明
- 作者重要声明
- 用户重要声明
浏览器声明最不重要,只有在声明被标记为重要时,用户才会覆盖作者。 具有相同顺序的声明将按特异性排序,然后按其指定顺序排序。 HTML 视觉属性会转换为匹配的 CSS 声明。它们会被视为低优先级的作者规则。
明确性
选择器特异性由 CSS2 规范定义,如下所示:
- 如果声明来自“style”属性,而不是带有选择器的规则,则计数为 1,否则为 0 (= a)
- 统计选择器中 ID 属性的数量 (= b)
- 统计选择器中其他属性和伪类的数量 (= c)
- 统计选择器中元素名称和伪元素的数量 (= 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,其尺寸为视口(浏览器窗口的可见部分)。
所有渲染器都有一个“布局”或“重排”方法,每个渲染器都会调用其需要布局的子级的布局方法。
脏位系统
为了避免针对每个微小更改都执行完整布局,浏览器会使用“脏位”系统。 经过更改或添加的渲染器会将自身及其子级标记为“脏”:需要布局。
有两个标志:“脏”和“子级脏”,这意味着虽然渲染器本身可能没问题,但它至少有一个子级需要布局。
全局和增量布局
布局可在整个渲染树上触发,这就是“全局”布局。 这可能是由以下原因导致的:
- 影响所有渲染器的全局样式更改,例如字体大小更改。
- 屏幕尺寸调整
布局可以是增量式的,只有脏渲染器才会被布局(这可能会造成一些损坏,需要额外的布局)。
当渲染器处于脏状态时,会(异步)触发增量布局。例如,当额外内容来自网络并添加到 DOM 树后,新的渲染器会附加到渲染树。
异步和同步布局
增量布局是异步完成的。Firefox 会将“重排命令”排入队列,以进行增量布局,并由调度程序触发这些命令的批量执行。 WebKit 还有一个计时器,用于执行增量布局 - 遍历树并布局“脏”渲染器。
请求样式信息(例如“offsetHeight”)的脚本可能会同步触发增量布局。
全局布局通常会同步触发。
有时,由于某些属性(例如滚动位置)发生变化,系统会在初始布局后触发布局作为回调。
优化
当布局由“调整大小”或渲染器位置(而非大小)变化触发时,渲染器大小会从缓存中获取,而不会重新计算…
在某些情况下,只有子树会被修改,布局不会从根开始。如果更改是局部的,并且不会影响其周围环境,就可能会发生这种情况,例如将文本插入文本字段(否则每次按键都会触发从根开始的布局)。
布局流程
布局通常采用以下模式:
- 父级渲染器自行确定其宽度。
- 家长会检查孩子的情况,并:
- 放置子渲染器(设置其 x 和 y)。
- 根据需要(例如,子布局已过时、处于全局布局中或出于其他原因)调用子布局,以计算子布局的高度。
- 父级使用子级的累积高度以及外边距和内边距的高度来设置自己的高度,父级渲染器的父级将使用此高度。
- 将其脏位设置为 false。
Firefox 使用“状态”对象 (nsHTMLReflowState) 作为布局(称为“重排”)的参数。状态中包含父级宽度等。
Firefox 布局的输出是一个“指标”对象(nsHTMLReflowMetrics)。它将包含渲染程序计算的高度。
宽度计算
渲染器的宽度是使用容器块的宽度、渲染器的样式“width”属性、外边距和边框计算得出的。
例如,以下 div 的宽度:
<div style="width: 30%"/>
将由 WebKit 计算如下(类 RenderBox 方法 calcWidth):
- 容器宽度是容器 availableWidth 和 0 的最大值。 在本例中,availableWidth 是 contentWidth,其计算方式如下:
clientWidth() - paddingLeft() - paddingRight()
clientWidth 和 clientHeight 表示对象的内部,不包括边框和滚动条。
元素宽度是“width”样式属性。 它将通过计算容器宽度的百分比来计算绝对值。
现在,水平边框和内边距已添加。
到目前为止,这是“首选宽度”的计算。 现在,系统会计算最小和最大宽度。
如果首选宽度大于最大宽度,则使用最大宽度。 如果该值小于最小宽度(最小不可拆分单元),则使用最小宽度。
这些值会被缓存,以防需要布局,但宽度不会改变。
换行
当布局中间的渲染器决定需要换行时,该渲染器会停止,并向布局的父级传播需要换行的信息。 父级会创建额外的渲染器,并对其调用布局。
绘画
在绘制阶段,系统会遍历渲染树,并调用渲染器的“paint()”方法,将内容显示在屏幕上。 绘制使用界面基础架构组件。
全局和增量
与布局一样,绘制也可以是全局的(绘制整个树)或增量的。 在增量绘制中,某些渲染器的更改方式不会影响整个树。 更改后的渲染器会使其在屏幕上的矩形失效。 这会导致操作系统将其视为“脏区域”并生成“绘制”事件。 操作系统会巧妙地将多个区域合并为一个。 在 Chrome 中,情况要复杂得多,因为渲染器与主进程位于不同的进程中。Chrome 会在一定程度上模拟操作系统行为。 演示文稿会监听这些事件,并将消息委托给渲染根。系统会遍历该树,直到找到相关的渲染程序。它会自行重绘(通常也会重绘其子级)。
绘制顺序
CSS2 定义了绘制过程的顺序。 这实际上是元素在堆叠上下文中的堆叠顺序。此顺序会影响绘制,因为堆栈是从后向前绘制的。 块渲染器的堆叠顺序为:
- 背景颜色
- 背景图片
- 边框
- 孩子
- 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 盒模型描述了为文档树中的元素生成并根据视觉格式模型布局的矩形框。
每个框都有一个内容区域(例如文本、图片等)以及可选的周围内边距、边框和外边距区域。
每个节点会生成 0 到 n 个这样的框。
所有元素都有一个“display”属性,用于确定将生成的框的类型。
示例:
block: generates a block box.
inline: generates one or more inline boxes.
none: no box is generated.
默认值为 inline,但浏览器样式表可能会设置其他默认值。 例如,“div”元素的默认显示方式是块。
您可以在以下网址找到默认样式表示例:www.w3.org/TR/CSS2/sample.html。
定位方案
有三种方案:
- 正常:对象根据其在文档中的位置进行定位。这意味着它在渲染树中的位置与其在 DOM 树中的位置类似,并根据其框类型和尺寸进行布局
- 浮动:对象首先像正常流一样布局,然后尽可能向左或向右移动
- 绝对:对象在渲染树中的位置与在 DOM 树中的位置不同
定位方案由“position”属性和“float”属性设置。
- static 和 relative 会导致正常流
- absolute 和 fixed 会导致绝对定位
在静态定位中,未定义任何位置,而是使用默认定位。 在其他方案中,作者指定位置:顶部、底部、左侧、右侧。
盒子的布局方式由以下因素决定:
- Box 类型
- 盒装尺寸
- 定位方案
- 外部信息,例如图片大小和屏幕大小
Box 类型
块级框:形成一个块,在浏览器窗口中拥有自己的矩形区域。
内嵌框:没有自己的块,但位于包含块内。
块会一个接一个地垂直排列。 内嵌广告采用横向格式。
内嵌框会放入行或“行框”中。 这些行的高度至少与最高的框一样高,但也可以更高,当框对齐“基线”时,这意味着元素的底部与另一个框的底部以外的点对齐。 如果容器宽度不够,内嵌内容将分布在多行中。 这通常是段落中发生的情况。
定位
相对
相对定位 - 像往常一样定位,然后移动所需的增量。
浮点数
浮动框会移到一行的左侧或右侧。有趣的是,其他方框会围绕它流动。 HTML:
<p>
<img style="float: right" src="images/image.gif" width="100" height="100">
Lorem ipsum dolor sit amet, consectetuer...
</p>
如下所示:
绝对和固定
无论正常流程如何,布局都是精确定义的。该元素不参与正常流程。 尺寸是相对于容器而言的。 在固定定位中,容器是视口。
分层表示
这由 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>
结果如下:
虽然在标记中,红色 div 位于绿色 div 之前,并且在常规流程中会先绘制,但 z-index 属性更高,因此在根框所持有的堆栈中更靠前。
资源
浏览器架构
- Grosskurth, Alan. 网络浏览器参考架构 (pdf)
- Gupta, Vineet. 浏览器的工作原理 - 第 1 部分 - 架构
解析
- Aho、Sethi、Ullman,《Compilers: Principles, Techniques, and Tools》(又名“龙书”),Addison-Wesley,1986 年
- Rick Jelliffe。《大胆而美丽》:HTML 5 的两个新草案。
Firefox
- L. David Baron,《Faster HTML and CSS: Layout Engine Internals for Web Developers》(更快的 HTML 和 CSS:面向 Web 开发者的布局引擎内部机制)。
- L. David Baron,Faster HTML and CSS: Layout Engine Internals for Web 开发者(Google 技术讲座视频)
- L. David Baron,Mozilla 的布局引擎
- L. David Baron,Mozilla 样式系统文档
- Chris Waterson,HTML 重排说明
- Chris Waterson,Gecko 概览
- Alexander Larsson,HTML HTTP 请求的生命周期
WebKit
- David Hyatt,Implementing CSS(第 1 部分)
- David Hyatt,WebCore 概览
- David Hyatt,WebCore 渲染
- David Hyatt,FOUC 问题
W3C 规范
浏览器构建说明
翻译
此页面已翻译成日语,翻译了两次:
- How Browsers Work - 现代网络浏览器幕后故事(日语) 作者:@kosei
- ブラウザってどうやって動いてるの?(モダンWEBブラウザシーンの裏側 by @ikeike443 and @kiyoto01.
谢谢大家!