剖析当代浏览器工作原理
Tali Garsiel (作者) – Developer, Incapsula,
Paul Irish (编辑) – Developer Relations, Google
August 16, 2011
Wu Min Qi(中文翻译)- Developer,IBM
原文链接:http://www.html5rocks.com/en/tutorials/internals/howbrowserswork/
前言
这部关于WebKit和Gecko内部机制的综合性文献是以色列开发者Tali Garsiel大量研究的成果。在过去的数年中,她回顾了所有有关浏览器内部机制的公开资料,并花费大量时间阅读浏览器源代码。她写道:
在IE雄踞90%浏览器市场的时代,人们只能将浏览器视作一个黑盒而无从考察其内部原理,但现在不同了,开源浏览器已经占有超过半数的市场份额,终于是时候可以一探浏览器内部究竟,看一看,在那几百万行的c++代码下面,浏览器,究竟是如何运行的…
Tali将她的研究发布在个人网站,但是,我们相信,这部文献所包含的信息值得向更广大的读者群推荐。所以我们将其整理并重新在这里[译者:html5rocks.com]予以发布。
对于web开发者来说,了解浏览器的内部运行机制有助于做出更好的技术选择并理解那些隐藏在开发最佳实践背后的依据。这是一部超长的文档,我们建议你花些时间,深入挖掘,我们保证你的付出会得到回报。
Paul Irish, Chrome Developer Relations
浏览器也许是当前最被广泛使用的软件。在这部文献中,我将为你解释,从你在地址栏敲下google.com开始,直到页面完整显示于屏幕的整个过程中,浏览器内部是如何工作的。
Contents [hide]
当前市场上的五大主流浏览器,分别是 – IE, FireFox, Safari, Chrome和Opera。我将在文中使用开源浏览器 – Firefox, Chrome和Safari(部分开源)来举例以配合阐述。根据StatCounter浏览器统计数据,当前(2011年8月), Firefox,Safari和Chrome的联合市场占有率将近60%。开源浏览器已经成为浏览器产业的中坚力量。
浏览器最主要的功能,是将用户请求的web资源,从服务器端取来,展示在用户屏幕之上。这里所说的资源,通常指代HTML文档,PDF文档,图片,亦或其它。资源的位置由URI(统一资源标识符)来标识。
浏览器解释并显示HTML文档的方式是由HTML和CSS的规范指定的。这些规范由W3C组织负责维护,它是web标准的维护组织。
长期以来,各浏览器厂商对于规范的遵守并不乐观(同时还在各自开发各自的扩展),给web开发者带来了严重的兼容性问题。
各浏览器的用户界面存在很多相似之处。比如:
有趣的是,浏览器的用户界面并没有被任何形式的规范所指定过,它是多年来的最佳实践自然进化的结果,也是浏览器之间相互模仿的结果。
正在评审中的HTML5规范,虽然也未指定浏览器所必须包含的用户交互模块,但也列出了一些基本元素,例如,地址栏,状态栏和工具栏。当然,浏览器厂商也完全可以开发出一些独有的功能模块,比如Firefox的下载管理模块。
浏览器的主要模块包括以下这些:
值得指出的是,Chrome,和其它浏览器不同,每个标签页都一一对应一个渲染引擎,每个标签页都是一个独立的进程。
渲染引擎的职责是,嗯,没错,渲染,就是在浏览器屏幕上显示请求返回的内容。
渲染引擎可以原生显示HTML,XML文档,以及图片。通过安装插件(或者是浏览器扩展),它可以显示其它类型的内容;比如通过pdf浏览器插件显示pdf文档。在这一节中,我们只会关注最核心的用例:显示应用CSS样式后的HTML和图片。
本文所涉及的浏览器 – Firefox, Chrome 和 Safari构建在两大浏览器渲染引擎之上。Firefox使用Gecko – Mozilla自家的渲染引擎。Safari和Chrome都选择WebKit.
Webkit是一个开源的渲染引擎,它起初是Linux平台上的浏览器渲染引擎[译注:KHTML],随后Apple公司在其上修改衍生出一个新的分支成为Webkit,并增加了Mac和Windows的支持。访问webkit.org可以获得更多的相关信息。
浏览器渲染引擎会从网络层获得请求文档的内容开始,内容的大小一般限制在8000个chunk以内。(译注:参见HTTP Spec, chunked mode)
自网络层获得内容之后,就进入了渲染引擎的基本流程:
渲染引擎解析HTML文档,并将各标签逐个转化成为”content tree”上的一个个DOM节点。同时它也会解析样式信息,包括外部CSS以及HTML文件内部的样式。这些带着视觉指令的样式信息被用来创建另一棵树 – render tree。
Render tree由一个个包含视觉属性比如颜色尺寸等的矩形组成。这些矩形的位置顺序就是它们在屏幕上被显示出来的顺序。
Render tree构建完毕之后,进入”布局”处理阶段,就是给每个节点一个精确的出现在屏幕上的坐标位置。下一阶段是绘制 – 引擎会遍历整个render tree,由UI底层模块将每个结点绘制出来。
需要着重指出的是,这是一个渐进的过程。为达到更好的用户体验,渲染引擎会尝试尽快将内容显示出来。它不会等到整个HTML文档被解析完毕之后才开始构建和布局render tree.在不停接收和处理从网络层传输来内容的同时,引擎就已经开始将之前的部分内容解析并显示出来。
从图3和图4不难看出,虽然Webkit和Gecko使用的术语略有区别,但是整体的流程大致相同。
Gecko把经由视觉样式化的元素组成的树叫做”Frame tree”.每个元素都是一个帧(frame)。Webkit使用的术语是”Render Tree”,每个元素是一个”渲染对象(Render Objects)”。Webkit把放置这些元素的过程称为”布局(layout)”,而Gecko称之为”reflow”。Webkit把连接Dom结点和视觉信息从而创建render tree的过程称为”attachment”[译注:Gecko称之为"Frame Constructor"]。还有一个细微的,对整体意义影响不大的差别是在HTML和DOM Tree之间,Gecko增加了额外的一个称之为”Content Sink”的模块,这是一个生成DOM元素的工厂模块。接下来我们会逐一论述流程中的每一部分:
解析是渲染引擎流程之中相当重要的一环节,我们将多置些笔墨在此。首先,来介绍一下解析。
解析文档,目地是为了将文档转化成为有意义的结构,能被程序所理解和使用的结构。解析的输出通常是一棵节点树,代表文档的结构,被称作解析树,或者句法树。
举例 – 解析 2 + 3 -1 这个表达式,返回下面这棵树:
解析是建立于文档所遵循的句法规则(语言本身或者它的书写格式)之上的。所有可以被解析的格式,必须对应确定的文法(由词汇和句法规则构成)。这被成为上下文无关文法。人类语言并不是一种符合上下文无关文法的语言,因此,它无法用常规的解析技术进行解析。
解析的过程可以分成两个子进程 – 词法分析和句法分析。
词法分析是将输入的内容分割成为一堆标记符的过程。标记符就是语言中的词汇,是构成内容的单位。在人类语言中,它相当于语言字典中的单词。
句法分析是应用语言的句法规则的过程。
解析的整个过程就是依次执行下面两个子进程 – 词法分析(有时也叫做分词),将输入分解成一个个单词,然后解析器应用语言的句法规则把文档结构化,将单词构建成为解析树。词法分析知道如何将单词分离出来,比如根据空格和换行符。
解析是一个迭代的过程。解析器从词法分析器获取一个单词,并尝试去匹配各条句法规则。如果发现有匹配,那么一个对应于该单词的节点会被加入到解析树中,然后解析器向词法分析器要求下一个单词。
如果没有规则可以匹配,解析器就会将单词在内部缓存起来,并请求下一个单词直到和已缓存的单词组合起来符合某条规则。假如完全找不到一条匹配的规则,就会抛出一个异常。意味着这是一个无效的文档,包含句法错误。
很多时候,解析树还不是最终产品。解析的结果还需进行翻译 – 将输入文档转换成为另一种形式。一个例子是代码的编译。编译器将源代码编译成机器码的过程是,首先将其解析形成解析树,然后把解析树翻译成为机器代码。
在图5中我们由一个数学表达式建立起了一个解析树。现在,让我们定义一个简单的数学语言,用来示例解析的过程。
词汇表:我们用来示例的语言包含整数,加号和减号。
语法:
现在让我们分析2 + 3 – 1.
率先匹配语法规则的是2, 根据语法规则#5,这是一个项。下一个匹配语法规则的是2 + 3,根据#3 ——- 一个项跟着一个运算符跟着一个项,这是一个表达式。然后下一个匹配是2 + 3 – 1,这已经到了输入的结束。2 + 3 – 1是一个表达式,因为我们已经知道2 + 3是一个项,所以我们有一个项跟着一个运算符跟着一个项。如果是2 ++则不会匹配任意规则,所以是一个无效的输入。
词汇通常用正则表达式表示。
上文我们提及的示例语言可以定义如下:
正如你所看到的,这里用正则表达式给出了整数的定义。
句法通常使用BNF范式进行定义。我们的示例语言的句法定义如下:
之前我们说过,上下文无关文法的语言可以被常规的解析器所解析。直观地说,一个上下文无关的文法就是指可以完全用BNF范式表达的文法。你可以在维基百科上查询关于上下文无关文法的正式定义。
有两种基本的解析器:自上而下的和自下而上的。直观地解释,自上而下的解析器从根节点出发,尝试从最外层找到匹配的句法结构。而自下而上的解析器从叶子节点出发,逐步向上匹配构造句法树。
让我们来看看这两种解析器会如何解析我们的示例:
自上而下的解析器会从高层的规则开始 – 它首先会识别2 + 3为一个表达式,然后识别 2 + 3 – 1为一个表达式(识别表达式的过程涉及到尝试匹配其它规则,但是起点是从最高级别的规则开始).
自下而上的解析器对输入进行扫描,当找到匹配的规则后,将匹配的那部分输入用规则进行替换。如此替换直到到达输入的尾部。部分匹配的表达式被保存在解析器的堆栈之中。
堆栈 | 输入 |
---|---|
2 + 3 – 1 | |
项 | + 3 – 1 |
项 运算符 | 3 – 1 |
表达式 | - 1 |
表达式 运算符 | 1 |
表达式 |
这种自下而上的解析器被称为移位归约解析器,因为输入在逐渐向右移位(设想一个指针从输入的头部逐渐移动到尾部),并且归约到句法规则上。
有一些工具可以帮助你生成解析器,它们被称做解析器生成器。你负责提供语言的语法-词法和句法规则-它提供一个相应的解析器。生成一个解析器需要对解析的深刻理解,人工创建一个优化的解析器并不是一件容易的事情,所以解析器生成器是很有用的。
Webkit使用了两个非常有名的解析器生成器 – 用于创建词法分析器的Flex和创建解析器的Bison(你可能听说过Lex和Yacc)。Flex的输入是一个包含标记符的正则定义的文件。Bison的输入是BNF范式格式的语言句法。
HTML解析器的任务是将HTML标记文档解析成为一棵解析树。
HTML的词汇和句法由W3C组织创建的规范定义。当前的版本是HTML4,HTML5正在评审之中。
正如我们在解析过程的介绍中已经了解的,文法可以用BNF范式表达。
很不幸的是,所有的常规解析器都不适用于HTML(我并不是开玩笑-它们可以用于解析CSS和JavaScript)。HTML并不能很容易地用一个解析器所需要的上下文无关文法来定义。
有一个可以定义HTML的正规格式 – DtD(文档类型定义)-但它不是上下文无关的文法。
这初看起来很奇怪:HTML和XML非常相似。一方面存在很多XML解析器,另一方面HTML存在一个XML的变种-XHTML。那么,为什么HTML仍然不是上下文无关的文法呢?
区别在于HTML的处理更为“宽容”,它允许你省略一些隐形添加的标签,有时省略一些起始或者结束标签等等。和XML严格的句法不同,HTML整体来看是一种富有弹性的句法。
看上去细微的差别实际上却带来了巨大的影响。一方面这促成了HTML的流行-它包容你的错误,使得网页的编辑更加容易。另一方面,这使得它很难写出一个正规的文法。概括地说 – HTML无法很容易地被常规的解析器和XML解析器来解析,因为它的文法不是上下文无关的。
HTML的定义是用DTD的格式来描述的。SGML(标准通用标记语言)族的语言都可以用DTD来定义。它包括所有有效的元素,属性和层次结构的定义。如我们前面已经说过的,HTML DTD不构成一个上下文无关文法。
DTD不只一种模式。严格模式完全遵守HTML规范,而其它模式会支持过去浏览器一些特有的标记。这样做的目的是确保兼容一些老的网页。当前,严格模式的DTD可以在这里找到:www.w3.org/TR/html4/strict.dtd
解析器的输出,是一棵解析树,它由DOM元素及其属性节点构成。DOM是文档对象模型的缩写(Document Object Model)。它是HTML文档的对象表示,同时它也是外部例如JavaScript,操作HTML元素的接口。
解析树的根节点是”Document”对象。
DOM元素和HTML标记之间几乎是一一对应的关系。比如下面这段标记语言:
会被翻译成下面这棵DOM树:
和HTML一样,DOM也是由W3C组织负责定义。这里:www.w3.org/DOM/DOMTR。这是一部操作Document的通用规范。其中有一块专门描述了各个对应HTML元素。HTML的定义可以在这里找到:www.w3.org/TR/2003/REC-DOM-Level-2-HTML-20030109/idl-definitions.html。
当我说解析树由DOM节点组成,意味着树是由实现了DOM接口的元素所组成的。浏览器在具体实现过程中,也会在元素中加入一些其它属性供浏览器内部使用。
之前章节已经说道,HTML无法用常规的自上而下或者自下而上的解析器进行解析。
原因是:
因为不能使用常规的解析技术,浏览器创建自定义的解析器以用于解析HTML。
HTML5的规范详细地描述了解析的算法。这个算法包含两个阶段 – 标记化和构建树。
标记化是一个词法分析的过程,将输入解析成为标记的集合。HTML的标记包括起始标签,结束标签,属性名和属性值。
标记生成器识别标记,传给树构造器,然后接受下一字符以识别下一标记,直到输入的结束。
算法的输出是一个个HTML的标记,该算法使用状态机来表示。每一个状态接收一个或者多个来自输入的字符从而进入下一个状态。进入下一状态的决定使用当前的标记化状态和树结构状态决定的。这意味着,如果当前状态不同,即使接受相同的字符,也有可能进入不同的新的状态。这个算法相当复杂,鉴于篇幅有限,我们使用一个示例来帮助理解其原理。
示例 – 将下面HTML标记化:
初始的状态是“数据状态(Data state)”。当接收到字符<,状态更新为”标签打开状态(Tag open state)”。再接收到一个a到z的字符,状态会更新到”标签名状态(Tag name state)”,并开始创建”开始标签标记(Start tag token)”。这个状态会一直保持,直至接收到一个>符号。这之间收到的每一个字符都会添加到标签名上。在这个例子里面,我们创建的第一个标记是一个html标签。
当读到>字符,当前的标记会出列,状态重新回到了”数据状态”。<body>标签也会被同样处理。html和body标签都被出列。重新回到”数据状态”。当接收到Hello World中的H字符,算法会释放该字符并创建一个字符标签,直至接收到</body>中的<字符。Hello world字符串中的每一个字符都会被处理为一个字符标记。
接收到<字符后,重新回到了”标签打开状态”。接收到下一个/字符时会触发结束标签的创建,并将状态更新到”标签名状态”。同样地,我们会保持这个状态,直到读到>字符。一个新的标签标记会出列而状态重新回到”数据状态”.</html>输入也是会得到相同的处理。
在解析器创建的同时,文档对象也被创建了。在解析树创建的同时,以Document为根节点的DOM树也在不断地被改变,不断地加入新的DOM元素。每一个被标记生成器释放出的标记都会交由解析树构建器处理。DOM规范定义了每一个标记所对应的DOM元素,这些元素会在接收到相应的标记时被创建。除了将这些元素加入DOM树,它们也会被加入到一个打开元素的栈中。这个栈用来纠正标签的嵌套错误和未正常关闭问题。它的算法也同样用状态机来描述。这些状态被称作”插入模式(insertion modes)”。
让我们来看一下示例输入的树构建过程:
树构建状态机的输入是一个由标记生成器给出的标记序列。第一个模式是”初始模式”。接收到html标记后切换到”before html”模式,并且在这个模式下重新处理这个标记。一个HTMLHtmlElement元素被创建并添加到Document根节点上。
然后,状态被切换到”before head”。然后我们接收到”body”标记。一个HTMLHeadElement被隐式地创建出来,即使我们的示例中没有”head”标记,它被加入到DOM树中。
我们随后进入”in head”模式然后是”after head”。body标记被重新处理,一个HTMLBodyElement被创建出来并插入到DOM树,模式转换为”in body”。
之后,我们接收到由”Hello world”生成的一系列的字符标记。第一个字符会触发一个”Text”结点的创建,而字符则插入到这个结点中,随后的其它字符也会被插入到Text结点之中。
接收到body的结束标签会促成进入”after body”模式。然后我们接收到html结束标签,进入”after after body”模式。最后我们接收到文件结束(EOF)标记,解析过程就此结束。
到了这个阶段,浏览器将文档标注为交互状态,开始解析那些”延期执行(deferred)”的脚本 – 就是那些被设定为在文档解析完成后才执行的脚本。文档状态被设置成为”完成”,一个”加载”的事件随之触发。
你可以在HTML5的规范中看到完整的标记化和树构建算法。
你很少会在浏览HTML网页时看到”无效句法(Invalid Syntax)”的错误。浏览器会纠正所遇到的那些无效内容,然后继续。
以下面的HTML为例:
在这里,我已经违反了很多条HTML语法(“mytag”不是一个标准的标签,”p”和”div”元素之间出现嵌套错误等等),但是浏览器仍然会正确地显示其内容并且毫无抱怨。大量的HTML解析器代码用于纠正网页作者所犯下的错误。
浏览器的错误处理机制相当一致,但令人称奇的是,它当前并不属于HTML的规范。和书签收藏以及历史前进/回退按钮一样,它也是浏览器发展历史中出现并保留下来的产物。有一些相当普遍的无效HTML结构在很多网站都存在,浏览器就会尝试和其它浏览器一样以一种额外授予的方式对无效网页进行纠正。
HTML5的规范定义了一部分类似的需求。Webkit在它的HTML解析器类的头部注释中对其做了很好的概括。
解析器对标记化的输入进行解析,构建文档树。如果文档的格式是有效的,则直接进行解析。
很不幸的是,我们不得不处理很多格式无效的HTML文档,所以解析器必须要能够容忍错误。
我们至少要能够处理下述这些错误情况:
- 出现一些很明显不能在当前打开的标签中添加的元素。我们应该将该元素之前打开的不能添加该元素的标签关闭,然后加入该元素。
- 出现一些我们不能直接添加的元素。这很有可能是网页作者忘记添加了之间的一些标签(或者之间的标签是可选的)。这些标签可能是:HTML HEAD BODY TBODY TR TD LI (还有遗漏的吗?)。
- 出现在一个inline元素中添加block元素的情况。关闭所有打开的inline元素,直到出现一个父亲block元素。
- 如果仍然无法处理,关闭所有打开的元素直到可以添加新接收的元素为止,或者直接跳过这个新元素.
让我们看一些Webkit容错的例子:
使用了</br> 而不是 <br>
有些站点使用使用了</br> 而不是 <br>,为了和IE以及Firefox兼容,Webkit将其和<br>做相同处理.
代码如下:
注意 – 错误处理是在内部进行的 – 用户并不会被告知这个过程。
迷路的表格(table)
迷路的表格指那些出现在另一个表格里,但又不是在任一个单元格之内的表格。
就像这个例子:
Webkit会将其层次结构改变,使其成为两个并列的兄弟表格:
Webkit内部的处理代码是这样的:
Webkit使用一个栈用于存放当前的元素内容 – 它会从外部表格的栈中抛出内部的表格,这样,两个表格就变成了平行的兄弟关系。
嵌套的form元素
如果用户在一个form元素中又放入了一个form,那么第二个form会被忽略。
代码:
太深的标签嵌套
代码的注释已经说得很清楚了:
www.liceo.edu.mx是一个有嵌套1500个标签的站点。有一堆嵌套的<b>标签。我们只允许最多20层的相同标签的嵌套,更深的标签会被全部忽略。
放错位置的html或者body结束标签
同样地 – 代码的注释已经讲得很清楚了:
对于格式非常糟糕的html页面。我们从不试图关闭body标签,因为一些愚蠢的网页会在body标签关闭之后放置内容。我们依赖于end()的调用来做关闭操作。
所以网页的作者要注意了 – 除非你想出现在Webkit容错代码的示例中 – 写符合标准的HTML网页。
还记得在引言中关于解析的描述吗?是的,和HTML不同,CSS是上下文无关的语法,可以用引言中介绍的各种解析器进行解析。事实上,CSS的规范已经定义了CSS的词法和句法规则。
来看一些例子:
词汇是用正则表达式定义的各个标记:
“ident”是标识符(identifier)的缩写,比如类名。”name”是元素的id(常常用#来引用)。
用BNF范式描述句法。
解释:这是一个规则集的结构:
div.error和a.error都是选择器,包含在花括号里边的内容是会被应用的一组规则,选择器和一组规则一起构成了一个规则集。它的结构的正式定义是这样的:
意思是一个规则集是一个选择器,或者一组被逗号和空格(S表示空格)分开的选择器。规则集包含花括号,以及里面包含的一个或者多个由分号分割开的声明。”声明”和”选择器”由下面的BNF范式定义。
Webkit使用Flex和Bison解析器生成器,从CSS的语法文件直接生成解析器。正如我们之前在介绍解析器的时候说道的,Bison创建一个自底向上的归约解析器。Firefox使用一个手工写的自顶向下的解析器。这两个解析器都会将CSS文档解析成为样式表对象,每个对象都包含CSS规则。CSS规则对象则包含选择器和声明对象,以及其它对应CSS语法的对象。
web的模型是同步的。网页作者希望当解析器读到<script type=”text/javascript”>的标签时,将脚本解析并立即执行。文档的解析会停止,知道脚本被执行完毕。如果脚本是外部的,那么它必须先要被从服务器上拿过来 – 这也是同步的,解析停止直到脚本文件加载完毕。这个模型已经存在很多年,也被包含在HTML4和HTML5的规范中。作者也可以将脚本标注为“延后(defer)”[译注:只被IE支持],那么它就不会中止文档的解析,直到解析结束才会执行。HTML5增加了一个选项,允许脚本被异步的由另一个线程解析和执行。
Webkit和Firefox都会进行这项优化。当脚本被执行时,另一个线程会同时解析文档的剩余部分,寻找需要从外部加载的资源并开始加载它们。这样外部资源可以以并列的方式加载,总共消耗的时间有所降低。注意 – 预解析器不会改变DOM树,而将这项工作交由主解析器,它只会解析链向外部的资源,类似外部的脚本,样式表和图片。
另一方面,样式表有不同的工作模式。理论上来说,应用样式表不会改变DOM树,没有必要由于等待它而中止文档的解析。但这涉及到一个问题,发生在脚本在文档解析过程中试图获得样式信息的情况下。如果样式当时还没有被加载和解析,脚本就会获得错误的信息从而产生问题。这看上去是一个非典型案例,但事实上非常普遍。Firefox在样式表加载和解析的过程中会阻塞所有的脚本执行。Webkit只会在脚本试图获取还没有被加载的样式信息时阻塞脚本的执行。
在DOM树构建的同时,浏览器开始构建另一棵树,渲染树。这是一棵由可视化元素组成的树,按照它们被显示的顺序排列。它是文档对象的可视化表示。这棵树的作用是帮助按照正确的顺序绘制文档内容。
Firefox把渲染树上的元素称为”帧(frames)”。Webkit使用术语renderer或渲染对象(render object)。
渲染对象清楚如何布局和将自已及子元素绘制出来。
Webkit的RenderObject类,是所有渲染对象的基类,有如下的定义:
每一个渲染对象都代表了一个矩形的区域,通常对应于该结点的CSS盒(box),这一点在CSS2的规范中有所描述。它包含诸如宽度,高度,位置等几何信息。盒类型会被相关联的结点的”display”属性所影响(可参考样式计算章节)。下面这段代码描述了根据display属性的不同,针对相同的结点应创建不同的渲染对象。
同时,结点的类型也是考虑因素之一,比如form的控件和table就有不同对应的帧(或者渲染对象)。
在Webkit中,如果一个元素需要创建自定义的渲染对象,它需要复写createRenderer方法。渲染对象有一个指针,指向样式对象,样式对象中包含一些和几何无关的信息。
渲染对象和DOM元素相对应,但是它们之间的关系并非一一对应。非可视化的DOM结点不会被插入渲染树。比如”head”元素。元素的display属性为”none”的,也不会加入到渲染树(但是值为”hidden”的元素仍然会被插入)。
也有一些DOM元素对应多个渲染对象。它们往往是那些有复杂结构的DOM元素,无法用单一的矩形来进行描述。比如,”select”元素包含3个渲染对象 – 一个是显示框,一个是下拉列表,还有一个是下拉按钮。而当文本由于宽度不够被迫撑开成多行时,新的行也会被当作新的渲染对象加入到渲染树中。
另一个对应多个渲染对象的例子是无效格式的HTML。根据CSS的规范,一个inline元素必须只能包含block元素或inline元素。如果出现混合出现的情况,一个匿名的block渲染器会被添加以包裹混合内容。
有一些对应于DOM结点的渲染对象处于和DOM结点不太相同的位置。Float和绝对定位的元素就是这样,它们处于正常的flow之外,放置在渲染树的另一个地方,并且映射到真正的渲染对象。放在原位的只是一个占位渲染对象。
在Firefox中,展示层会在DOM结点更新上注册一个侦听器。展示层把帧的创建委托给FrameConstructor,FrameConstructor负责解决样式问题(参见样式计算章节)并创建帧。
在Firefox中,展示层会在DOM结点更新上注册一个侦听器。展示层把帧的创建委托给FrameConstructor,FrameConstructor负责解决样式问题(参见样式计算章节)并创建帧。
处理html和body标签会触发渲染树的根结点的创建。这个根结点对应于CSS规范中提到的容器block(containing block) – 最上部的block,包含其它所有的block。它的尺寸就是viewport – 浏览器可视窗口的尺寸。Firefox把它称作ViewPortFrame,Webkit把它称作RenderView。这是文档对象指向的渲染对象。渲染树的剩余构建有DOM树的结点插入所触发。
参见CSS2规范以了解这个处理模型。
构建渲染树的时候需要计算每一个渲染对象的视觉属性。这是通过计算每个元素的样式属性来完成的。
样式信息有各种不同的来源,比如inline的样式元素和HTML标签的视觉属性(例如”bgcolor”属性)。后者会被转化成为CSS格式的属性。
样式信息的源是浏览器的默认样式表,也就是由页面作者提供的样式表,或者是浏览器用户提供的样式表(浏览器允许你定义自己最喜欢的样式,以Firefox为例,用户可以将自己喜好的样式表放在”Firefox Profile”文件夹下)。
样式计算存在着以下的这些难点:
举个例子 – 比如这个组合选择器:
这条规则会匹配一个是三个div元素的孩子结点的<div>元素。假设你想要验证这个规则是否适用于一个给定的<div>元素。你需要从当前结点上溯以进行验证,而当你上溯两层后发现只有两个div所以这条规则不适用时,你必须继续尝试其它规则。
让我们来看看浏览器是如何处理这些问题的:
Webkit中的结点会引用样式对象(RenderStyle)。这些对象在某些情况下可以被不同结点所共享。这些结点往往是兄弟关系并且:
为了实现更简便地样式计算,Firefox使用了两棵额外的树 – 规则树(rule tree)和样式上下文树(style context tree)。Webkit也有样式对象,但它们不是保存在类似样式上下文树这样的树状存储中,只是被DOM结点引用罢了。
样式上下文会存储最终的计算值。这个值是按照顺序应用所有的匹配规则,并将其从相对的逻辑值转化为最后具体的结果值。比如 – 如果本来是相对于屏幕大小的百分比,现在则需要转化成为绝对的像素单元。规则树的想法真的很巧妙,它使得结点之间可以共享计算得出的样式值,避免重复计算。也可以起到节约存储空间的作用。
所有匹配的规则会保存在规则树中。路径中越接近底部的结点有更高的优先级。规则树包含所有已知的匹配规则路径。规则的存储是延迟进行的。规则树不会在开始的时候就为所有的结点进行计算,而是只有当一个结点的样式需要被计算的时候,它的规则路径才会被加入到规则树中。
这个想法相当于把规则树的结点看做词典中的字母,查找路径看做寻找单词,假设我们已经有如下计算得出的规则树:
假设我们需要为内容树上的另一个元素匹配规则,并且找到匹配路径是B-E-I(顺序相同)。我们在树上已经有相同的路径A-B-E-I-L,所以现在不需用从头计算起了。
现在让我们看看样式上下文树如何帮助我们减少工作。
结构化
样式上下文可以被分割成一个个结构体。每个结构体包含一个特定类别的样式信息比如border或者color。结构体中的属性不是inherited就是non-inherited,非此即彼。Inherited的属性指除非在当前被定义,否则应用父结点对应的属性值。Non-inherited指属性值(也被称为”reset”属性值)如果没有在当前结点定义,则使用默认值。
上下文树通过缓存整个结构体来帮助减少重新计算的工作量。如果一个底部的结点没有找到不能匹配结构体的定义,那么它尝试匹配上层结点的被缓存的结构体值。
使用规则树来计算样式上下文值。
当为一个特定的元素计算样式上下文时,我们首先为之计算其在规则树上对应的路径,或者找到已经存在的相同路径。然后我们沿着匹配路径来填充新的样式上下文结构体。从路径的底部结点开始 – 这是拥有最高优先级的(通常也是最特殊的选择器)并向上遍历直到结构体被填充。如果在当前结点不能找到可以填充的结构体,那么我们就从路径更上层的结点去寻找填充,一旦找到后就将引用指向那个结点,这是一种很好的优化,整个样式结构体被共享,减少了重复的计算并且节省了空间。
如果我们只找到部分的结构体定义,那么我们继续往路径的上方上溯直到结构体被完全填充。
如果我们的结构体在路径中中没有找到任何定义,那么假如这个结构体是”inherited”类型,我们会在上下文树中指向结构体的父亲,这样就可以共享父亲的结构体。如果是“reset”类型,那么会使用默认值。
如果特定的结点增加了样式值,我们需要做一些额外的计算来将其转化成为具体的值。然后我们会把它缓存起来以供其孩子结点使用。
如果一个元素有兄弟结点指向相同的树结点,那么整个样式上下文都可以被共享。
让我们来看一个例子:假设我们有这样一个HTML
和下面的样式规则:
为了简化我们假设只考虑两个结构体 – color结构体和margin结构体。color结构体只包含一个值, margin结构体包含四个值。
形成的规则树如下图所示(结点用如下的方式标记 – 结点名:匹配的规则顺序号)
上下文树如下图所示(结点用如下的方式标记 – 结点名: 所指向的规则结点):
假设我们解析HTML遇到了第二个<div>标签,我们需要为之创建一个样式上下文并填充它的样式结构体。
经过规则匹配,我们发现这个<div>对应的规则是1,2和6。这意味着之前规则树上已经有一条我们的结点可以部分使用的路径,只需要再增加一个结点以匹配规则6即可(规则树上的结点F)。
我们创建一个样式上下文对象并把它放到上下文树中。新的样式上下文对象指向规则树中的结点F。
现在我们需要填充它的样式结构体。首先是margin结构体。因为最后的结点(F)并没有添加margin结构体,我们需要上溯规则树直到找到一个计算过的margin结构体并且使用它。我们在结点B上找到了margin结构体,这是声明了margin规则的最接近的结点。
结点F中已经包含了color结构体的定义,所以我们不需用上溯寻找其它可用的缓存结构体。我们可以直接计算最终的color值(比如把字符串类型的color值转化成RGB)并把值缓存在当前结点的color结构体中。
计算第二个<span>元素则更加简单。我们匹配规则找到它对应规则树上的元素G,和之前的span元素完全相同。因为我们找到了指向了相同结点的兄弟元素,就可以共享整个样式上下对象,只需要把指针指向之前span元素的上下文对象即可。
对于那些可以从父结点继承的属性结构体,缓存是在上下文树上做的(事实上color属性是可继承的,但是Firefox把它当作一个reset属性,把它缓存在了规则树上)。
例如我们给div增加一个font规则:
那么作为div的子结点的段落<p>元素,就会共享它的父结点的相同的font结构体。假如段落p没有指定font规则的话。
在Webkit中,没有规则树,匹配声明会遍历4次。首先是non-important高优先级的属性(那些被别的属性所依赖的比如-display属性),然后是高优先级important,然后是普通优先级non-important,最后是普通优先级important规则。多次出现的属性会通过正确的级联顺序来解决。最后出现的最终生效。
概括来说 – 共享样式对象(完全地或者部分地)可以解决问题1和问题3。Firefox的规则树也可帮助按照正确的顺序应用属性值。
样式规则有多个来源:
后面两类的样式很容易和元素进行匹配,因为它是元素特有的样式属性,可以使用元素作为键值进行匹配查找。
在之前的问题#2中已经提到过,CSS规则的匹配有时很棘手。为了解决这个难题,可以将CSS规则进行一些处理以便更容易地查询。
在样式表解析结束后,CSS规则会依据选择器被加入到一个或者多个hash表中。有根据id索引的表,有根据类名索引的表,根据标签名字索引的表,还有一个通用的表存放不适用于上三种类型的规则。如果选择器是一个id,它会被加入到id表,如果选择器是一个类名,会被加入到类名表依此类推。
这个处理可以使查找匹配规则更为方便。无需遍历每一个规则声明 – 我们可以从hash表中查找出相应的规则。
让我们看一个例子:
第一条规则会被插入类表,第二个插入id表,第三个插入标签表。
对于下面这个HTML片段:
我们会首先为p元素寻找匹配的规则。类表中有一个以error为键的规则,可以匹配”p.error”。div元素可以在id表中找到对应的规则(id为主键)。然后剩下的工作只是找出哪些通过键值找到的规则是真正匹配的了。
比如如果有对应div的CSS规则如下:
这条规则仍然会从tag表中抽取出来,因为键值是最右边的选择器,但这条规则并不匹配我们的div元素,因为我们的div没有一个table的祖先结点。
Webkit和Firefox都会进行这样的处理。
样式对象有针对每一个视觉方面的属性(所有的CSS属性但更为通用)。如果某一方面的属性没有被任意一条CSS规则所定义 – 那么那些inherite的属性就会应用父亲结点的对应属性,non-inherite的属性则会应用默认的值。
问题是极有可能出现多个定义 – 这就是级联顺序所要解决的问题了。
样式表级联顺序
某一个样式属性的声明可能会出现在多个样式表文件中,又或者在同一个样式表文件中出现多次。这意味着应用样式的顺序极为重要。这被成为”级联”顺序。根据CSS2的规范,级联的顺序定义如下(从优先级低到高):
浏览器的声明是最低优先级的,而用户只有将声明标记成为Important时才可以覆盖网页作者的声明。同样顺序的声明会根据区分性(specificity)来进行排序,然后是它们被定义的顺序。HTML视觉属性会被翻译成为CSS规则声明,它们被处理成为低优先级的网页作者规则。
区分性(Specificity)
CSS2规范所定义的选择器的区分性如下:
将四个数字连接起来a-b-c-d(选择一个大数作为进制的数字系统中),构成该选择器的区分性。
你选择的进制需要能确保比任意类别里的最大数都要大。
比如,如果a=14,你可以选择十六进制。如果碰巧a=17,那么你需要一个十七进制。这可能是出现了这样的一个选择器: html body div div p … (在选择器里出现了十七个标签,尽管这样的可能性极低)
一些例子:
对CSS规则进行排序
找到匹配的规则之后,它们会被根据级联顺序进行排序。当列表较小时,Webkit会使用冒泡排序,当列表较大时,则使用归并排序。Webkit通过重现”>”运算符来实现排序。
规则如下:
Webkit使用一个标签来标记是否所有的顶级样式表(包括@imports)加载完毕。如果在进行attach的时候还没有加载完毕-会先使用占位符,并在文档中进行标注,当样式表加载完毕后再进行重新计算。
在渲染对象创建和加入渲染树的时候,它并不包含位置和大小的信息。计算这些值的过程被称作布局(layout)或者reflow。
HTML采用基于流(flow)的布局模型,意味着大多数时候只需要过一遍就能计算出大小位置等几何信息。处于流中的后面位置元素通常不会影响前面元素的大小和位置,所以布局可以按从左至右,从上至下的顺序遍历文档获得。但是也有例外 – 比如,HTML的表格(table)的几何计算需要不止一次的遍历。
布局的坐标系统是相对应根帧(root frame)来建立的。使用的是top和left坐标。
布局是一个递归的过程。它从根渲染对象(对应HTML文档的<html>元素)开始。然后遍历部分乃至所有层次的帧,为每一个需要计算的渲染对象计算大小位置信息。
根渲染对象的位置是0,0,它的大小由viewport定义 – 也就是浏览器窗口的可见区域。
所有的渲染对象都有一个”laybout”或者”reflow”方法,每一个渲染对象都会调用它的孩子结点的layout方法。
为避免对每一个细小的变化都进行一次整体重新布局,浏览器使用一种”脏位(dirty bit)”系统。当一个渲染对象标注自己和自己的孩子结点都为”dirty”时,需要进行重新布局。
有两个标记 – “dirty”和”孩子结点dirty”.”孩子结点dirty”意思是尽管渲染对象自己没有变化,但它至少有一个子结点需要重新布局。
全局布局是指触发了整颗树范围内的重新布局。它有可能由以下情况触发:
重新布局也可以是增量的,就是只有dirty的渲染对象进行重新布局(有时候需要进行一些额外布局)。
当渲染对象为dirty时,增量的布局会被异步触发。举个例子,比如当从网络层加载到新内容,新的元素加入到DOM树之后,一个新的渲染对象被添加到了渲染树。
增量布局是异步执行的。Firefox将增量布局的“reflow命令”加入队列,一个调度程序会触发这些命令的批量执行。Webkit也有一个计时器执行增量布局 – 遍历渲染树,然后将dirty的渲染对象重新布局。
当脚本查询样式信息时,例如”offsetHeight”,往往会同步触发增量布局。
整体的布局往往是同步触发的。
当初始化布局完成之后,当一些属性(比如下拉滚动的位置)发生变化时,布局会作为回调被触发。
当一次重新布局是由渲染对象的位置改变而触发的,渲染对象的大小可以从缓存中获取而无需进行重新计算。
在某些情况下 – 只有一棵子树发生了改变因此无需从根结点开始进行重新布局。比如在输入控件内输入文本,它的变化不会影响它的外围元素,不然每一次键盘输入都将导致一次从根结点开始的重新布局。
布局的处理通常遵照下面这个模式:
Firefox使用一个”状态(state)”对象(nsHTMLReflowState)作为布局的参数。这其中就包括父渲染对象的宽度。
Firefox布局的输出是一个”指标(metrics)”对象(nsHTMLReflowMetrics)。它包含计算得出的渲染对象的高度。
渲染对象的宽度是根据外部容器的宽度,渲染对象样式中的”width”属性以及marnin和border来计算得出的。
比如下面这个div的宽度:
就是这样被Webkit计算得出的(BenderBox类的calcWidth方法):
clientWidth和clientHeight表示一个对象的内部,除去边框和滚动条。
现在计算得出的是”preferred width”。然后需要计算最小宽度和最大宽度。
如果preferred width大于最大宽度,那么值为最大宽度。如果preferred width小于最小宽度(最小的不可破开的单位),那么值为最小宽度。
这个数值会被缓存起来,以用于布局发生变化而宽度不变的情形。
在布局中如果一个渲染对象发现它需要断行。它立即停止布局并告知它的父对象自己需要断行。父对象会创建额外的渲染对象并对其进行布局。
到了绘制这个步骤,会遍历整个渲染树,并逐个调用渲染对象的”paint”方法来将它们的内容显示在屏幕上。绘制的工作是调用UI基础组件来完成的。
和布局一样,绘制也分为全局(绘制整个渲染树)和增量两种。对于增量绘制,它适用于那些局部渲染对象改变,这种改变不会影响整棵树。渲染对象将其对应的屏幕矩形区域置为无效,这导致OS将其视为一块”dirty区域”并触发”paint”事件。在实现上,OS会很巧妙地将多个区域合并成一个以方便重绘。对于Chrome来说,实现起来要更为复杂一些因为Chrome的渲染对象和主进程不在同一个进程上。Chrome会某种程度上模拟OS的行为。显示层会侦听这些”paint”事件,并把消息代理给渲染器的根结点。然后整个渲染树会被遍历直到找到相对应的渲染对象,它会对自己进行重绘(通常也包括它的子结点)。
CSS2的规范定义了绘制的流程。事实上绘制的顺序就是元素进入样式上下文栈的顺序。这个栈会从后往前开始绘制。比如说一个块渲染对象的栈内顺序是这样的:
Firefox遍历整棵渲染树,为需要绘制的矩形建立一个显示列表。列表中有序地(按照绘制的顺序,比如先背景,然后边框,等等)放置着和绘制矩形相关的渲染对象。这样渲染树只需要被遍历一次,等到重新绘制的时候,就不需用重新遍历,重新绘制所有的背景,所有的图片,所有的边框等等。
对于这个过程Firefox也进行了一定的优化,那就是不往列表中加入隐藏的元素,比如那些被不透明元素完全遮挡住的元素。
在重新绘制之前,webkit会将原来的矩形存储为一张位图。然后只对新旧矩形之间的差量进行绘制。
在发生变化的时候,浏览器会尝试将改变最小化。比如,改变一个元素的颜色只会对该元素进行重绘。改变一个元素的位置只会对该元素,及其子元素还有可能受到影响的兄弟元素进行重新布局和绘制。插入一个DOM结点会导致父结点的重新布局和绘制。但是一些重大变化,比如增加”html”元素的字体,便会导致缓存无效,整棵渲染树都会重新进行布局和绘制。
渲染引擎是单线程的。几乎所有的操作,除了网络,都是在一个线程进行。在Firefox和Safari,这个线程就是主线程,对于Chrome,这个线程是标签进程的主线程。
网络操作可以由多个并行的线程同时进行。并发的连接数往往是受限的(通常是2-6个连接,比如Firefox 3, 允许6个并发连接)。
浏览器的主线程是一个事件循环。它是一个无限循环,永远处于接受处理状态。它等待事件(比如布局,比如绘制)的到来并处理它们。这是Firefox中关于事件循环的代码:
根据CSS2的规范,canvas这个术语的意思是“用来渲染格式化结构的空间”-也就是浏览器绘制内容的区域。canvas的大小是无限的,但是浏览器会根据可视区域的尺寸选择一个初始的宽度。
根据www.w3.org/TR/CSS2/zindex.html所述,当canvas包含在其它canvas之内时,它是透明的,否则会赋予一个浏览器默认的颜色。
CSS盒模型描述的是文档树上元素对应生成的矩形box,它们根据可视化格式模型进行布局。
每一个box包含一个内容(content)区域(比如text,image,等等),和可选的外部padding,边框(border),和margin区域。
每一个结点会生成0..n个box。
所有的元素都有一个”display”属性,决定了它们所对应生成的盒类型。例如:
display属性值默认是inline,但是浏览器的不同元素会有不同的样式默认值,比如-”div”元素的默认display属性值是block。
在这里www.w3.org/TR/CSS2/sample.html你可以看到默认的样式表值。
有三种定位策略:
定位策略是由”position”属性和”float”属性所决定的。
static定位无需指定具体位置,会直接使用默认的位置。对于其它的策略,网页作者需要指定其位置 – top,bottom,left,right.
box的布局是由以下这些因素决定的:
Block box: 形成一个block – 在浏览器窗口中拥有自己的矩形区域。
Inline box: 没有自己的矩形区域,但是包含在一个block内。
Block是一个接一个垂直布局的,Inline是水平方向布局的。
Inline box是放置在行内,或者说是”line box”中的。Lines至少有最高的box那么高,甚至更高。当box根据”baseline”对齐时 – 意味着各个元素之间需要以一个比底部稍高一些的线对齐。如果容器的宽度不够,那么inline的元素会被放置到多行。对于paragraph元素来说这种情况很常见。
相对定位 – 先按照通常的方式定位,然后根据给定的位移进行移动。
一个浮动的box会移动到line box的左边或者右边。有趣的是,其它box会漂浮在它的周围。下面这段HTML:
显示出来如下:
这种定位是完全无视常规的文档流(normal flow)的定位。元素不进入normal flow。它的坐标是相对于容器的坐标。其中,固定(fixed)定位则是将可视区域作为容器来设定坐标。
注意 – 如果是固定定位,那么即使是滚动条滚动也不会影响它的位置。
这是由CSS的z-index属性来指定的。它代表着box的第三个维度,也就是沿着z轴的定位。
多个box分开入栈(称为上下文堆栈)。对应每一个堆栈,后面的元素会先行绘制,然后靠前的元素绘制在它的上面,更接近用户。如果出现重叠,那么新绘制的元素就会覆盖之前的元素。
栈是按照z-index的属性进行排序的。包含z-index属性的box形成一个局部堆栈,可视区域(viewport)则有一个外部堆栈。
示例:
显示的结果就会是这样:
虽然红色div在DOM树中的位置在绿色div之前,也因此比绿色的div先行绘制,但是z-index属性值更大,所以它被移动到了根box堆栈的更前面的位置。