How browsers work

时间:2021-06-28 04:59:45

这几天翻译一篇旧文 How browsers work ( 以现代浏览器chrome、火狐、safari 为对象来分析),这篇文章网上有其他的翻译版本,自己再翻译一遍主要是练习阅读英文文章,而且自己翻译记忆会更深刻。

原文链接:  http://taligarsiel.com/Projects/howbrowserswork1.htm#Introduction

简介

浏览器可能是使用最多的软件,我会分析现代浏览器是如何工作的,你将会了解从地址栏输入网址 "google.com" 到显示屏出现谷歌首页的整个过程中发生了什么。

我们讨论的浏览器对象

目前绝大多数用户使用的浏览器主要有 5 款,IE、Firefox、Safari、Chrome 和 Opera。我以开源的浏览器 Firefox、Chrome 和 Safari 为对象来举例分析。根据 W3C 浏览器状况统计,截止 2009年10月这三款浏览器的市场份额加起来接近60%。所以现在开源浏览器成为浏览器业务中非常重要的一部分。

浏览器的主要功能

浏览器的主要功能是通过向服务器发送请求,把用户选择的网络资源,展示在浏览器的窗口。资源通常是 HTML 文件或者 PDF 文件、图片等等其他格式。资源的位置由 URI(统一资源标识符)指定。更多相关内容参阅网络章节。

浏览器按照 HTML 和 CSS 规范来解析 HTML 文件,这些规范由 W3C(万维网联盟)组织负责制定和维护。目前 HTML 的版本是4 (http://www.w3.org/TR/html401/),HTML5的标准还在制定中。CSS 标准的版本是2,CSS3标准同样也在制定中。

在过去的好多年,不同的浏览器厂商们都只实现了标准规范的一部分并且制定了自己的标准,使得 web 开发者为了让自己的网页在每个浏览器上的兼容而头痛不已。如今,大多数浏览器多多少少都会按照标准规范工作。

不同浏览器的用户界面有许多相同的地方:

  • 用来输入 URI 的地址栏
  • 回退、前进按钮
  • 书签功能
  • 刷新、停止加载按钮 ---- 重新加载和停止加载当前页面
  • 主页按钮 ---- 快速打开主页的

奇怪的是,对于浏览器的用户界面并没有相关的标准,如今的界面是浏览器厂商数年来的最佳实践经验以及相互模仿而统一的。HTML5 标准也没有规定浏览器用户界面必须有哪些元素,只是列出了一些通常用到的元素,包括地址栏、状态栏、工具栏。当然特定的浏览器会有与其他浏览器不同的组件,例如 Firefox 中的下载管理。更多相关内容参阅用户界面章节。

浏览器构造

浏览器的主要组成部分:

用户界面 —— 除页面窗口外的其他部分包括地址栏、后退/前进按钮、书签菜单等。

浏览器引擎 —— 查询和操作渲染引擎的接口。

渲染引擎 —— 负责展示请求到的内容。例如:当请求内容是 HTML时,负责解析 HTML 和 CSS 并将内容展示在屏幕上。

网络 —— 用于发送 HTTP 等网络请求。每个平台下都有实现,且有独立于平台的通用接口。

用户界面后台 —— 描绘基本组件例如下拉列表框、窗口。有独立于平台的通用接口,在底层调用了操作系统的用户界面方法。

JavaScript 解释器 —— 解析和执行 JavaScript 代码。

数据存储 —— 这是一个持久层,因为浏览器需要将所有数据存储在硬盘上,例如 cookies。HTML5 标准在浏览器内定义了一个轻量级但是功能完善的数据库叫做"web database"。

How browsers work

图1:浏览器主要组成部分

需要注意的是,与其他浏览器不同,Chrome 浏览器会保持多个渲染引擎的实例,每个实例负责一个 tab 标签页,每个 tab 标签都有独立的进程。

后面会针对所有的组件一一详解。

浏览器各部分之间的通信

Firefox 和 Chrome 各自开发了一套独特的通信结构。后面会详细讲到。

渲染引擎

渲染引擎的任务就是……渲染,即把请求到的内容展示在浏览器窗口内。

渲染引擎默认可以展示 HTML文件、XML 文件和图片。通过浏览器插件扩展,也可以展示其他内容。例如,通过 PDF 阅读器插件展示 PDF 文件。我们会有专门一章讨论插件扩展,在这一章主要解析浏览器怎样展示带有 CSS 的 HTML 和 图片。

渲染引擎 Rendering engines

Firefox、Chrome、Safari 使用了两种渲染引擎,Firefox 用的是 Gecko —— 是 Mozilla 自主研发的渲染引擎,而 Safari 和 Chrome 使用的是 Webkit 引擎。

Webkit 引擎是一款开源的渲染引擎,起初是为 Linux 系统研发的,苹果公司将其移植到了 Mac 系统和 Windows 系统。更多内容参阅  http://webkit.org/

主要流程 The main flow

渲染引擎在网络层请求文件的内容,通常在 8K 数据块内完成。

下图展示了获取内容之后渲染引擎的基本工作流程:

How browsers work

图2:渲染引擎基本工作流程

渲染引擎一开始会解析 HTML 文件,把每个标签转换成 “内容树” 上的 DOM 节点。引擎会解析节点相关联的样式,包括外部的 CSS 文件和内联样式。样式信息和 HTML 内的可视属性(visual instructions)会一起用来构建另一颗树 —— 渲染树。

渲染树是由一系列的矩形组成,这些矩形带有一些视觉属性例如颜色、尺寸。这些矩形按照正确的顺序一一展示在屏幕上。

渲染树构建后,引擎会开始构建整个页面布局,把每个节点放在它该出现的屏幕坐标处。接下来就是描绘过程,遍历渲染树的所有节点,调用用户界面后台进行描绘。

值得注意的是,为了更好的用户体验,渲染引擎会尝试尽早把页面内容展示出来,不会等到整个 HTML 解析完之后才开始构建布局和渲染树。在不断接收内容的同时,先接收到的部分内容会被先解析然后显示出来。

主要流程示例 Main flow examples

How browsers work

图3:Webkit 主要工作流程

How browsers work

图4:Mozilla's Gecko 渲染引擎工作流程

从图3和图4可以看出,Webkit 和 Gecko 的整个工作流程基本相同,只是某些步骤的名称不同。

Gecko 把带有样式的元素组成的树称为框架树(frame tree),每个元素都是一个框(frame)。Webkit 则使用渲染树(render tree)来称呼渲染对象们(render objects)构成的树。Webkit 把元素的摆放称为“布局”(layout),而 Gecko 称之为“回流”(reflow)。Webkit 把结合 DOM 节点和样式信息生成渲染树的过程叫做附着(attachment)。如果要说两者在流程上有什么不同,仅有的不同是 Gecko 在 HTML 和 DOM树之间有额外还有一层“内容汇集”(content sink),DOM 元素就是在这一层生成。

接下来我们来讨论一下整个流程中的每一步:

解析 Parsing - general

因为解析对渲染引擎来说是非常重要的一个过程,我们会讨论地稍微深入点,一开始先介绍解析这个概念。

解析一个文件就是把文件转化成一个代码能够理解和使用的结构,通常一个文件会被解析成一棵由节点构成的树,整棵树表示了文件的构造。我们把这棵树叫做解析树(parse tree)或者语法树(syntax tree)。

例子 —— 表达式 “2+3-1” 会被解析为下面这棵树:

How browsers work

图5:数学表达式节点树

1.文法 Grammars

解析是基于文件的语法规则的 —— 文件的语言或者格式。每一种可被解析的格式都是由词汇和语法规则构成的一种确定性的文法,即上下文无关文法。人类的语言不符合这个特征,因此不能被常规的解析技术解析。

2.解析器 Parser - Lexer combination

解析的过程可以分为两步 —— 词汇分析和语法分析。

词汇分析将内容分解为有效的构建块的集合,即语言单词。对于人类语言,语言单词的集合就是某国语言在其字典里出现的所有单字或单词。

语法分析就是按照语言的语法规则分析。

解析器通常将任务分配给两个组件 —— 词法分析程序或称为分词器(lexer sometimes called tokenizer) 负责把输入内容分解为语言单词,语法分析器按照语法规则分析文档的结构然后构建解析树。分词器会把与内容无关联的部分去掉,比如空格和换行。

How browsers work

图6:从文档到解析树

解析的过程是迭代的。解析器从分词器获得语言单词然后去匹配语法规则,如果符合了某个规则,该单词对应的节点会被添加到解析树,接着解析器继续从分词器获取新的语言单词。如果不符合所有的规则,解析器将单词储存在内部然后继续获取语言单词直到内部储存的内容符合了某个语法规则。如果最后有不匹配规则的内容,解析器会抛出错误,表示该文档里有语法错误不可用。

3.转化 Translation

解析树通常不是我们要的最后结果,而是转化(将文档转化成其他格式)过程中的一步。拿编译来说,编译器把文档源代码编译为机器语言代码时会先把源代码解析成一个解析树,再把解析树转化成机器语言文档。

How browsers work

图7:编译的流程

4.解析示例 Parsing example

在图5中我们已经展示过一个数学表达式转化成的解析树,这里我们尝试定义一种数学语言然后看看解析的过程是怎样的。

词汇表:整数、加号、减号

语法:

  1. 语言的构成有表达式、term、运算符。
  2. 可以有任意数量的表达式。
  3. 表达式的格式为:一个 term 之后跟着一个运算符,之后再跟一个 term。
  4. 运算符为加号或者减号。
  5. term 为一个整数或者一个表达式。

接着我们来分析一下字符串 "2 + 3 - 1"。

第一个符合语法的子字符串是 "2",符合规则 5 是一个整数。第二个符合语法的是 "2+3",符合规则 3,是一个表达式。最后一个符合的就是整个输入内容 "2 + 3 - 1",同样符合规则 3,是一个表达式。如果输入是 "2 + +" 则不符合任何的语法规则,即输入的内容是无效不可用的。

5.词汇和语法的正式定义  Formal definitions for vocabulary and syntax

词汇通常是用正则表达式来定义。

例如我们刚才定义的语言词汇可以表示为:

整数:0|[1-9][0-9]*

加号:+

减号:-

整数的定义就是用的正则表达式。

语法通常用巴科斯范式(BNF)来定义,我们刚定义的语言语法可以表示为:

表达式 := term 运算符 term

运算符 := 加号 | 减号

term := 整数 | 表达式

我们上面提到如果一种语言可以被常规的解析器解析,该语言的语法是上下文无关的。对于上下文无关语法的一个直观的定义就是可以完全用 BNF 进行表达的语法。而正式的定义可以参阅: http://en.wikipedia.org/wiki/Context-free_grammar

6.解析器种类

解析器有两种基本类型 —— 从上到下解析和从下到上解析。从上到下解析即从高级别语法规则开始尝试匹配,从下到上的解析即从低级别的语法规则逐渐匹配,直到*。

我们来分析上面提到的表达式 "2 + 3 - 1" 在这两种解析方式下如何被解析:

从上到下解析会从高级别的规则开始匹配,会将 "2 + 3" 当作表达式,然后将 "2 + 3 - 1" 当作一个表达式(识别一个表达式需要匹配多个规则,但是是从最顶层的规则开始匹配)。

从下到上解析会扫描输入的内容直到匹配一个规则,然后用匹配的规则替换掉匹配的内容,重复该过程知道内容的最后。局部匹配的表达式放在解析器栈内。

Stack Input
    2 + 3 - 1
term  + 3 - 1
term operation 3 - 1
expression - 1
expression operation 1
expression  

从下到上解析又被称为移位解析,因为内容是不断向右(想象一下有个指针一开始在内容的最开始,然后不停向右移动)逐渐进行匹配。

7.自动生成解析器 Generating parsers automatically

有许多工具可以帮你生成一个解析器,这些工具被统称为解析器生成器。你输入定义的语言文法 —— 词汇表和语法,生成器就会生成一个解析器给你。写一个解析器需要对解析过程有非常深入的了解,而且使解析器达到最优化并不容易,因此解析器生成工具非常有用。

Webkit 使用了两款知名的解析器生成工具 —— Flex 和 Bison,Flex 生成分词器,Bison 生成语法分析器(也许你称他们为 Lex 和 Yacc)。只要把词汇表中的词汇用正则表达式定义,并放在一个文件提交给 Flex 即可,Bison 要求所有语法按照 BNF 格式输入。

HTML 解析器

HTML解析器的工作就是解析 HTML 标签并转换成解析树。

1. HTML 的文法定义 The HTML grammar definition

HTML 的词汇表和语法是由 W3C 组织编撰的,目前版本是 HTML4,HTML5 的标准正在定制中。

2. 不是上下文无关文法 Not a context free grammar

在介绍解析过程的时候已经讲到,语法可以用 BNF 格式定义。

不幸的是所有常规的语法解析器规则都不适用于 HTML (这些常规的语法解析器会被用来解析 CSS 和 JS),因为上下文无关的文法并不能很好的定义 HTML。定义 HTML 的格式为 DTD(Document Type Definition),但它不是一种上下文无关的文法。

HTML 和 XML 的格式很接近,而 XML 解析器有很多。有一种 HTML 的 XML 变种格式 —— XHTML,两者又有什么不同呢?

不同之处在于,HTML 的规则更加宽松,如果你漏掉某个开始或结束标签,HTML 会帮你补上。总的来说,相对于 XML 严格的语法规范来说,HTML 的语法规范非常"温柔"。

宽松的语法使得 HTML 变得非常流行,因为 HTML 可以包容 web 开发者的错误,使用起来更加容易。但正是因为语法宽松,所以确定文法的格式非常困难。总之,解析 HTML 并不容易,不能使用常规的解析器因为其文法不是上下文无关的,也不能用 XML 解析器。

3. HTML 文件类型定义 HTML DTD

HTML 是通过 DTD 格式定义的,这种格式用来定义标准通用置标语言(SGML),其中定义了所有可以使用的元素,元素的属性和层级关系。就像之前说到的,DTD 不是一种上下文无关的文法。

DTD 有几个不同的版本,严格模式版本是唯一完全按照规范来的版本,其他版本则支持旧版本浏览器使用的标签,目的是向后兼容以前的老旧网页内容。目前的严格 DTD 版本地址: http://www.w3.org/TR/html4/strict.dtd

4. DOM

解析树是由 DOM 节点和属性节点组成的,DOM 是 Document Object Model(文档对象模型)的缩写。DOM 是表示 HTML 文档的对象,同时也是 HTML 元素向外提供的接口,提供给 JS 等调用。

树的根节点为 Document 对象。

DOM 节点与 HTML 标签是一一对应的关系,比如:

<html>
  <body>
      <p>
        Hello World
      </p>
      <div><img src="example.png" /></div>
  </body>
</html>

会被转换成下面的 DOM 树:

How browsers work

图8:示例标签转换成的 DOM 树

和 HTML 一样,DOM 的规范也是由 W3C 组织起草的,规范地址:http://www.w3.org/DOM/DOMTR 。DOM 规范是文档操作的通用规范,其中一个版本是针对 HTML 元素的,该版本的地址:http://www.w3.org/TR/2003/REC-DOM-Level-2-HTML-20030109/idl-definitions.html

本文中说的树中包含 DOM 节点,意思是组成这棵树的元素实现了任意的 DOM 接口,至于每个浏览器的具体实现则都用到了各浏览器内部定义的属性。

5. 解析的算法 The parsing algorithm

前面说到,HTML 不能被常规的自上至下或自下至上的解析器解析。原因是:

  1. HTML 语言是语法宽松的语言。
  2. 事实上浏览器对于常见的 HTML 错误有一套容错机制。
  3. 解析过程是可重入的。通常在解析文件过程中,文件的内容是不会变化的。但是 HTML 文件中包含的 JS 代码 "document.write" 可以添加新的内容到文件里,所以实际上解析的过程会修改解析开始时输入的内容。

因为不能使用常规的解析方法解析 HTML,浏览器自定义了一个解析器。

解析的算法在 HTML5 规范中有详细的描述,算法由两个部分组成,分词和树结构。

分词就是进行词汇分析,把内容分解成单词。对于 HTML 来说,单词是指开始标签、结束标签、属性名字、属性值。

分词器识别出单词,传递给树构造器,然后继续识别下一个单词,循环往复直至内容的最后。

How browsers work

图9:HTML 解析流程

6. 分词算法 The tokenization algorithm

分词算法类似一个状态机,输出的内容为一个合法的 HTML 单词。每个状态下都会获取内容的一个或者多个字符,然后根据获取的字符更新下个状态。状态更新会受当前单词的状态和结构树状态的影响,意思就是在不同的状态下相同的字符会导致下个状态不同。分词算法非常复杂,我们通过一个简单的例子来看看算法的大致原理。

例子 —— 对下面的 HTML 进行分词

<html>
  <body>
    Hello World
  </body>
</html>

初始状态是 "Data state",当遇到 "<" 后,状态更改为 "Tag open state"。获取 "a-z" 中的任意字符会生成一个开始标签(Start tag token),而状态更新为 "Tag name state"。状态会一直保持在 "Tag name state" 直到获取到 ">",然后所有 "Tag name state" 状态下获取的字符都会添加到生成的开始标签中。在这个例子中,标签单词为 "html"。

在遇到 ">" 之后,当前获取的单词被发射出去,然后状态重新变为 "Data state"。标签 "<body>" 会以同样的步骤进行处理,至此 "html" 和 "body" 标签都处理完毕而状态回到 "Data state"。接下来获取 "Hello world" 中的 "H" 字符时会生成和发射一个字符单词,重复生成和发射过程直到遇到 "</body>" 中的 "<"。获取 "Hello world" 中的每一个字符都会发射一个字符单词。

让我们看看状态更改为 "Tag open state" 之后,获取 "/" 会生成一个结束标签(end tag token)并且状态更新为 "Tag name state"。同样的,在遇到 ">" 之前会一直保持在状态。遇到 ">" 之后标签被发射出去,状态重新回到 "Data base"。标签 "</html>" 的处理过程也是如此。

How browsers work

图10:示例 HTML 代码的分词过程

7. 树构建算法 Tree construction algorithm

当生成解析器时,文档对象也已经生成。在构建树的过程中,DOM 树表示的文档中的元素会被添加到树上。每个分词器发射的节点都会被树构造器进行处理,对于每个节点,都会有与之相关联的一个 DOM 元素被创建。除了添加节点到 DOM 树之外,还会添加到开放元素的栈里,这个栈是用来纠正未正确闭合的标签的。树构建算法也类似一个状态机,其状态被称为"插入模式(insertion modes)"。

同样以上面的例子来分析树构建的过程:

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

在树构建阶段,用来构建树的内容是分词阶段获取的一系列单词。一开始的模式为 "initial mode",获取 html 单词后变为 "before html mode" 模式,然后在该模式下对单词进行再处理。再处理后会生成 HTMLhtmlElement 元素,并把元素添加到文档对象的根元素。

然后模式变为 "before head",接下来获取到 body 单词,尽管没有获取 head 单词,仍然会生成一个 HTMLHeadElement 并添加到树中。

之后模式依次变为 "in head"、"after head"、"in body",在 "in body" 模式中生成了 HTMLBodyElement 并插入树中。

接下来获取到 "Hello world" 中的所有字符,获取第一个字符时会生成一个 "Text" 节点并插入树中,而其他的字符会添加到该节点。

在获取到 body 的结束标签(end tag token)后,模式更改为 "after body"。紧接着获取 html 的结束标签,模式又变为 "after after body"。当到达文件最末端时,解析停止。

How browsers work

图11:示例 html 代码的树结构

8. 解析完成后

当解析完成后,浏览器会将文档标记为可交互,然后开始执行那些应该在文档解析完成后执行的 JS 代码。而文档的状态更改为 "complete" 并触发 "load" 事件。

分词和树构建算法的全部信息可以参阅 HTML5 规范:http://www.w3.org/TR/html5/syntax.html#html-parser

9. 浏览器容错机制

在 HTML 页面上永远不会出现无效的语法,因为浏览器会对无效内容进行修复。

就拿下面的代码为例:

<html>
  <mytag>
  </mytag>
  <div>
  <p>
  </div>
    Really lousy HTML
  </p>
</html>

这段代码里面有多处地方不符合代码规范("mytag" 不是一个标准的标签,"div" 和 "p" 的嵌套有问题等等),但是浏览器仍然会正确显示内容,所以解析器内部有许多代码是为了纠正 HTML 开发者的错误。

浏览器从一开始就有错误处理的能力,但令人吃惊的是 HTML 规范至今并不包括这方面的内容。就像书签和 后退/前进 按钮一样,只是浏览器在发展过程中总结形成的。有许多不合规范的 HTML 结构代码重复出现在许多网站里,而所有浏览器都会尝试用一致的方式对错误进行修复。

HTML5 规范确实提了一些要求,Webkit 在 HTML 解析器代码的注释里很好地总结了这些要求。

解析器把分词器分好的部分添加到文档中,构成文档树。如果文档的格式是没有问题的,解析器会直接解析。可惜我们经常要处理许多格式有问题的 HTML 文档,所以解析器必须要有容错机制。

我们至少要对一下几种情况进行处理:

  1、有些元素是明确禁止嵌入在某些标签内。

    这种情况我们应该把禁止嵌入的元素及其内部的标签整个取出,添加到外部标签之后。

  2、不允许直接添加元素

    写代码的人可能忘记写外层的标签直接添加了内层标签(或者外部的标签是可选的),比如 HTML、HEAD、BODY、TBODY、TR、TD、LI 。

  3、将块状元素添加到内联元素内部

    闭合内联元素标签到更高一级的块状元素。

  4、如果上方方法都不奏效,就先不添加该元素,直到规则允许添加时再添加,如果无法添加就忽略掉。

让我们看一些 Webkit 容错机制的例子:

1. 使用 </br> 代替 <br>

一些网站使用 </br> 代替 <br>,为了兼容 IE 和 Firefox,Webkit 把 </br> 当做 <br> 处理。

if ( t→isCloseTag(brTag) && m_document→inCompatMode()){

  reportError(MalformedBRError);

  t→beginTag = true;

}

注意,错误处理是在内部进行的,并不会展示在用户面前。

2. 混乱的 table 表格

当一个 table 直接嵌套在另一个 table 里而不是 table 的 td 里时,就像:

<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 元素成为兄弟节点。

3. 嵌套的 form 元素

如果开发者将一个 form 元素放入另一个 form 元素内部,内部的 form 元素会被忽略。

对应的处理代码:

if (!m_currentFormElement){

  m_currentFormElement = new HTMLFormElement(formTag, m_document);

}

4. 标签嵌套层级过多

代码注释中这样说道:

www.liceo.ed.mx 就是个例子,一个网站里面的嵌套标签 <b> 达到1500个。浏览器只允许 20 层同类标签的嵌套,其余会被忽略。

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;

}

5. 放错位置的 html 或者 body 结束标签

代码注释中写道:

支持不完整的 html 文件。

浏览器不会闭合 body 标签,因为有的"愚蠢"网页会在正确位置之前闭合 body 标签,取而代之浏览器使用 end() 方法来结束。

if (t → tagName == htmlTag || t → tagName == bodyTag)

return;

所以 web 开发者们注意了,除非你是要写一个 Webkit 容错机制的例子,否则请书写正确格式的 HTML。

CSS 解析 - CSS parsing

还记得上面提到的解析的概念吗?不同于 HTML,CSS 的文法是上下文无关的,因此可以使用上面提到的常规解析器进行解析。CSS 规范中定义了 CSS 的词汇表和语法 (http://www.w3.org/TR/CSS2/grammar.html)。

让我们看一些例子:

词汇表是用正则表达式定义的:

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" 是 "identifier" 即标示符的简写,比如说类名。"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*] ')' ]
;

一个 ruleset 的实例是下面这样的结构:

div.error , a.error {
color:red;
font-weight:bold;
}

div.error 和 a.error 是选择器,大括号里面的部分是这个 ruleset 实现的效果。ruleset 的结构是由下面的形式定义的:

ruleset
: selector [ ',' S* selector ]*
'{' S* declaration [ ';' S* declaration ]* '}' S*
;

表示 ruleset 包含了一个或多个由逗号加空格(S表示空格)隔开的选择器,一对大括号以及括号里面一个或多个由分号隔开的效果说明。"declaration" 和 "selector" 在 ruleset 之后都有给出 BNF 定义。

Webkit CSS 解析器 - Webkit CSS parser

Webkit 使用 Flex 和 Bison 根据 CSS 的文法自动生成解析器。如果你回想起上面介绍解析器的部分,Bison 生成一个自下而上解析的移位解析器。Firefox 使用的是人工编写的自上而下解析的解析器。在这两种解析器的解析下,CSS 文件被解析为样式表对象,每个对象中包含着许多 CSS 规则。一个 CSS 规则是由选择器和效果描述部分和其他 CSS 文法中定义的部分组成的。

How browsers work

图12:CSS 解析

脚本解析

这部分将会在 JavaScript 章节讨论。

解析脚本和样式表的顺序

脚本 scripts

web 的模式是同步的,开发者希望当解析器遇到<script>标签时可以立即解析和执行脚本代码,但文档在脚本执行完毕前的这段时间里不会被解析。如果脚本是来自外部文件的,还要先从网络请求到资源,这一步同样也是同步进行的,解析会停止直到获取到脚本文件。这种模式使用了很多年,在 HTML4 和 5 的规范中也有说明。开发者可以给<script>标签添加 "defer" 属性,这样标签内的脚本会在整个文档解析完之后再执行。HTML5 添加了一个可选的标记给<script>标签让其可以在另一个线程异步加载和执行。

预解析 Speculative parsing

Webkit 和 Firefox 都在这部分进行了优化。在执行脚本的同时,另一个线程会继续解析文档并找到那些需要从网络请求的资源,将其下载下来。这种方式可以使请求的网络资源并行下载,整体上速度更快。需要注意的是,预解析并不修改 DOM 树,而是留给主解析器来做,它只解析外部的资源,例如外部脚本、样式表、图片等等。

样式表 Style sheets

样式表的模式与脚本不同,从概念上来讲,样式表不会更改 DOM 树的内容,所以没有理由因为样式表而暂停文档的解析。但还是有一个问题,在文档解析的时候脚本获取样式信息,如果对应的样式还没有加载解析的话,脚本会获取到错误的信息,这会引起很多麻烦。有人会觉得这个问题不常遇到,但其实它经常出现。当有样式表在加载和解析时,Firefox 会阻塞脚本代码。而 Webkit 则是在脚本尝试获取确定的样式属性,而这些属性可能受未加载的样式表影响时才阻塞脚本。

渲染树结构 Render tree construction

浏览器在构建 DOM 树的同时也在构建渲染树。渲染树是由可见的元素按照在页面的顺序构成,是文档的可视化表示形式。渲染树的作用是让页面的内容按照正确的顺序进行绘制。

Firefox 把渲染树中的元素称为 "frames",Webkit 则称之为 renderer 或者 渲染对象(render object)。

渲染对象知道如何对其自身和子元素进行布局和绘制。

Webkits 的 RenderObject 类是渲染对象的基础类,定义如下:

class RenderObject {

  virtual void layout();

  virtual void paint(PaintInfo);

  virtual void rect repaintRect();

  Node* node;  // the computed style
  RenderLayer* containgLayer;  //the containing z-index layer
}

CSS2 规范中讲到,渲染对象是用一个与节点的 CSS 盒子模型一致的矩形区域来表示,其中包含了宽度、高度、位置等几何信息。

盒子的类型由节点的样式属性 "display" 决定(参阅样式计算 style computation 部分)。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 中如果一个元素要生成特殊的渲染对象,需要重写 "createRender" 方法,使渲染对象指向包含非几何信息的样式对象。

与 DOM 树关联的渲染树 The render tree relation to the DOM tree

渲染对象与 DOM 元素是相对应的,但不是一一对应的关系,不可见的 DOM 元素不会被插入渲染树,例如 "head" 元素。同样的,display 属性设置为 "none" 的元素也不会出现在渲染树上(display 为 "hidden" 的元素会出现在树中)。

有些 DOM 元素会对应多个渲染树中的对象,这些元素通常因为有复杂的结构不能仅仅用一个的矩形表示。例如,"select" 元素就对应了三个渲染对象 —— 一个表示元素占用的区域,一个表示下拉列表的盒子模型,一个表示按钮。同样,如果文本长度超过容器长度,不能在一行展示完而断成多行的话,每一行都会添加新的渲染对象。

另外,不规范的 HTML 代码也会对应多个渲染对象。根据 CSS 规范,一个 inline 元素里要么只包含块状元素,要么只包含内联元素。如果同时包含了两种元素,则会生成匿名的块状渲染对象包裹在内联元素外。

有的渲染对象对应的 DOM 节点与其他节点在树中位置并不相同。流动的或者绝对定位的元素脱离了文档流,他们会被放在树中另外的位置,并映射到实际的框架结构,而他们原本应该在的地方会用占位符标示。

How browsers work

图13:渲染树和对应的 DOM 树。"Viewport" 是最初包含块,在 Webkit 里最初包含块是 "RenderView"。

渲染树构建流程 The flow of constructing  the tree

在 Firefox 中,过程表述为注册一个监听器来监听 DOM 更新,DOM 更新后,"FrameConstuctor" 会决定样式(参阅样式计算 style computation) 并生成一个渲染对象。

在 Webkit 中,决定样式并生成渲染对象的过程叫做 "attachment"。每个 DOM 节点都有一个 "attach" 方法,"attachment" 过程是同步的,节点在被插入 DOM 树时会调用其 "attach" 方法添加到渲染树中。

html 和 body 标签会被处理成渲染树的根渲染对象。根节点就是 CSS 规范中定义的包含块(containing block) —— 最顶部的包含了所有其他渲染对象的块。包含块的大小等于浏览器窗口区域,Firefox 把包含块叫做 ViewPortFrame,Webkit 称之为 RenderView。整个文档指向的就是根渲染对象,渲染树的其余部分会随着 DOM 树的构建同时构建完成。

参阅 CSS2 中该主题内容:http://www.w3.org/TR/CSS21/intro.html#processing-model

样式计算 Style Computation

构建渲染树需要计算每个渲染对象的可视化属性,这可以通过计算样式属性得到的。

元素的样式包括样式表中的样式、内联样式、HTML 中定义的样式属性(例如 "bgcolor" 属性),后者会被转换成对应的 CSS 样式属性。

样式表的来源包括浏览器默认的样式表、开发者的样式表、用户的样式表——浏览器使用者提供的样式表(浏览器允许你定义自己喜欢的页面样式。例如在 Firefox 中,你可以把自己定义的样式表放在 "Firefox Profile" 文件夹中使其生效。)。

样式计算有几个困难的地方:

  1. 样式数据非常庞大,包含了很多样式属性,可能会引起内存问题。
  2. 如果不进行优化,为每个元素寻找匹配的样式规则会造成性能问题。为每一个元素寻找匹配的规则时都遍历整个规则列表是非常繁重的任务。当选择器结构很复杂时,可能要试很多次才能寻找到匹配的元素。例如下面的选择器:
    div div div div {
    ......
    }

    选择器表示那些是3个 div 的子孙元素的 div。如果你要确定一个 "<div>" 是否匹配,你要按照该元素在树中的路径遍历到最顶端,如果该元素只有两个 div 祖先元素,就不与之相匹配,必须找另外的 div 元素重新遍历其在树中的路径。

  3. 应用的规则涉及到复杂的级联,需要从多个匹配的规则中选出权重最高的。

让我来看看浏览器是如何处理这些问题的:

共享样式信息 Sharing style data

Webkit 的渲染节点引用了样式对象(渲染样式),这些样式对象在某些条件下可以被节点共享,例如当节点是兄弟节点或者是表兄弟节点时,并且符合:

  1. 元素必须有相同的鼠标状态(不能一个在 :hover 状态而其他不在)
  2. 元素不能有 id
  3. 标签名称要匹配
  4. 类名要匹配
  5. 对应的属性要一致
  6. 链接状态要匹配
  7. 焦点状态要匹配
  8. 不能有元素受到属性选择器影响,即不能有包含属性选择器的选择器与元素相匹配
  9. 元素中不能有内联样式
  10. 不能使用兄弟选择器,WebCore 遇到兄弟选择器时会简单地抛出一个全局转换,并在展示的时候使整个文档的样式共享失效,包括 + 选择器和 :first-child、:last-child 这样的选择器。

Firefox 规则树 Firefox rule tree

Firefox 为了简化样式计算另建了两颗树——规则树和样式上下文树。Webkit 也有样式对象但他们不储存在样式上下文树这样的树中,只是有相应对的 DOM 节点指向他们。

How browsers work

图14:Firefox 样式上下文树

样式上下文里的是最终值,这些值的计算是通过以正确的顺序应用匹配的规则并完成运算,将值从逻辑值变为具体的值。例如:如果逻辑值是屏幕的百分比,会被计算转换成绝对单位。

规则树的的使用非常巧妙,它能够在节点之间共享这些值从而避免了重复运算,而且也节约了空间。所有匹配的规则储存在一颗树中,树中越靠近底部的节点优先级越高。树中包含了所有匹配规则的路径,规则树并不是一开始就计算每个节点,而是等到一个节点的样式需要被计算时才把路径添加到树中。

整个构想就是把树中路径看作是词典中的单词。看看下面这颗计算后得到的规则树:

How browsers work

图15:规则树

假设我们需要为内容树中另一个元素匹配规则,找到的匹配规则(按照正确顺序)是B - E - I。而规则树中已经有同样的路径,因为已经计算了一条路径为 A - B - E - I - L,那么就不用从头再把所有工作做一遍。

让我们看看规则树如何保存。

分解为结构体 Division into structs

一个样式上下文里包含的所有样式被分解为不同的结构体,每一个结构体包含了一个具体的类别的样式信息,例如边框或者字体颜色(为了便于理解,将以下的结构体称为样式属性)。样式属性分为可继承的与不可继承两类,可继承的属性如果没有定义的话会从父元素那里继承,不可继承的属性(又叫"重置"属性)如果的没有定义则使用默认值。

规则树里面保存了所有样式属性(包括计算后的最终值),底部节点如果没有某一个属性,就可以使用上一层节点内缓存的属性。

利用规则树计算样式上下文 Computing the style contexts using the rule tree

当计算一个具体元素的样式上下文时,我们首先计算出规则树中的一条路径或者使用已经存在的路径,之后用路径中的规则填充元素样式上下文中的属性。我们会从路径的底部节点开始,即优先级最高的(通常是最特殊最详细的选择器),向上遍历规则树直到所有的样式属性都获得值。如果底部的规则树节点没有一个样式是需要的,我们可以沿着树向上直到找到一个包含需要的样式的节点,最好的情况是这个节点所有样式刚好是我们需要的,整个节点的样式都被共享,这样会节省最终值的计算时间和内存。如果这个节点中只有部分样式是我们要的,就沿着树继续向上直到所有样式都找到对应的值。

当我们没有找到需要的样式时,如果该样式是可继承的,我们就指向上下文树中父元素的样式,同样实现了样式的共享。但假如该属性是不可继承型的,就使用默认的值。

如果规则树里靠近底部的节点被添加了值,我们需要做一些计算将其转化为具体的实际的值(例如把百分比转换成具体单位的值),然后保存节点的值以便孩子节点可以使用。

如果一个元素有兄弟节点指向同样的规则树节点,那么整个样式上下文可以在这些元素中间共享。

让我们看一个例子:假设我们有以下 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>

以及下面的样式规则:

  1. div {margin:5px;color:black}
  2. .err {color:red}
  3. .big {margin-top:3px}
  4. div span {margin-bottom:4px}
  5. #div1 {color:blue}
  6. #div 2 {color:green}

为了简化过程我们假设只需要填充两个样式属性:color(字体颜色)和 margin(外边距)。color 就只有一个属性成员——颜色,margin 包含了四个方向的外边距。

最终规则树会看起来像下面这样(节点名是对应的上面的样式规则序号):

How browsers work

图16:规则树

上下文树会是下面这样(节点名是 DOM 元素指向的规则树中的节点):

How browsers work

图17:上下文树

假设我们解析 HTML 代码到第二个 <div> 标签,我们需要为其生成一个样式上下文并把该元素的样式填充进去。

通过比对会发现符合该节点的样式有1、2、6。从图16中可以看到 1-2-6 这条路径已经存在,可以直接使用。我们只需在上下文树中新建一个样式上下文节点,并指向规则树中的 F 节点即可。

之后我们要给新的样式上下文填充属性值,先从 margin 属性开始。因为规则树最底层的 F 节点没有包含 margin 属性,我们继续向上找直到最顶端的节点 B 找到了一个 margin 属性,我们直接拿来使用。节点 B 是之前向上下文树插入新节点时(即第一个 div 元素对应的样式上下文节点 Div:D)添加到规则树当中的。

而 color 属性 F 节点中已经包含了,所以就直接使用节点中的值,并计算出最终值(例如把 string 转换成 RGB 等等)储存在该节点中。

对第二个 <span> 元素需要做的更简单,匹配了所有的样式之后得出结论,其上下文节点应该指向规则树中的节点 G,和第一个 <span> 元素一样。因为已经有兄弟元素指向同一个规则树节点了,所以第二个 <span> 元素可以直接指向前一个 <span> 元素的上下文节点与之共享整个样式上下文。

对于那些含有从父元素继承的属性值的上下文节点,值是储存在上下文树中的(color 属性是可继承的,但是 Firefox 将其当做不可继承的属性并将值储存在规则树中)。

举个例子,我们在一段文本上添加了字体的样式:

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

那么上下文树中 p 元素的孩子节点会继承该样式,前提是没有对孩子节点单独设置字体样式。

在 Webkit 中,并没有规则树,匹配的样式声明要遍历 4 次。首先是非 important 高优先级属性(其他属性要依赖的属性——例如 display 应当首先被应用),其次是 important 高优先级属性,接下来非 important 一般优先级属性,最后 important 一般优先级属性。这样就使得涉及到级联的样式属性会按照正确的优先级应用,最后应用的那个是优先级最高的。

总结一下,共享样式对象(其内部的全部或者部分样式)解决了问题 1 和 3。Firefox 规则树同样可以按照正确的级联应用属性值。

对样式进行处理以简化匹配 Manipulating the rules for an easy match

样式规则的来源有以下几个:

  • 外部样式表或者<style>标签内的 CSS
p { color: blue; }
  • 内联样式
<p style=" color:blue; "></p>
  • HTML 属性(会被转换成对应的 CSS 样式)
<p bgcolor=" blue "></p>

后面两种样式很容易匹配,因为内联样式是元素自己的属性,而 HTML 属性能使用元素当做 key 进行映射。

就像之前说到的三个问题中的问题2,CSS 规则的匹配很麻烦。为了解决这个问题,会对规则进行处理,以便匹配过程更简单。

在解析完样式表之后,根据选择器的不同,规则被添加进一个映射表。映射表分为按 id、类名、标签名和不属于前三类的一般映射。如果一条规则的选择器是 id 选择器,那么这条规则就会被添加到 id 映射表内,如果是类名则被添加至类名映射表,以此类推。

这一步操作使得规则匹配简化了许多,因为我们没有必要查看每个规则声明,我们可以从映射表中获取一个元素相关的规则,从而优化了 95% 以上的规则,以至于在匹配过程中甚至不用考虑他们。

让我们看一个例子:

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" 的 key,对应的值是 "p.error" 内部定义的规则。div 元素的相关样式规则在 id 映射表和标签映射表中,我们只用找到表中 div 元素包含的 key 即可。

如果有一条 div 的规则是:

table div { margin:5px; }

那么这条规则也会从标签映射表中取出来,因为表中的 key 是选择器中最右边的那个,但是这条规则并不会匹配我们 HTML 代码里的 div 元素,因为它没有一个表格的祖先元素。

Webkit 和 Firefox 都会进行这一步处理。

按照正确的层叠顺序应用规则 Applying the rules in the correct cascade order

每一个视觉属性都有对应的样式对象的属性,如果某个属性在所有匹配的规则里面都没有定义,那么某些可继承的属性会从父元素样式对象那里继承,另外的不可继承属性则有默认值。

问题是如果一个元素的某个属性在几个规则中被定义了不同的值时要如何处理,层叠的引进就是为了解决这个问题。

样式表层叠顺序 Style sheet cascade order

同一个样式属性的声明可以出现在多个样式表中,或者在同一个样式表中出现多次。这意味着应用样式规则的顺序是非常重要的,我们称之为层叠。根据 CSS2 规范,层叠顺序为(从低到高):

  1. 浏览器声明的样式
  2. 用户声明的一般属性
  3. 开发者声明的一般属性
  4. 开发者声明的 important 属性
  5. 用户声明的 important 属性

浏览器的默认样式级别最低,用户声明的带有 important 的属性可以覆盖开发者声明的样式。

相同级别的声明会根据特殊性进行排序,如果特殊性也相同则按照声明的顺序取最后声明的样式。HTML 可视属性会被转换成对应的 CSS 声明,会被当做开发者的一般声明处理。

特殊性 Specifity

CSS2 规范对于样式规则的特殊性作了以下定义:

  • 如果是内联样式特殊性加 1,如果不是内联样式不加(= a)
  • 计算选择器中 ID 个数(= b)
  • 计算选择器中属性选择器和伪类选择器的个数(= c)
  • 计算选择器中标签选择器和伪元素选择器的个数(= d)

将四个数字连接起来 a-b-c-d 之后便得到了一条规则的特殊性。(为了简便,我们这里不讨论进制问题,abcd之间不涉及进位,所以可能会出现 0,17,1,5 这样的排列)。

一些特殊性计算的例子:

 *             {}  /* 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 */

给规则排序 Sorting the rules

找到某元素匹配的所有规则之后,这些规则会按照联级排好序。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;
}

渐进的过程 Gradual process

Webkit 使用一个标记标示所有的样式表(包括 @imports 引进的)是否被加载完毕。如果样式还没有加载完,但是拥有样式的元素被调用了,那么元素在文档中就会被标记出来,等到样式表加载完毕之后对元素在计算一遍。

布局 Layout

当渲染对象生成并添加到渲染树时,还没有位置和尺寸信息,计算这些值的过程叫做布局(layout)或者回流(reflow)(下面再出现该用语时均使用"回流")。

HTML 使用的布局模型是一个文档流,大多数情况下只需要一遍就可以完成几何结构的计算。文档流中靠后出现的元素的几何结构并不会影响到靠前出现的元素,所以布局可以按照从左到右,从上到下的顺序处理整个文档。但是也有例外的情况——例如,HTML 表格元素,需要多次计算才能确定表格最终结构。

坐标系统是相对于根节点进行计算的,坐标是相对于根元素顶部和左侧的偏移量。

回流是一个递归的过程,从渲染树的根开始即 HTML document 元素,遍历所有的节点层级,计算出需要进行布局的元素的几何结构。

根节点的尺寸是浏览器窗口的可视部分,位置坐标为0,0。

所有的渲染对象都有一个 "layout" 或者 "reflow" 方法,渲染对象在其孩子节点需要回流时会调用 layout 方法。

页面重写标志位系统 Dirty bit system

浏览器用页面重写标志位系统来防止一个小变化引起的全局回流。一个新添加的或者发生了变化的渲染对象会把自身和孩子节点标记为 "dirty",表示其需要回流。

有两种标记——"dirty" 和 "children are dirty",后者表示一个渲染对象的孩子节点中至少有一个需要回流,尽管这个渲染对象本身不需要。

全局增量式回流 Global and incremental layout

当整个渲染树都进行了回流——称为全局回流,当出现以下情况时可能发生全局回流:

  1. 一个全局样式影响了所有渲染对象,例如更改了字体大小。
  2. 浏览器窗口大小更改

回流也可是增量式的,只有被标记了 "dirty" 的渲染对象会回流(可能同时会引起一些额外的回流操作)。

当渲染对象被标记为 "dirty" 就会触发(异步地)增量式回流,比如从网络获取到新的内容并添加到 DOM 树之后,新的渲染对象添加到渲染树时就会触发。

How browsers work

图18:增量式回流 - 只有标记为 "dirty" 的渲染对象和孩子节点会进行回流

异步和同步回流 Asynchronous and Synchronous layout

增量式回流是异步进行的,Firefox 会把增量式回流的命令添加到队列中,并由一个调度程序分批次执行。Webkit 也有一个计时器来执行增量式的回流——遍历整个树并使 "dirty" 的渲染对象回流。

脚本获取样式信息时,例如 "offsightHeight" 会同步触发增量式回流。

全局回流经常是同步触发。

有些回流会作为初始布局完成之后的回调触发,例如滚动条发生了滚动。

优化 Optimizations

当回流是由于窗口大小改变或者是渲染对象位置改变(不是尺寸改变)引起时,渲染对象的大小不必重新计算。

有时只是渲染树的子树被修改,回流不会从根节点开始,比如一个文本区域内的文本发生改变,改变只是局部的并未影响周围的元素。(否则每次按下键盘按钮都会触发一次从根节点开始的回流)

回流的过程 The layout process

回流的模式通常是:

  1. 父渲染对象设置自己的宽度。
  2. 父亲节点检查孩子节点:
    1. 设定孩子节点的位置(即设定 X 和 y 坐标)
    2. 如果需要使孩子节点回流(当孩子节点被标记为"dirty"或者是全局回流或者其他原因)—— 这样会计算孩子节点的高度
  3. 父节点的高度设置为所有孩子节点的高度加上内外边距的高度,这个高度也会被父节点的父节点使用。
  4. 将 "dirty" 标记设置为 false

Firefox 使用一个 "状态"("state")对象(nsHTMLReflowState)作为回流的参数,状态中包含了父节点的宽度。

Firefox 回流的输出是一个"度量"("metrics")对象(nsHTMLReflowMetrics),其中包含渲染对象的高度。

宽度的计算 Width calculation

渲染对象的宽度是参照包含块的宽度,样式中的 width 属性、外边距和边框来计算的。

例如下面这个 div 的宽度:

<div style="width:30%"/>

在 Webkit 中 div 的宽度计算过程是:

  • 容器的宽度是容器的有效宽度和 0 之间取较大值,在这个例子中有效宽度等于内容宽度,计算如下:

    clientWidth() - paddingLeft() - paddingRight()

    clientWidth 和 clientHeight 表示内部的宽高除去边框和滚动条后的长度。

  • 元素的宽度由 width 样式属性确定,会按照容易宽度的百分比转换成绝对单位。
  • 添加水平边框和内边距。

目前为止,"最佳宽度值"计算完毕,接下来计算最大宽度和最小宽度。

如果"最佳宽度值"比样式表中设置的最大宽度宽则使用最大宽度值,如果"最佳宽度值"比最小宽度值还小则使用最小宽度值。

宽度设置后会被储存,当元素本身没有变化但产生了回流时可以直接使用。

换行 Line breaking

当一个渲染对象在回流的时候需要换行时,它会停止回流并告诉父节点需要换行,父节点会生成新的渲染对象并让他们重新回流。

绘制 Painting

在绘制阶段,会遍历渲染树并调用渲染对象的 "paint" 方法将内容展示在屏幕上。绘制过程使用 UI 基础组件,更多内容参阅 UI 章节。

全局和增量式 Global and Incremental

就像回流一样,绘制也分为全局(绘制整个树)和增量式的。在增量式绘制中,渲染对象会以不影响整棵树的方式使矩形框失效,这样操作系统把对象当做 "dirty" 区域并进行绘制,而且操作系统会非常巧妙地把几个区域合并成一个。在 Chrome 中,这个过程更复杂,因为处理渲染对象的进程和主进程不是同一个进程。Chrome 会在一定程度上模仿操作系统的行为,监听渲染对象的变化并将信息派给渲染树根节点,然后遍历渲染树找到相关的渲染对象,重新绘制该对象(通常还会重新绘制其子节点)。

绘制顺序 The painting order

CSS2 定义了绘制顺序:http://www.w3.org/TR/CSS21/zindex.html

绘制的顺序和元素被放入栈上下文(stacking contexts)的顺序一样,这个顺序会影响绘制因为栈是从后向前进行绘制的。一个渲染对象块的入栈顺序是:

  1. 背景颜色
  2. 背景图片
  3. 边框
  4. 孩子节点
  5. 轮廓

Firefox 显示列表 Firefox display list

Firefox 会检查渲染树并为绘制完成的矩形生成一个列表,列表里包含了矩形对应的渲染对象,并将其按照正确的绘制顺序排列(渲染对象的背景、边框等等)。

用这种方式,重新绘制时只需遍历一遍渲染树而不用遍历多次——一次重绘背景色,然后再一次重绘图片,然后重绘边框等等。

Firefox 通过不添加会被藏起来的元素来优化这一过程,例如在某些不透明元素下方的元素。

Webkit 矩形储存 Webkit rectangle storage

在绘制之前,Webkit 把之前的矩形保存成位图,然后只绘制新矩形与旧矩形之间的增量。

动态变化 Dynamic changes

浏览器尽量在处理变化时做最少的工作,所以一个元素颜色发生变化只会造成该元素的重绘。元素位置的变化会引起元素自身、孩子元素甚至可能包括兄弟元素重绘。添加一个 DOM 节点会引起该节点的回流和重绘。大多数变化,比如增大了 html 的字体尺寸,会使缓存无效,使得整棵树回流和重绘。

渲染引擎的线程 The rendering engine's threads

渲染引擎是单线程的,几乎所有的工作都是单线程进行的,除了网络操作。在 Firefox 和 Safari 中渲染引擎是浏览器的主要线程,在 Chrome 中渲染引擎则是 tab 标签页的主要线程。

网络操作可以在几个线程中平行执行,线程数量是有限制的,通常在 2 - 6 个(例如 Firefox 3 中有6个)。

事件环 Event loop

浏览器主线程是一个事件环,这个环是无限大的以保持线程一直运行,它等待着事件(比如回流和绘制事件)发生并处理掉。Firefox 中的事件环代码:

while (!mExiting)
NS_ProcessNextEvent(thread);

CSS2 可视模型 CSS2 visual model

画布 The canvas

根据 CSS2 规范,画布指 "格式化的结构进行渲染的地方",也就是浏览器绘制内容的地方。

画布在各个维度上是无限延伸的空间,浏览器将视窗的大小作为画布的初始宽度。

根据  http://www.w3.org/TR/CSS2/zindex.html 的定义,如果一个画布包含在另一个画布中的话则被包含的画布背景为透明的,否则浏览器会给画布一个默认的颜色。

CSS 盒模型 CSS Box model

CSS 盒模型描述了文档树中为元素生成,并按照可视化格式模型进行布局的矩形。

每个盒子都有一个内容区域,以及可选的内边距、边框、外边距区域。

How browsers work

图19:CSS2 盒模型

每个节点都会生成 0 到 n 个这样的盒子。

元素的 "display" 属性会决定生成哪一种盒子。例如:

block - 生成一个 block 盒子

inline - 生成一个或多个 inline 盒子

none - 不生成盒子

该属性默认值为 inline,但是浏览器对默认值进行了调整,比如 "div" 元素的 "display" 默认值为 block。

你可以找到更多的默认样式表例子在:  http://www.w3.org/TR/CSS2/sample.html

定位方案 Positioning scheme

一共有三种定位方案:

  1. Normal - 元素会按照在文档当中的位置定位,表示元素在渲染树中对应的位置和在 DOM 树中的位置相同,并且会按照盒模型的类型以及大小进行布局。
  2. Float - 元素先按照正常文档流中的元素进行定位,然后尽量向左或者右移动。
  3. Absolute - 元素在渲染树中的位置和 DOM 树中的位置不一样。

定位方案是按照 "position" 和 "float" 的值决定的。

  • 值为 static 和 relative 时,会按照 nomal 方案定位
  • 值为 absolute 和 fixed 时,会按照 absolute 方案定位

当 position 属性没有定义值时,默认为 static,使用 normal 方案定位元素。如果是其他定位方案,开发者可以定义元素的位置通过设置 top、bottom、left、right。

盒子的布局方式有以下几个因素决定:

  • 盒子类型 (box type)
  • 盒子大小 (box dimensions)
  • 定位方案 (Positioning scheme)
  • 扩展信息——比如图片尺寸和屏幕尺寸

盒子类型 Box types

块状盒子(block box):盒子形成一个块,在浏览器窗口中有自己的块。

How browsers work

图20:块状盒子

内联盒子(inline box):盒子没有自己的块,而是在一个包含块内。

How browsers work

图21:内联盒子

块状盒子在竖直方向一个接一个排列,内联盒子则是在水平方向上排列。

How browsers work

图22:块状和内联盒子格式

内联盒子被放在行或者 "line boxes" 中,每一行的高度至少与该行中最高的盒子一样高。当盒子以基线为准对齐时,元素的底部会和另一个盒子底部之外的某一点对齐。如果容器的宽度不够,内联元素会放在多个行里,这种情况在一个段落里面经常出现。

How browsers work

图23:多行

定位 Positioning

相对 Relative

相对定位(relative positioning) - 正常定位,然后按照偏移量移动。

How browsers work

图24:相对定位

浮动 Floats

一个浮动的盒子会移动到行内的左侧或者右侧,有趣的是其他的盒子会围绕着它流动。HTML 代码:

<p>
<img style="float:right" src="data:images/image.gif" width="100" height="100">Lorem ipsum dolor sit amet, consectetuer...
</p>

的效果为:

How browsers work

图25:浮动

绝对定位和固定定位 Absolute and fixed

这两种定位的元素会脱离正常的文档流,是相对于容器来定位。而对于固定定位来说,容器就是浏览器的视窗。

How browsers work

图26:固定定位

注意——固定定位的盒子不会移动,就算是文档滚动也不移动。

分层展示 Layered representation

由 CSS 的 z-index 属性指定,表示盒子的第三个维度,在 z 轴上的位置。

盒子被分解为一个栈(栈上下文),每个栈中最底部的元素先绘制,最顶层的元素更靠近用户。如果发生重叠,后面绘制的元素会遮盖掉前面的。

栈的排序是按照 z-index 属性值排列的,带有 z-index 属性的盒子会形成一个本地栈,浏览器窗口有一个外部栈。

例如:

<STYLE type="text/css">
div {
position: absolute;
left: 2in;
top: 2in;
}
</STYLE> <p> 
<!--- 原文中此处两行代码反了 --->
<DIV style="z-index: 1; background-color:green; width: 2in; height: 2in;"></DIV>
<DIV style="z-index: 3; background-color:red; width: 1in; height: 1in; "></DIV>
</p>

结果为:

How browsers work

图27:元素按 z-index 值排列

尽管在文档中绿色的 div 在红色的 div 前面出现,并且应该先于红色 div 绘制,但是红色的 div 有更高的 z-index 值,所以红色的 div 在栈里面更靠顶部,更靠近用户。

参考资源

  1. Browser architecture
    1. Grosskurth, Alan. A Reference Architecture for Web Browsers. http://grosskurth.ca/papers/browser-refarch.pdf.
  2. Parsing
    1. Aho, Sethi, Ullman, Compilers: Principles, Techniques, and Tools (aka the "Dragon book"), Addison-Wesley, 1986
    2. Rick Jelliffe. The Bold and the Beautiful: two new drafts for HTML 5. http://broadcast.oreilly.com/2009/05/the-bold-and-the-beautiful-two.html.
  3. Firefox
    1. L. David Baron, Faster HTML and CSS: Layout Engine Internals for Web Developers. http://dbaron.org/talks/2008-11-12-faster-html-and-css/slide-6.xhtml.
    2. L. David Baron, Faster HTML and CSS: Layout Engine Internals for Web Developers(Google tech talk video). http://www.youtube.com/watch?v=a2_6bGNZ7bA.
    3. L. David Baron, Mozilla's Layout Engine. http://www.mozilla.org/newlayout/doc/layout-2006-07-12/slide-6.xhtml.
    4. L. David Baron, Mozilla Style System Documentation. http://www.mozilla.org/newlayout/doc/style-system.html.
    5. Chris Waterson, Notes on HTML Reflow. http://www.mozilla.org/newlayout/doc/reflow.html.
    6. Chris Waterson, Gecko Overview. http://www.mozilla.org/newlayout/doc/gecko-overview.htm.
    7. Alexander Larsson, The life of an HTML HTTP request. https://developer.mozilla.org/en/The_life_of_an_HTML_HTTP_request.
  4. Webkit
    1. David Hyatt, Implementing CSS(part 1). http://weblogs.mozillazine.org/hyatt/archives/cat_safari.html.
    2. David Hyatt, An Overview of WebCore. http://weblogs.mozillazine.org/hyatt/WebCore/chapter2.html.
    3. David Hyatt, WebCore Rendering. http://webkit.org/blog/114/.
    4. David Hyatt, The FOUC Problem. http://webkit.org/blog/66/the-fouc-problem/.
  5. W3C Specifications
    1. HTML 4.01 Specification. http://www.w3.org/TR/html4/.
    2. HTML5 Specification. http://dev.w3.org/html5/spec/Overview.html.
    3. Cascading Style Sheets Level 2 Revision 1 (CSS 2.1) Specification. http://www.w3.org/TR/CSS2/.
  6. Browsers build instructions
    1. Firefox. https://developer.mozilla.org/en/Build_Documentation
    2. Webkit. http://webkit.org/building/build.html