How browsers work
Behind the scenes of modern web browsers
!> 原文
序言
这是一部关于 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(万维网联盟)组织维护,该组织是网络标准组织。多年来,浏览器都只遵守了这些规范中的部分要求,并且一直在开发自己的扩展程序。这给网站作者带来了严重的兼容性问题。目前,大多数浏览器或多或少都符合规范。
浏览器界面有很多共同之处。常见的界面元素包括:
- 用于插入 URI 的地址栏
- “返回”和“前进”按钮
- 书签选项
- 用于刷新或停止加载当前文档的“刷新”和“停止”按钮
- 用于前往首页的主屏幕按钮
奇怪的是,浏览器的界面并没有任何正式的规范,这只是源自多年来积累的良好实践以及浏览器彼此模仿的结果。 HTML5 规范未定义浏览器必须具备的界面元素,但列出了一些常见元素。其中包括地址栏、状态栏和工具栏。当然,有些功能是特定浏览器独有的,例如 Firefox 的下载管理器。
概要 基础架构
浏览器的主要组件包括:
- 界面:包括地址栏、后退/前进按钮、书签菜单等。浏览器界面的每个部分,但显示请求网页的窗口除外。
- 浏览器引擎:在界面和渲染引擎之间协调操作。
- 呈现引擎:负责显示请求的内容。例如,如果请求的内容是 HTML,呈现引擎会解析 HTML 和 CSS,并在屏幕上显示解析后的内容。
- 网络:对于 HTTP 请求等网络调用,在平台无关接口后面为不同平台使用不同的实现。
- 界面后端:用于绘制基本微件,例如组合框和窗口。此后端公开了与平台无关的通用接口。底层使用操作系统界面方法。
- JavaScript 解释器。用于解析和执行 JavaScript 代码。
- 数据存储。这是一个持久层。浏览器可能需要在本地保存各种数据,例如 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 都解析完毕后才开始构建和布局渲染树。系统会解析并显示部分内容,同时继续处理不断从网络传入的其余内容。
主要流程示例
图 3:WebKit 主流程
图 4:Mozilla 的 Gecko 渲染引擎主流程
从图 3 和图 4 可以看出,虽然 WebKit 和 Gecko 使用的术语略有不同,但流程基本相同。
Gecko 将采用视觉格式的元素的树称为“帧树”。每个元素都是一个帧。WebKit 使用“渲染树”这一术语,它由“渲染对象”组成。WebKit 使用“布局”一词来表示元素的放置,而 Gecko 称之为“重新流式传输”。“附件”是 WebKit 用来连接 DOM 节点和视觉信息以创建渲染树的术语。一个次要的非语义差异是,Gecko 在 HTML 和 DOM 树之间增加了一层。它称为“内容接收器”,是用于制作 DOM 元素的工厂。我们将 介绍该流程的各个部分:
解析 - 常规
解析是呈现引擎中非常重要的一个环节,因此我们将对此进行更深入的探讨。 我们先来简要介绍一下解析。
解析文档意味着将其转换为代码可以使用的结构。解析结果通常是表示文档结构的节点树。这称为解析树或语法树。
例如,解析表达式 2 + 3 - 1
可能会返回以下树:
图 5:数学表达式树节点
语法
解析基于文档遵循的语法规则:文档所用的语言或格式。您可以解析的每种格式都必须具有由词汇和语法规则组成的确定性语法。这种语言称为无上下文语法。人类语言不是这种语言,因此无法使用传统的解析技术进行解析。
解析器 - 词法分析器组合
解析可分为两个子过程:词法分析和语法分析。
词法分析是将输入内容分解成多个词元的过程。 令牌是语言词汇:一系列有效的构建块。在人类语言中 ,它包含该语言的字典中出现的所有单词。
语法分析是指应用语言语法规则。
解析器通常会将工作分为两部分:负责将输入拆分为有效令牌的词法分析器(有时称为“分词器”);以及负责根据语言语法规则分析文档结构以构建解析树的解析器。
词法分析器知道如何移除空格和换行符等无关紧要的字符。
图 6:从源文档到解析树
解析过程是迭代的。解析器通常会向词法分析器请求新的令牌,并尝试将该令牌与某个语法规则进行匹配。如果匹配到规则,系统会将与令牌对应的节点添加到解析树中,解析器会请求另一个令牌。
如果没有匹配的规则,解析器将在内部存储令牌,并不断请求令牌,直到找到与内部存储的所有令牌匹配的规则。如果未找到任何规则,解析器将引发异常。这意味着该文档无效且包含语法错误。
翻译
在许多情况下,解析树还不是最终产品。解析通常用于翻译:将输入文档转换为其他格式。编译就是这样一个例子。将源代码编译为机器代码的编译器会先将其解析为解析树,然后将该树转换为机器代码文档。
图 7:编译流程
解析示例
在图 5 中,我们通过一个数学表达式构建了一个解析树。我们来尝试定义一种简单的数学语言,并了解解析过程。
语法:
- 语言语法构成要素包括表达式、项和运算。
- 我们的语言可以包含任意数量的表达式。
- 表达式定义为“项”后跟“运算”后跟另一个“项”
- 操作是加号令牌或减号令牌
- 字词是整数标记或表达式
我们来分析一下输入 2 + 3 - 1
。
与规则匹配的第一个子字符串是 2
:根据规则 #5,它是一个字词。第二个匹配项是 2 + 3
:这与第三条规则匹配:一个项接一个运算符,然后再接一个项。 只有在输入结束时,才会找到下一个匹配项。2 + 3 - 1
是一个表达式,因为我们已经知道 2 + 3
是一个项,因此我们有一个项,后跟一个运算,后跟另一个项。2 + +
与任何规则都不匹配,因此是无效的输入。
词汇和语法的正式定义
词汇通常由正则表达式表示。
例如,我们的语言将定义为:
<span>INTEGER</span><span>:</span><span> </span><span>0</span><span>|</span><span>[</span><span>1</span><span>-</span><span>9</span><span>][</span><span>0</span><span>-</span><span>9</span><span>]</span><span>*</span>
<span>PLUS</span><span>:</span><span> </span><span>+</span>
<span>MINUS</span><span>:</span><span> </span><span>-</span>
如您所见,整数由正则表达式定义。
语法通常采用名为 BNF 的格式进行定义。我们的语言定义为:
<span>expression</span><span> </span><span>:=</span><span> </span><span>term</span><span> </span><span>operation</span><span> </span><span>term</span>
<span>operation</span><span> </span><span>:=</span><span> </span><span>PLUS</span><span> </span><span>|</span><span> </span><span>MINUS</span>
<span>term</span><span> </span><span>:=</span><span> </span><span>INTEGER</span><span> </span><span>|</span><span> </span><span>expression</span>
我们曾说过,如果某种语言的语法是无上下文语法,则可以由正则解析器解析。对无上下文语法的直观定义是:完全可以用 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。
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 树:
图 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。
原因如下:
- 语言的宽容性。
- 浏览器具有传统的容错能力,可以支持众所周知的 HTML 无效情况。
- 解析过程是可重入的。对于其他语言,来源在解析过程中不会发生变化,但在 HTML 中,动态代码(如包含
document.write()
调用的脚本元素)可能会添加额外的标记,因此解析过程实际上会修改输入内容。
由于无法使用常规的解析技术,浏览器就创建了自定义的解析器来解析 HTML。
HTML5 规范详细介绍了解析算法。该算法包含两个阶段:令牌化和树构建。
词元化是词法分析,用于将输入解析为词元。HTML 令牌包括起始标记、结束标记、属性名称和属性值。
标记生成器识别该标记,将其提供给树构造函数,然后使用下一个字符来识别下一个标记,依此类推,直到输入结束。
图 9:HTML 解析流程(摘自 HTML5 规范)
令牌化算法
该算法的输出是 HTML 标记。 该算法以状态机的形式表示。每个状态都会使用输入流中的一个或多个字符,并根据这些字符更新下一个状态。此决定会受到当前的令牌化状态和树构建状态的影响。这意味着,对于正确的下一个状态,相同的已消耗字符会产生不同的结果,具体取决于当前状态。 该算法过于复杂,无法完整描述,因此我们来看一个简单的示例,以便了解其原理。
基本示例 - 将以下 HTML 标记化:
<html>
<body>
Hello world
</body>
</html>
初始状态为“数据状态”。 当遇到 <
字符时,状态会更改为**“标记打开状态”。 接收一个 a-z
字符会创建“起始标记令牌”,状态会更改为“标记名称状态”**。 我们会一直保持此状态,直到 >
字符被消耗完。每个字符都会附加到新词元名称中。在本例中,创建的令牌是 html
令牌。
达到 >
标记后,系统会发出当前令牌,并且状态会恢复为**“数据状态”。系统会按照相同的步骤处理 <body>
标记。到目前为止,html
和 body
标记已发出。现在,我们回到“数据状态”**。 使用 Hello world
的 H
字符会导致创建并发送字符令牌,这种情况会持续到达到 </body>
的 <
为止。我们将为 Hello world
的每个字符发出一个字符令牌。
现在,我们回到**“代码处于打开状态”。 使用下一个输入 /
会导致创建 end tag token
并移至“标记名称状态”。再次强调一下,我们会一直保持此状态,直到达到 >
。然后,系统会发出新的代码令牌,我们会返回到“数据状态”**。 系统会将 </html>
输入视为前面的示 例。
图 10:对示例输入进行标记化
树构建算法
创建解析器时,系统会创建 Document 对象。在树构建阶段,系统会修改根目录中包含文档的 DOM 树,并向其中添加元素。分词器发出的每个节点都将由树构造函数处理。规范中会为每个令牌定义哪个 DOM 元素与其相关,并且将为该令牌创建该元素。元素会添加到 DOM 树以及打开的元素堆栈中。 此堆栈用于更正嵌套不匹配和未闭合标记。该算法还可描述为状态机。这些状态称为“插入模式”。
我们来看看示例输入的树构建过程:
<html>
<body>
Hello world
</body>
</html>
树构建阶段的输入是来自标记化阶段的词元序列。第一种模式是**“初始模式”。收到“html”令牌将导致系统切换到“html 之前”**模式,并在该模式下重新处理令牌。这将导致创建 HTMLHtmlElement 元素,该元素将附加到根 Document 对象。
状态将更改为**“在 head 之前”**。然后,系统会收到“body”令牌。系统会隐式创建 HTMLHeadElement,即使我们没有“head”令牌,它也会被添加到树中。
现在,我们将进入**“在头部前面”模式,然后进入“在头部后面”模式。系统会重新处理正文令牌,创建并插入 HTMLBodyElement,并将模式转换为“in body”**。
现在,系统会收到“Hello world”字符串的字符令牌。第一个字符会导致创建并插入“文本”节点,其他字符会附加到该节点。
收到正文结束令牌后,系统会转换为**“正文后”模式。现在,我们将收到 html 结束标记,这会将我们转换到“body 后”**模式。收到文件结束令牌后,解析将结束。
图 11:示例 HTML 的树状结构
解析完成后的操作
在此阶段,浏览器会将文档标记为交互式,并开始解析处于“延迟”模式的脚本:这些脚本应在文档解析完毕后执行。然后,文档状态将设置为“完成”,并会触发“load”事件。
浏览器的错误容错性
您在浏览 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(我是不是漏了什么?)。
- 我们希望在内嵌元素内添加一个块元素。关闭所有内嵌元素,直至下一个更高级别的 block 元素。
- 如果这样没有帮助,请关闭元素,直到我们允许添加元素为止,或者忽略该标记。
我们来看一些 WebKit 错误容错示例:
</br>
代替 <br>
有些网站会使用 </br>
而非 <br>
。为了与 IE 和 Firefox 兼容,WebKit 会将其视为 <br>
。
代码:
<span>if</span><span> </span><span>(</span><span>t</span><span>-</span>><span>isCloseTag</span><span>(</span><span>brTag</span><span>)</span><span> && </span><span>m_document</span><span>-</span>><span>inCompatMode</span><span>())</span><span> </span><span>{</span>
<span> </span><span>reportError</span><span>(</span><span>MalformedBRError</span><span>);</span>
<span> </span><span>t</span><span>-</span>><span>beginTag</span><span> </span><span>=</span><span> </span><span>true</span><span>;</span>
<span>}</span>
请注意,错误处理是在内部进行的:不会向用户显示。
一个孤岛表
离散表格是指位于另一个表格中,但不属于某个表格单元格中的表格。
例如:
<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>
代码:
<span>if</span><span> </span><span>(</span><span>m_inStrayTableContent</span><span> && </span><span>localName</span><span> </span><span>==</span><span> </span><span>tableTag</span><span>)</span>
<span> </span><span>popBlock</span><span>(</span><span>tableTag</span><span>);</span>
WebKit 会为当前元素内容使用一个堆栈:它会将内部表从外部表堆栈中弹出。现在,这些表将是同级表。
嵌套表单元素
如果用户将一个表单放入另一个表单中,系统会忽略第二个表单。
代码:
<span>if</span><span> </span><span>(</span><span>!</span><span>m_currentFormElement</span><span>)</span><span> </span><span>{</span>
<span> </span><span>m_currentFormElement</span><span> </span><span>=</span><span> </span><span>new</span><span> </span><span>HTMLFormElement</span><span>(</span><span>formTag</span><span>,</span><span> </span><span>m_document</span><span>);</span>
<span>}</span>
代码层次结构过深
注释已经说明了一切。
<span>bool</span><span> </span><span>HTMLParser</span><span>::</span><span>allowNestedRedundantTag</span><span>(</span><span>const</span><span> </span><span>AtomicString</span>&<span> </span><span>tagName</span><span>)</span>
<span>{</span>
<span>unsigned</span><span> </span><span>i</span><span> </span><span>=</span><span> </span><span>0</span><span>;</span>
<span>for</span><span> </span><span>(</span><span>HTMLStackElem</span><span>*</span><span> </span><span>curr</span><span> </span><span>=</span><span> </span><span>m_blockStack</span><span>;</span>
<span> </span><span>i</span><span> < </span><span>cMaxRedundantTagDepth</span><span> && </span><span>curr</span><span> && </span><span>curr</span><span>-</span>><span>tagName</span><span> </span><span>==</span><span> </span><span>tagName</span><span>;</span>
<span> </span><span>curr</span><span> </span><span>=</span><span> </span><span>curr</span><span>-</span>><span>next</span><span>,</span><span> </span><span>i</span><span>++</span><span>)</span><span> </span><span>{</span><span> </span><span>}</span>
<span>return</span><span> </span><span>i</span><span> </span><span>!=</span><span> </span><span>cMaxRedundantTagDepth</span><span>;</span>
<span>}</span>