共计 10828 个字符,预计需要花费 28 分钟才能阅读完成。
浏览器是如何工作的(How browser work)
-
1. 介绍
- 1.1 本文涉及到的浏览器
- 1.2 浏览器的主要功能
- 1.3 浏览器的主要结构
- 1.4 组件之前的通信
-
2. 渲染引擎
- 2.1 渲染引擎
- 2.2 主要的流程
- 2.3 流程示例
- 2.4 解析以及 DOM 树的结构
- 2.5 渲染树 (Render tree) 的结构
- 2.6 布局
- 2.7 绘制(Painting)
- 2.8 动态改变
- 2.9 渲染引擎的线程
- 2.10 css2 虚拟模型
- 2.11 资源
1. 介绍
浏览器可谓是使用最广泛的软件. 这篇文章我将要解释浏览器在底层是如何工作的. 我们将会了解当你在浏览器地址栏里输入 ’google.com’ 直到页面呈现出来这一过程都发生了什么。
1.1 本文涉及到的浏览器
目前市面上主要有 5 款浏览器:Internet Explorer, Firefox, Safari, Chrome 以及 Opera。
我将使用开源的浏览器中进行举例,包含 Firefox, Chrome 以及 部分开源的 Fafari。
根据 W3C browser statistics, 当前时间是 2009 年 10 月,使用 Firefox, Safari 以及 Chrome 的比例占据将近 60%。
所以目前开源浏览器占据的浏览器市场很大的份额。
1.2 浏览器的主要功能
浏览器的主要功能是把你从服务器请求到的网络资源呈现在浏览器窗口上。资源通常包含了 HTML,PDF,图片等等。资源通常是由用户指定的 URI(Unifor resource Identifier 统一资源定位符)来定位的。稍后的章节会介绍。
浏览器解释和呈现 HTML 文件的方式是通过 HTML 和 CSS 规范来实现的。这些规范是由 W3C 组织进行维护的,该组织是互联网的标准制定者。长久以来各个浏览器厂商只实现了一部分规范,并且开发自己的扩展程序。这导致了在不同的浏览器当中很严重的兼容性问题。到目前为止,大部分的浏览器都大多实现了规范。
不同的浏览器 UI 有很多相同的部分:
- 输入 URI 的地址栏
- 前进和后退按钮
- 书签操作
- 操作当前加载文档的刷新和停止按钮
- 返回主页的主页按钮
但是比较奇怪的是,浏览器的 UI 并没有一个通用的规范,它只是不同的浏览器厂商从长期的使用习惯中积累的经验。HTML5 规范并没有规定浏览器的 UI 必须包含哪些元素,只是列出了一些通用的元素。地址栏、状态栏、工具栏以及各个浏览器指定的特定,例如 Firefox 的下载管理。更多参见用户界面章节。
1.3 浏览器的主要结构
浏览器的主要组成部分:
- 用户界面(The user interface) – 包含地址栏、前进 / 后退按钮、书签等等。除了主要的窗口之外你所看到的就是请求的页面。
- 浏览器引擎 – 查询和操作渲染引擎的入口。
- 渲染引擎 – 负责呈现请求内容。例如请求内容是 HTML, 渲染引擎负责解析 HTML 以及 CSS,并且渲染解析后的内容到屏幕上。
- 网络链接 – 处理形如 HTTP 的网络请求。它有针对不同平台的实现接口。
- 用户界面的后台处理程序 (UI Backend) – 用于绘制类似于 bombo 盒子的小部件以及一些窗口。它抛出了各个平台通用的接口。它的底层是
使用了操作系统的用户界面方法。 - Javascript 解释器。用于解析和执行 Javascrip 代码。
-
数据存储。这是一个持久层。浏览器需要在硬盘上保存各种各样的数据,比如 coocies。HTNL5 规范定义了 ’web database’, 针对浏览器的完整的数据库(尽管比较轻量)
Figure 1: Browser main components.
值得注意的是,Chrome 不像其他的浏览器,它给每一个 tab 分配一个渲染引擎的实例,每一个 tab 都是一个独立的进程。
1.4 组件间的通信
Firefox 和 Chrome 都独自开发了一套特别的通信机制。
2. 渲染引擎
渲染引擎的职责就是进行渲染,也就是负责把请求到的内容呈现在浏览器屏幕上。
在默认情况下,渲染引擎能够展示 HTML,XML 以及 image 文档。也能通过插件来展示其他类型的文档。例如通过 PDF 视图插件可以展示 PDF。我们将会在特定的章节讨论插件和扩展程序。本章节主要着重于主要的情况 - 如何展示由 css 格式化的 HTML 和 images。
2.1 渲染引擎
我们参考的 Firefox,Chrome,Safari 浏览器都是基于两个渲染引擎建立的。Firefox 使用 Gecko, 一个 Mozilla 自己开发的引擎。Safari 和 Chrome 都是使用的 Webkit 引擎。Webkit 引擎最开始是用于 linux 平台的开源引擎。后续被修改用于支持 Apple 的 Mac 以及 Windows 系统。详情移步 http://webkit.org/
2.2 主要渲染流程
渲染引擎将会从网络层请求到内容开始进行工作。这通常的大小在 8k 以内。
在这之后,以下就是渲染引擎基本的流程:
Figure 2: Render engine basic flow.
解析 HTML,生成 DOM tree -> 渲染 render tree 结构 -> 组织 render tree 的布局 -> 在窗口绘制 render tree
渲染引擎会解析 HTML 文档,把 HTML 文档解析为“内容树(content tree)”,并把 HTML 标签转换为树中的 DOM 节点。渲染引擎还要解析样式文件,包含外链样式文件以及内联样式元素。样式信息和 HTML 当中可视化的指令将会用于创建另外一个树 – 渲染树(render tree)。
渲染树包含了具有颜色以及尺寸等可视化属性的矩形盒子集合。这些矩形盒子都是按照在屏幕上的显示顺序排序的。
在构造晚渲染树之后,将会经过“layout”过程。意思就是给每一个节点设置在屏幕上显示的确切坐标位置。下一个阶段是绘制(painting) – 渲染树将会通过 UI 的后台处理层,每一个节点都将会被绘制。
了解渲染的过程是一个循序渐进的过程很重要。为了达到更好的用户体验,渲染引擎将会尽可能快的把内容展示在屏幕上。它并不会等到所有的 HTML 都解析完之后才去构建和布局渲染树。当请求到一部分内容的时候,引擎将会解析和渲染这一部分内容,同时程序也将继续解析从网络中请求到的余下的内容。
2.3 渲染流程示例
Figure 3: Webkit main flow
Figure 4: Mozilla's Gecko rendering engine main flow
从图 3 和图 4 中可以看到尽管 Webkit 和 Gecko 使用了稍微不同的术语,但是流程是基本相同的。
Gecko 把格式化的元素形象的称为:Frame tree(结构树)。每一个元素都是一个框架。Webkit 使用术语:Render tree,它由 Render Object 组成。Webkit 把设置元素的位置称为 layout,而 Gecko 称为 Reflow。Webkit 把连接 DOM 节点和视觉信息生成渲染树称为 Attachment。另外一个较小的非语义上的差别是 Gecko 在 HTML 与 DOM 树之间多了额外的一层。叫做 content sink, 它是创建 DOM 元素的工厂。我们将会逐个了解流程的每一部分。
通常的解析
既然解析在渲染引擎内是一个非常重要的过程,我们将会深入的了解它。
文档解析,亦即把它转换为一种代码可以理解和使用的结构。解析的结果通常是一个表示文档结构的 节点树 。它被称为解析树或者 语法树 。
例如:2 + 3 – 1 的表达式解析结果为
Figure 5: 运算表达式的树节点
文法
解析是基于创建文档语言所遵循的语法规则。每一个你能够解析的格式,都有一个由词法和语法规则组成的确切的文法。它被称为 context free grammar(上下文无关的语法)。人类语言不是这样的语言,也就是说没法用常规的解析技术来进行解析。
解析器 – 词法组合
解析可以被分为两个步骤 – 词法分析 以及 语法分析。
词法分析是把输入的内容分解为很多符号的一个过程。这些符号是构成语言的词汇 (构建语言有效的块集合)。在人类的语言中,它就是某种语言在字典中的所有单词所组成的。
语法分析就是语言语法规则的应用。
解析器的工作通常分为两个内容:词法分析器 (有时称为 标记生成器)负责把输入分解为很多符号, 解析器 负责根据该语言的语法规则来分析文档结构,从而构建解析树。词法分析器知道如何区分和解释特殊的字符,例如空格和换行符。
Figure 6: from source document to parse trees
解析的过程是迭代式的。解析器通常会向词法分析器询问是否有新的符号,并且试图通过一条语法规则的来进行匹配。如果符合某条语法规则,该符号对应的节点将会被添加到解析树,紧接着解析器会询问另外一个符号就行解析。
如果没有规则匹配,解析器会在内部存储这个符号,并继续询问下一个符号直到某条规则匹配所有的内部存储的符号。如果没有找到对应的规则,解析器就回抛出一个异常。这意味着这个文档无效,并且包含语法错误。
翻译
通常解析树并不是最终的结果。解析结果通常被翻译 - 把文档翻译为另外一种格式。一个例子就是汇编。编译器会把源码编译为机器码,首先会把源码解析为解析树,然后再把解析树翻译为机器码文档。
Figure 7: compilation flow
解析实例
在图 5 中,我们从一个数学表达式中创建了一个解析树。让我们来定义一个简单的数学语言来了解解析过程。
词汇:我们的语言包含整数,加法符号,减法符号
语法:
1. 构成语法的元素包含表达式,运算项,运算符。2. 我们的语言能够包含任意数量的表达式。3. 一个表达式定义为:一个运算项 跟着一个 操作符,再跟着另外一个运算项。4. 操作符为加号或者减号
5. 运算项为一个整数或者一个表达式。
分析下:”2 + 3 – 1″。
根据上面第 5 条规则,第一个匹配规则的子串是 ”2″。第二个匹配的的结果是 ”2 + 3″,它对应第二条规则。下一个匹配的结果已经到了该输入项的结尾。我们已经知道了形如?2 + 3? 表示一个完整项,那么 “2 + 3 + 1″ 就是一个表达式。”2 + +” 是一个无效的输入,因为没有匹配任何规则。
正式的定义词汇和语法
词汇通常都通过常规的表达式来表示。
例如我们将会像下面这样来定义我们的语言:
INTER :0|1-9*
PLUS : +
MINUS : –
如你所见,整数是通过常规的表达式来表达的。
语法是遵循 BNF(Backus Naur form).html)来定义的。我们的语言将会做如下的定义:
expression := term operation tem
operation := PLUS | MINUS
term := INTEGER | expression
我们之前说过,如果程序的语法是一个上下文无关的语法,就可以使用通常的解析器进行解析。上下文无关的语法,最直观的定义就是可以完全使用 BNF 来表示。可以参见 http://en.wikipedia.org/wiki/Context-free_grammar
解析器类型
解析器有两种类型:自上而下 和 自下而上 的解析器。自上而下的解析器是从语法层级比较高的地方着手进行匹配解析。自下而上的解析方式是从输入开始,逐级向上翻译为对应的语法规则,直到语法层级较高的规则为止。
让我们结合实例来看看这两种解析方式:
自上而下的解析将会从层级比较高的规则开始:它将把 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 parser),因为输入是从左向右移动的,并且根据规则匹配主键减少。
自动生成解析器
可以通过工具生成解析器,被称之为解析器生成器。你只需要提供语言的词汇以及语法规则,它就能够生成一个可用的解析器。创建一个解析器徐傲对解析有深入的理解。不太容易手动创建一个解析器,所有解析器生成器会比较有用。
Webkit 使用两个比较出名的解析器生成器:Flex 用于创建词法分析器,Bison 用于创建解析器(你可以使用 Lex 和 Yacc 来运行)。Flex 的输入是包含通常的表达式定义的一个文件。Bison 的输入是 BNF 格式的语法规则。
HTML 解析器
HTML 解析器的职责是把 HTML 标记转换为解析树。
HTML 语法定义
HTML 的词汇和语法在 w3c 创建的规范里定义。
非上下文无关的程序语言
在解析一节的介绍里,我们知道程序语法可以通过 BNF 格式进行定义。
但是不幸的是,所有常规的解析器都不适用于 HTML。HTML 不能够被轻易的定义为解析器需要的上下文无关的程序语法。
有一个定义 HTML 的通用格式 -DTD(Document Type Definition), 不过并不是上下文无关的语法。
一眼看上去,HTML 与 XML 非常的接近。有很多的 XML 解析器。有一个 HTML 的 XML 变体 -XXHTML。这二者有什么不用呢?
不同之处在于 HTML 的目的在于非严谨的,它允许忽略你某些标签,并隐式的添加上,例如有时候允许忽略开始或者结束标签。不同于 XML 语法的严格和硬性要求,HTML 整体上都是比较宽泛的。
一方面这也是 HTML 如此浏览的一个原因,允许你犯错,让 web 开发更容易。另一方面,它导致很难定义一个语法格式。总结起来说,HTML 比较难解析,由于并不是一个上下文无关的编程语法,它不能够被普通的解析器解析,也不能被 XML 解析器解析。
HTML DTD
HTML 是通过 DTD 来定义的。这个格式用于定义 SGML(Standard Generalized Markup Language)语言。它定义了所有允许的元素,属性以及层级。正如我们之前所说的,HTML DTD 不能形成上下文无关的语言。
DTD 有一些变动,严格模式严格符合规范,其他的模式支持历史版本的浏览器。目的也是为了兼容老版本的浏览器。最新的严格 DTD 地址:http://www.w3.org/TR/html4/strict.dtd
DOM
解析树是由 DOM 元素以及属性节点组成的。DOM 是 Document Objectd Model 的简称。它是 HTML 文档的对象形式以及其他外部语言 (形如 Javascript) 的接口。树的根节点是 Document 对象。
DOM 与标签之前有着一对一的对应关系。例如:
<html>
<body>
<p>
Hello World
</p>
<div> <img src="example.png"/></div>
</body>
</html>
将会被翻译为以下的 DOM 树:
Figure 8: DOM tree of the example markup
跟 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 接口的元素构建的。不同的浏览器使用了具体的实现,这些实现包含了浏览器内部使用的其他属性。
解析算法
在前几节中,我们知道,HTML 不能够通过自上而下或者自下而上的解析器解析。
原因如下:
1. 语言的非严谨性。2. 浏览器具有传统的容错机制,用于支持很好的辨别无效的 HTML。3. 解析进程的反复迭代机制。在解析的过程中,解析源通常都不会被改变,但是在 HTML 中,script 标签包含了 document.write,可以添 加额外的元素,所以解析过程中修改了原始输入。
由于不能够使用通常的解析器就行解析,浏览器为解析 HTML 创建了定制的解析器。
解析算法在 HTML5 规范中有详细的描述。算法由两步构成:符号化 和 构建树。
符号化即词法分析,把输入解析为一组符号。HTML 的符号包括开始标签,结束标签,属性名和属性值。
标记生成器识别不同的标记,并把它传递给树构造器,紧接着识别下一个标记,周而复始,直到结束。
Figure 6: HTML parsing flow (taken from HTML5 spec)
符号化的算法
算法的输出结果是一个 HTML 的标签。算法被表示为状态机。每一个状态消耗一个或者多个输入流的字符,然后根据选中的字符跟更新下一个状态。当前的执行会被符号化的状态和构建树的状态所影响。这意味着,相同的符号处理,将会产生不同的结果,根据当前的状体来纠正下一个状态。这个算法太复杂了,因此不能完整的呈现出来。所有我们通过一个简单的实例来帮助我们理解这个原则。
基础实例:符号化以下的 HTML:
<html>
<body>
Hello world
</body>
</html>
初始状态是 ”Data state”。当遇到 ”<“ 符号的时候,状态被变更为 ”Tag open state”。在处理 ”a-z” 之间的字符时会创建 ”Start tag token”,状态被变更为 ”Tag name state”。状态会一直保持,直到遇到 ”>” 字符。每一个字符都会被添加到新的标签名里。在我们的事例里创建的是一个 ”html” 标签。
当处理到 ”>” 符号的时候,当前的标签就回被发送出去,同时状态会变更回 ”Data state”。”<body>” 标签也以同样的方式进行处理。到目前为止,”html” 和 ”body” 标签都被触发。我们现在回到了 ”Data state”。
处理 ”Hello world” 中的 ”H” 字符会创建和出发一个字符标签,直到遇到 ”</bodu>” 的 ”<“ 符号为止。我们会为 ”Hello world” 的每一个字符都触发一个字符标签。
现在我们回到 ”Tag open state”。处理 ”/” 会创建一个 ”end tag token”,并且状态变更为 ”Tag name state”。我们依然保留当前状态直到遇到 ”>” 为止。之后新的标签就回被出发,状态返回 ”Data state”。”</html>” 的处理方式雷同。
Figure 9: Tokenizing the example input
树结构算法
当解析器被创建的时候,文档对象也会被创建。在构建树结构的过程中,文档的 DOM 树将会被修改,相应的元素将会被添加进去。标记生成器创建的每一个节点都会被树构造器处理。规范中定义的每一个 DOM 元素关联的标记都会被创建。除了把元素添加到 DOM 树之外,还会被添加到 ”open elements” 栈中。这个栈被用于纠正不匹配的嵌套以及处理未关闭的标签。这个算法过程也被描述为一个状态机。状态被称为 ”insertion modes”。
让我们看看事例中构造树的过程:
<html>
<body>
Hello world
</body>
</html>
树构造阶段接收的输入是从字符化阶段传入的字符序列。第一个模式是 ”initial mode”。当接收到 html 标签的时候,会移动到 ”before html” 模式,同时再对标签进行处理。此时会创建一个 HTMLHtmlElement 元素,这个元素会被添加到文档对象的根节点。
之后状态将会变为 ”before head”。我们会接收到 body 标签,此时将会隐式的创建一个 HTMLHeadElementut 元素并添加到 DOM 树里,尽管示例中并没有 head 标签。
紧接着移动到 ”in head”,然后是 ”after head”。body 标签会被再加工,一个 HTMLBodyElement 将会被创建和添加到 DOM 树,模式会移动到 ”in body”。
接下来会接收到 ”Hello world” 字符串。处理第一个字符的时候会创建一个 ”Text” 节点,其他的字符会被添加到这个节点中。
当接收到 body 结束标签的时候会移动到 ”after body” 模式。此时我们会接收到 html 结束标签,会移动到 ”after after body” 模式。接收到文件结束标签的时候将会结束解析。
Figure 10: tree construction of example html
解析之后的动作
在这一步浏览器将会把文档标记为可交互的,同时开始解析在“defferred”模式下的 scripts 文件(在文档解析完成之后将会被执行)。文档的状态将会被修改为“complete”,同时触发一个“load”事件。
你可以在 HTML5 规范里查看标记化以及构建树的完整算法。https://www.w3.org/TR/html5/syntax.html
浏览器的容错
在 HTML 页面里你永远不会收到一个“语法无效”的错误。浏览器会处理无效的内容。
以下面的 HTML 为例:
<html>
<mytag>
</mytag>
<div>
<p>
</div>
Really lousy HTML
</p>
</html>
我已经违反了很多规则(“mytag”不是标准的标签,“p”和“div”标签的错误嵌套等等),但是浏览器仍然会把内容正确的展示出来,并不会报错。所以有很多的解析代码来修复了 HTML 开发者的错误。
浏览器中的错误处理始终是一致的,但是让人比较惊讶的是它并不是当前 HTML 规范的一部分。它就像书签和前进后退按钮一样,只是多年以来在浏览器中开发出来的某个东西。在很多站点中都已知很多无效的 HTML 结构,浏览器会试着已一致的方式修复它们,以顺应其他浏览器。
HTML5 规范针对这些要求做了一些定义。Webkit 在 HTML 解析类开始的注释中做了很好的描述:
The parser parses tokenized input into the document, building up the document tree. If the document is well-formed, parsing it is straightforward.
Unfortunately, we have to handle many HTML documents that are not well-formed, so the parser has to be tolerant about errors.
We have to take care of at least the following error conditions:
1. The element being added is explicitly forbidden inside some outer tag.
In this case we should close all tags up to the one, which forbids the element, and add it afterwards.
2. We are not allowed to add the element directly.
It could be that the person writing the document forgot some tag in between (or that the tag in between is optional).
This could be the case with the following tags: HTML HEAD BODY TBODY TR TD LI (did I forget any?).
3. We want to add a block element inside to an inline element. Close all inline elements up to the next higher block element.
4. If this doesn't help, close elements until we are allowed to add the element or ignore the tag.
让我们看看 Webkit 的容错示例:
</br> instead of <br>
某些站点使用 </br> 代替 <br>。为了兼容 IE 和 Firefox, Webkit 使用 <br>。
代码:
if (t->isCloseTag(brTag) && m_document->inCompatMode()) {reportError(MalformedBRError);
t->beginTag = true;
}
注意:错误处理是在内容,并不会呈现给用户。
错乱的 table
错乱偏离的 table 是指在另外一个 table 里但是却不在 table cell 里的 table。
就像下面的例子:
<table>
<table>
<tr><td>inner table</td></tr>
</table>
<tr><td>outer table</td></tr>
</table>
Webkit 就会把结构修改为两个子 table
<table>
<tr><td>outer table</td></tr>
</table>
<table>
<tr><td>inner table</td></tr>
</table>
代码:
if (m_inStrayTableContent && localName == tableTag)
popBlock(tableTag);
Webkit 使用栈来管理当前元素内容,它会弹出内部 table,再入栈到外部 table 的栈中。table 至此就相邻了。
嵌套的表单元素
以防用户在 form 中放置另外一个 form, 第二个 form 将会被忽略。
if (!m_currentFormElement) {m_currentFormElement = new HTMLFormElement(formTag, m_document);
}
过深的标签层级
注释不言而喻。
www.liceo.edu.mx is an example of a site that achieves a level of nesting of about 1500 tags, all from a bunch of <b>s.
We will only allow at most 20 nested tags of the same type before just ignoring them all together.
代码:
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 结束标签的错放
看注释:
Support for really broken html.
We never close the body tag, since some stupid web pages close it before the actual end of the doc.
Let's rely on the end() call to close things.
if (t->tagName == htmlTag || t->tagName == bodyTag)
return;
所以 web 开发者需要注意:除非你想呈现一个 Webkit 容错的示例代码,否则请编写完整的 HTML 标签。
CSS 解析
待续 …