1. 介绍(Introduction)
浏览器可能是使用最广泛的软件。在此我将介绍其屏幕后的工作原理。我们将会看到当我们在地址输入栏中敲入"google.com" 后浏览器将 Google 页面呈现出来的过程中具体发生了什么。
1.1 主流的浏览器(The browsers we talk about)
至今有五种主流的浏览器在使用 :Internet Explorer(IE)/Edge,Firefox,Safari,Chrome 和 Opera。
我将会从开源浏览器 Firefox,Chrome 和部分开源的 Safari 中举一些例子。
根据 W3C 浏览器统计 当前(2009年10月),Firefox,Chrome 和 Safari 总共使用量占比接近总的 60%。
截至翻译日期(2020年2月),光 Chrome 一家就占比80%多。
由此也可见在浏览器业务中开源浏览器是占绝大部分的。
1.2 浏览器的主要功能(The browser’s main functionality)
浏览器的主要功能就是呈现你想要的页面,通过向目标服务器请求页面资源并将响应资源展示在浏览器窗口上。资源格式同来说是 HTML,但也可以是 PDF,image等等。资源的具体地址由 URI(Uniform Resource Identifier) 确定,一般我们常说的 URL 就属于 URI。
浏览器是通过 HTML 和 CSS 规范来解析和展示 HTML 文件的,这些规范由 W3C 组织维护。由于这些年来各个浏览器仅遵循部分规范中的部分内容并开发出各自的一些扩展,给 web 开发者造成了严重的兼容性问题。不过现如今浏览器厂商或多或少的都会遵循这些规范,真心希望能够统一这些规范,不光是为了开发者也是为了用户。
各个浏览器的界面大都是类似的,一般界面元素有:
- 地址栏,用于插入 URI
- 前进后退按钮
- 标签选择器
- 刷新按钮,用于强制刷新或者刷新过程中停止刷新
- 主页按钮,用于返回主页
浏览器的用户界面是没有正式规范指定的,能形成这种样子,都是通过各个浏览器实践磨合出来的。HTML5 规范也没有规定浏览器必须拥有哪些元素,但是列出了常用的元素,比如地址栏、状态栏和工具栏。当然也有一些特定浏览器独有的特性,比如火狐的下载管理器。
1.3 浏览器的结构(The browser’s high level structure)
- 用户界面,包括地址栏,前进/后退按钮,书签菜单栏等。可以说是除了显示网页的窗口外,浏览器的每一部分。
- 浏览器引擎:用于查询和操作渲染引擎的接口
- 渲染引擎:用于展示请求内容。比如请求的内容是 HTML,它负责解析 HTML 和 CSS 并将解析后的内容展示在页面上。
- 网络:用于网络调用,比如 HTTP 请求。它有独立于平台的接口,每个平台都有其对应的底层实现。
- UI Backend:用于绘制基本的小部件,比如组合框和窗口。它展示了与平台无关的通用接口。在底层使用的是操作系统用户界面的方法(user interface methods)。
- Javascript 解释器:用于解释和执行 Javascript 代码
- 数据存储:属于 persistence layer。浏览器需要保存各种数据至硬盘中,比如 cookies,或者 HTML5 规范中的 web database。
图1.
注意: Chrome 浏览器不像大多数浏览器,它拥有多个渲染引擎实例, 每个 tab 页面都有一个。每个 tab 页面都是一个独立的进程。
1.4 组件之间的通信(Communication between the components)
Firefox 和 Chrome 都开发了独特的通信框架。
2 渲染引擎(the rendering engine)
渲染引擎主要用于将请求资源展示在浏览器中。
浏览器默认是可以展示 HTML 和 XML 格式的资源文件,其余像 PDF 格式的就需要通过插件来支持。在这章,我们只讲主要的事例——展示被CSS 格式化的 HTML 和 images 文件。
2.1 渲染引擎的种类(Rendering engines)
Firefox浏览器用的是 Gecko 渲染引擎,它是一个自制的 Mozilla 渲染引擎。
Safari 和 Chrome 用的都是 Webkit 引擎。
Webkit 引擎是一个开源的渲染引擎,它最开始是在 Linux 平台上,后被苹果公司修改用于支持 Mac 和 Windows 系统。详见 http://webkit.org/
2.2 主要流程(the main flow)
渲染引擎最开始会从网络层获取请求的文档内容,之后渲染引擎的基本流程如下:
图2.
渲染引擎会线解析 HTML 文件,会将相应的的标签转成 DOM 树中的节点。它将会解析样式数据,包括外部样式和 style
元素内的样式。这些样式信息结合 HTML 中的可视化说明被用于构建另一个树——渲染树(render tree).
这渲染树包含带有像颜色和尺寸等可视化属性的矩形,这些矩形按照正确的顺序显示在屏幕上。
在构造完渲染树后,会进入一个 layout 过程。这意味着为每个节点提供在屏幕上的具体位置坐标。下一阶段就是绘制(painting),渲染树将会被遍历,并且每个节点都会使用 UI backend 层绘制出来。
注: 重要的是要理解这是一个渐进的过程。为了获得更好的用户体验,渲染引擎将会尽快地在屏幕上显示内容。它不会等到所有的 HTML 都被解析后才开始构建和 layout 渲染树。部分内容将被解析和显示,而这个过程将继续处理来自网络的其余内容。
2.3 主要流程实例(Main flow Examples)
Webkit 主要流程如下图:
图3.
Gecko 主要流程如下图:
图4.
Gecko
把可视格式化元素称为 “Frame tree”,每个元素就一个 “frame”。而 Webkit
使用 “Render Tree” 形式并且由 “Render Objects” 组成。Webkit
将元素定位操作称为 “layout”,而 Gecko
称为 “Reflow”. “attachment” 是 Webkit
地术语,为的是连接 DOM 结点和可视化信息来构造渲染树。一个细微非语义的差异在于 Gecko
在 HTML 和 DOM 树之间存在一个额外的 “layer”,称之为 “content sink”,主要是生成 DOM 元素。接下来将详细的介绍以上流程的每一部分。
2.4 解析和 DOM 树构造(Parsing and DOM tree construction)
2.4.1 通用的解析方法(Parsing - general)
解析在渲染引擎中是一个非常重要的过程,所以我们将稍微深入地讨论下。
文档解析意味着将其转换成某种合理的结构使得代码能够理解何使用,解析的结果通常是一个能够表示文档结构的树。它称之为 “parse tree”(解析树) 或者 “syntax tree”(语法树)
比如 解析表达式 2+3-1
可以返回以下这个树:
图5.
语法
解析要基于文档遵循的语法规则——即编写文档时使用的语言或格式。你解析的每种格式都必须有由词汇表和语法规则组成的确定性语法。 这称之为 “context free grammar”, 人类语言不是这样的语言,因此不能用传统的解析技术进行解析。
Parser - Lexer combination
解析可分为词法(lexical)分析和语法(syntax)分析两个子过程。
词法(lexical)分析是将输入分解为 tokens 的过程。tokens 是语言词汇表,有效构建块的集合。在人类语言中,它将由该语言的字典中出现的所有单词组成。
语法(syntax)分析是对语言语法规则的应用。
解析器通常将工作划分为两个组件:“lexer”(有时称为 tokenizer)负责将输入分解为有效的 tokens,而解析器(parser)负责根据语言语法规则分析文档结构来构 造解析树。“lexer” 知道如何去掉不相关的字符,比如空格和换行符。
图6.
解析过程是迭代的。解析器通常会向 “lexer” 请求一个新的标记(token),并尝试用语法规则来匹配这个标记(token)。如果有规则与之匹配,那么与该标记(token)对应的节点将被添加到解析树中,解析器将请求另一个标记(token)。
如果没有规则可匹配,解析器将在内部存储 token,并不断寻找 tokens,直到找到匹配所有内部存储的 token 的规则。如果没有找到任何规则,则解析器将引发异常。这意味着该文档无效且包含语法错误。
Translation(翻译)
很多时候,解析树并不是最终的结果。解析通常用于翻译——将输入文档转换为另一种格式。编译就是一个例子。将源代码编译成机器码的编译器首先将其解析成解析树,然后将树转换成机器码文档。
图7.
解析实例
图5中,我们为一个数学表达式建立了解析树。我们尝试定义一个简单的数学语言并理解其解析过程。
词汇:该语言包含整数,加号和减号。
语法规则:
- 语言语法的构建块是表达式、术语(term)和操作。
- 语言可以包含任何数字的表达。
- 表达式被定义为:一个术语(term)后面跟一个“操作”后面跟另一个术语(term)
- 操作是正标记(token)或负标记(token)
- 术语(term)是整数标记或表达式
接下来分析输入 “2 + 3 - 1”
第一个匹配规则的子串是“2”,根据规则 #5,它是一个术语。第二个匹配项是“2 + 3”,它匹配第二个规则:一个术语后面跟着一个操作,后面跟着另一个术语。下一个匹配将只会在输入结束时命中。“2 + 3 - 1”是一个表达式,因为我们已经知道,2+3
是一个 term(因为根据规则3可知2+3
是一个表达式,再根据规则5可知2+3
是一个 term),所以我们有一个 term,然后是一个运算,然后是另一个term。再比如 “2 + +” 将不匹配任何规则,因此是无效的输入。
词汇表和语法的正式定义
词汇通常用利用 regular expressions 来表达。
比如,我们的语言词法可以被定义为:
INTEGER: 0|[1-9][0-9]*
PLUS: +
MINUS: -
由上可知,整数由正则表达式定义。
语法通常由称为 BNF 的一种格式来定义, 我们的语言语法可以被定义为:
expression := term operation term
operation := PLUS | MINUS
term := INTEGER | expression
我们说过,如果一种语言的语法是 “context free grammar”,那么它可以被常规语法分析器解析。“context free grammar” 的直观定义是可以完全用BNF表示的语法。有关正式定义,请参阅 http://en.wikipedia.org/wiki/Context-free_grammar 。
解析器的类型(Types of parsers)
解析器有两种基本类型:自顶向下解析器和自底向上解析器。一个直观的解释是,自顶向下解析器查看语法的高级结构,并尝试匹配其中之一。自底向上解析器从输入开始,逐步将其转换为语法规则,从低级规则开始,直到满足高级规则为止。
让我们看看这两种类型的解析器将如何解析我们的例子:
自顶向下解析器将从高级规则开始—它将“2 + 3”标识为表达式。然后,它将“2 + 3 - 1”标识为一个表达式(标识表达式的过程演化为匹配其他规则,但起点是最高级别的规则)。
自底向上解析器将扫描输入,直到匹配规则为止,然后用规则替换匹配的输入。这将一直持续到输入结束。部分匹配的表达式放在解析器堆栈上:
stack | Input |
---|---|
2 + 3 - 1 | |
term | + 3 - 1 |
term operation | 3 - 1 |
expression | - 1 |
expression operation | 1 |
expression |
这种自底向上的解析器称为"shift reduce"解析器,因为输入被移向右边(假设一个指针首先指向输入起点,然后向右移动),并逐渐简化为语法规则。
自动生成解析器(Generating parsers automatically)
有一些工具可以为你生成解析器。它们被称为解析器生成器。你向它们提供你的语言语法—词汇表和语法规则,它们就会生成一个有效的解析器。创建解析器需要对解析有深刻的理解,手动创建很好的解析器并不容易,因此解析器生成器非常有用。
Webkit
使用了两个众所周知的解析器生成器,Flex用于创建"lexer", Bison用于创建解析器(你可能会遇到名称为 Lex 和 Yacc 的解析器)。Flex输入是一个包含由标记(tokens) 正则表达式定义的文件。 Bison的输入是 BNF 格式的语言语法规则。
2.4.2 HTML 解析器(HTML Parser)
HTML 解析器的工作是将 HTML 标记解析成解析树。
HTML 语法定义(The HTML grammar definition)
HTML的词汇表和语法是在 w3c 组织创建的规范中定义的。当前版本是HTML4, HTML5的工作正在进行中。
Not a context free grammar
正如我们在解析介绍中看到的,语法可以使用 BNF 之类的格式来正式定义。
不幸的是,所有传统的解析器都不适用于HTML(我并不是为了好玩才提出这些的,它们将用于解析CSS和JavaScript)。解析器需要的 context free grammar 很难定义HTML。
有一种定义HTML - DTD(文档类型定义, Document Type Definition)的正式格式,但它不是 context free grammar。
这看起来很奇怪,HTML 非常接近 XML,有很多可用的 XML 解析器。HTML有一个XML变体——XHTML,那么最大的区别是什么呢?
不同之处在于HTML方法更“宽容”,它允许您省略某些隐式添加的标记,有时省略标记的开始或结束等。总的来说,它是一种“软”语法,而不是 XML 僵硬和苛刻的语法。
很明显,这种看似微小的差异会造成天壤之别。一方面,这是为什么 HTML 如此流行的主要原因——它原谅你的错误,利于web开发者。另一方面,它使编写格式语法变得困难。总而言之,HTML不容易解析,传统解析器无法解析它,因为它的语法不是 context free grammar,XML解析器也无法解析它。
HTML DTD
HTML定义是DTD格式。这种格式用于定义 SGML 家族的语言。该格式包含所有允许的元素及其属性和层次结构的定义。正如我们前面看到的,HTML DTD不能形成 context free grammar。
DTD有几个变体。严格模式仅符合规范,但其他模式包含对过去浏览器使用的标记的支持。其目的是向后兼容旧的内容。当前的严格DTD在这里:http://www.w3.org/TR/html4/strict.dtd
DOM
作为输出树,本质上是拥有DOM元素和属性节点的解析树。DOM是(文档对象模型,Document Object Model)的简称。它用于表示HTML文档的对象,是HTML元素与外部世界(如JavaScript)的接口。其中DOM树的根是“Document”对象。
DOM与标记几乎是一对一的关系。比如:
<html>
<body>
<p>
Hello World
</p>
<div> <img src="example.png"/></div>
</body>
</html>
将被转换为以下的 DOM 树:
图8.
与HTML一样,DOM也是由w3c组织指定的。见http://www.w3.org/DOM/DOMTR。它是操作文档的通用规范。其中特定的模块描述HTML对应的元素。HTML定义可以在这里找到:http://www.w3.org/TR/2003/REC-DOM-Level-2-HTML-20030109/idl-definitions.html。
当我说树包含DOM节点时,我的意思就是这树是由实现一个DOM接口的元素构成的。浏览器也会实现某些在浏览器内部使用的其他属性。
解析算法(The parsing algorithm)
正如我们在前面几节中看到的,不能使用常规的自顶向下或自底向上解析器解析HTML。
原因如下:
- 语言的宽容本性
- 浏览器具有传统的容错能力来支持众所周知的无效HTML。
- 可重入(reentrant)的解析过程。通常在解析过程中源代码不会改变,但是在HTML中,脚本标记包含“document.write“可以添加额外的标记(tokens),因此解析过程实际上修改了输入.
由于无法使用常规的解析技术,浏览器创建自定义解析器来解析HTML。
解析算法由HTML5规范详细描述。该算法由 tokenization 和 树构造(tree construction) 两个阶段组成。
tokenization 是词法分析,将输入解析为标记(tokens)。在HTML标记(tokens)中有开始标签、结束标签、属性名称和属性值。
tokenization 识别标记(token),将其交给树构造器(tree constructor),并使用下一个字符来识别下一个标记,以此类推,直到输入结束。
The tokenization algorithm
算法的输出是一个HTML标记(token)。该算法被表示为一个状态机。每个状态使用输入流的一个或多个字符,并根据这些字符更新下一个状态。决策受到当前 tokenization 状态和树构造(tree construction) 状态的影响。这意味着,根据当前状态,相同的已使用字符将为正确的下一个状态产生不同的结果。这个算法太复杂了,所以我们来看一个简单的例子来帮助我们理解原理。
比如:
<html>
<body>
Hello world
</body>
</html>
初始状态是“数据状态(Data state)”。当遇到 “<” 字符时,状态变为 “标签打开状态(Tag open state)”。使用 “a-z” 字符会创建 “开始标签标记(Start tag token)”,状态将更改为“标签名称状态(Tag name state)”。我们将一直保持这种状态,直到“>”字符被使用。每个字符都附加到这新的 token 中。在我们的例子中,创建的token是一个“html” 标记(token)。
当遇到“>”字符时,当前 token 被触发并将状态更改回“数据状态(Data state)”。<body>
标签将按照相同的步骤处理。到目前为止,已经处理了“html
”和“body
”标签。我们现在回到了“数据状态(Data state)”。使用“Hello world”的“H”字符将导致创建和触发字符标记(token),这将一直进行下去,直到遇到“</body>
”的“<”为止。我们将为“Hello world”的每个字符发出一个字符标记(token)。
我们现在回到了“标签打开状态(Tag open state)”。使用下一个输入“/”将导致创建“结束标签标记(end tag token)”并更新为“标记名称状态(Tag name state)”。我们再次保持这种状态,直至遇到“>”。然后发出新的标签token