# 浏览器渲染HTML的工作原理

浏览器的主要功能是向服务器发出请求,在浏览器窗口上展示你选择的网络资源。这里所说的资源一般是指HTML文档,也可以是 PDF,图片或其它的类型。

# 浏览器结构

浏览器的主要结构可以划分为

  • 用户界面:包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗口显示的您请求的页面外,其他显示的各个部分都属于用户界面

  • 浏览器引擎:在用户界面和渲染引擎之间传送数据

  • 渲染引擎:负责解析请求的内容。如果请求的内容是HTML,它就负责解析HTML和CSS内容,并将解析后的内容显示在浏览器窗口上

  • 网络:用于网络调用,如HTTP请示。其接口与平台无关,并为所有平台提供底层实现

  • 用户界面后端:用于绘制基本的窗口小部件,比如组合框和窗口。其公开了与平台无关的通用接口,而在底层使用操作系统的用户界面方法。

  • JavaScript 解释器: 用于解析和执行 JavaScript 代码

  • 数据存储:这是持久层。可以通过浏览器存储各种数据,例如 Cookie,storage等

TIP

值得注意的是,和大多数浏览器不同,Chrome 浏览器的每个标签页都分别对应一个呈现引擎实例。每个标签页都是一个独立的进程

# 渲染引擎

渲染引擎的功能就是负责显示请求的内容,如果显示 HTML 和 XML 文档与图片。通过插件(或浏览器扩展程序),还可以显示其他类型的内空;例如,使用 PDF 查看器插件就能显示 PDF 文档。这里我们将集中介绍其主要用途:显示使用 CSS 格式化的 HTML 内容和图片。

渲染引擎一开始会从网络层获取请求文档的内容,内容的大小一般限制在 8000 个块以内。

然后进行如下所示的基本流程:

解释如下:

  • 解析 DOM 元素生成 DOM 节点树

  • 解析 CSS 及样式元素中的数据,生成样式规则

  • DOM 树和 CSS 样式规则结合形成呈现树

  • 进入布局处理阶段,也就是为每个节点分配一个应出在屏幕上的确切坐标,这一步也叫回流或者重排

  • 然后就是绘制,呈现引擎会遍历呈现树,将每个节点绘制出来

TIP

需要着重指出的是,这是一个渐进的过程。为达到更好的用户体验,呈现引擎会力求尽快将内容显示在屏幕上。它不必等到整个 HTML 文档解析完毕之后,就会开始构建呈现树和设置布局。在不断接收和处理来自网络的其余内容的同时,呈现引擎会将部分内容解析并显示出来。

主流程示例

Webkit主流程

接下来将分析 HTML 解析的具体过程,在介绍之前我们先了解一下什么是解析

# HTML 解析

# 何为解析?

解析文档是指将文档转化成为有意义的结构,也就是可以让代码理解和使用的结构。解析得到的结果通常是代表了文档结构的节点树,它称作解析树或者语法树

示例: 解析 2 + 3 - 1 这个表达式,会返回下面的树:

# 解析器和词法分析器

解析的过程可以分成两个子过程:词法分析和语法分析。

  • 词法分析

    词法分析(有时候也称为标记生成器)是将输入的内容分割成大量标记的过程。标记是语言中的词汇,即构成内容的单位。在人类的语言中,它相当于语言字典中的单词

  • 语法分析

    语法分析是应用语言的语法规则的过程,负责根据语言的语法规则分析文档的结构,从而构建解析树

解析是一个迭代的过程。通常,解析器会向词法分析器请求一个新标记,并尝试将其与某条语法规则进行匹配。如果发现了匹配规则,解析器会将一个对应于该标记的节点添加到解析树中,然后继续请求下一个标记。

如果没有规则可以匹配,解析器就会将标记存储到内部,并继续请求标记,直至找到可与所有内部存储的标记匹配的规则。如果找不到任何匹配规则,解析器就会引发一个异常。这意味着文档无效,包含语法错误

解析器类型

有两种基本类型的解析器:

  • 自上而下解析器

    自上而下的解析器从语法的高层结构出发,尝试从中找到匹配的结构

  • 自下而上解析器

    自下而上的解析器从低层规则出发,将输入内容逐步转化为语法规则,直至满足高层规则

# 翻译

很多时候,解析树还不是最终产品。解析通常是在翻译过程中使用的,而翻译是指将输入文档转换成另一种格式。编译就是这样一个例子。编译器可将源代码编译成机器代码,具体过程是首先将源代码解析成解析树,然后将解析树翻译成机器代码文档。

下面具体了下 HTML 文档的解析过程

# HTMl解析器

HTML 解析器的任务是将 HTML 标记解析成 DOM树

DOM

DOM 是文档对象模型 (Document Object Model) 的缩写。它是 HTML 文档的对象表示,同时也是外部内容(例如 JavaScript)与 HTML 元素之间的接口。

DOM树 的根节点是 Document 对象。

DOM 中的标记之间几乎是一一对应的关系。比如下面这段标记:

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

可翻译成如下的 DOM 树:

所有的常规解析器都不适用于 HTML, 包括 XML 解析器,HTML 并不能很容易地用解析器所需的 与上下文无关的语法 来定义。原因如下:

  • 语言的宽容本质。

  • 浏览器历来对一些常见的无效 HTML 用法采取包容态度。

  • 解析过程需要不断地反复。源内容在解析过程中通常不会改变,但是在 HTML 中,脚本标记如果包含 document.write,就会添加额外的标记,这样解析过程实际上就更改了输入内容

因为 HTML 无法用常规的解析器进行解析,浏览器就根据HTM5规范创建了自定义的解析器来解析 HTML。

HTML 的正规格式:DTD(Document Type Definition,文档类型定义),但它无法构成与上下文无关的语法。

TIP

与上下文无关的语法

解析是以文档所遵循的语法规则(编写文档所用的语言或格式)为基础的。所有可以解析的格式都必须对应确定的语法(由词汇和语法规则构成),这称为与上下文无关的语法。粟子:若有一条规则: A->α, 则意味着A可能会在任何地方被 α 替换, 无论 A 在什么位置出现,平时说话经常会用代词指代东西, 代词就是上下文有关的, 不然无法判断其指代, 而某个名字往往就是上下文无关的, 这个名字直接被理解为其对应的事物

比如: 那个男人, 可以有多个理解, 需要上下文, 而书就明确被大脑接收并替换为"书"这种东西

# 标记化和树构建

HTML5 规范详细地描述了解析算法。此算法由两个阶段组成:标记化和树构建

  • 标记化是词法分析过程,将输入内容解析成多个标记。HTML 标记包括起始标记、结束标记、属性名称和属性值。

  • 标记生成器识别标记,传递给树构造器,然后接受下一个字符以识别下一个标记;如此反复直到输入的结束。

标记化算法

该算法的输出结果是 HTML 标记。该算法使用状态机来表示。每一个状态接收来自输入信息流的一个或多个字符,并根据这些字符更新下一个状态。当前的标记化状态和树结构状态会影响进入下一状态的决定。这意味着,即使接收的字符相同,对于下一个正确的状态也会产生不同的结果,具体取决于当前的状态

基本示例 - 将下面的 HTML 代码标记化:

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

初始状态是数据状态。遇到字符 < 时,状态更改为 标记打开状态。接收一个 a-z 字符会创建起始标记,状态更改为标记名称状态。这个状态会一直保持到接收 > 字符。在此期间接收的每个字符都会附加到新的标记名称上。在本例中,我们创建的标记是 html 标记。

遇到 > 标记时,会发送当前的标记,状态改回数据状态<body> 标记也会进行同样的处理。目前 htmlbody 标记均已发出。现在我们回到数据状态。接收到 Hello world 中的 H 字符时,将创建并发送字符标记,直到接收 </body> 中的 <。我们将为 Hello world 中的每个字符都发送一个字符标记。

现在我们回到标记打开状态。接收下一个输入字符 / 时,会创建 end tag token 并改为标记名称状态。我们会再次保持这个状态,直到接收 >。然后将发送新的标记,并回到“数据状态”。</html> 输入也会进行同样的处理。

树构建算法

在创建解析器的同时,也会创建 Dcoument 对象。标记生成器发送的每个节点都会由树构建器进行处理,构建器接收到标记后会根据规范创建对应的DOM元素,这些元素不仅会添加到 DOM 树中,还会添加到开放元素的堆栈中。此堆栈用于纠正嵌套错误和处理未关闭的标记。其算法也可以用状态机来描述。这些状态称为插入模式

让我们来看看示例输入的树构建过程:

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

树构建阶段的输入是一个来自标记化阶段发出的标记

  1. 第一个模式是initial mode,接收HTML标记后转为before html模式,并在这个模式下重新处理此标记,这样会创建一个HTMLHtmlElement元素,并将其附加到Document根对象上

  2. 然后状态将改为before head,即使我们的示例中没有head标记,系统也会隐式创建一个 HTMLHeadElement,并将其添加到树中,然后进入in head模式,处理head中的元素后就转入after head模式

  3. 然后系统对 body 标记进行重新处理,创建并插入 HTMLBodyElementin body

  4. 现在,接收由Hello world字符串生成的一系列字符标记。接收第一个字符时会创建并插入Text节点,而其他字符也将附加到该节点

  5. 接收 body 结束标记会触发after body模式。现在我们将接收 HTML 结束标记,然后进入after after body模式。接收到文件结束标记后,解析过程就此结束。

# 解析结束后的操作

在此阶段,浏览器会将文档标注为交互状态,并开始解析那些处于 deferred 模式的脚本,也就是那些应在文档解析完成后才执行的脚本。然后,文档状态将设置为 完成,一个 加载 事件将随之触发。

# 浏览器的容错机制

您在浏览 HTML 网页时从来不会看到 语法无效 的错误。这是因为浏览器会纠正任何无效内容,然后继续工作。

# CSS解析

和 HTML 不同,CSS 是上下文无关的语法,可以由各种解析器进行解析

# WebKit CSS 解析器

CSS解析器会将 CSS 文件解析成 StyleSheet 对象,且每个对象都包含 CSS 规则。CSS 规则对象则包含选择器和声明对象,以及其他与 CSS 语法对应的对象。

# 处理脚本和样式表的顺序

脚本

当解析器遇到 <sciprt> 标记时会立即解析脚本并执行脚本,文档的解析将停止,直到脚脚本执行完毕。

如果脚本是使用的外链的形式加的,那么解析过程会停止,直到从网络同步抓取资源并解析然后执行后再继续

现在在脚本上标注 deferasync 来改变脚本的执行顺便

  • async:异步执行脚本(仅适用于外部脚本)

  • defer: 规定是否对脚本加载和执行进行延迟,直到页面加载为止

# 预解析

WebKit 和 Firefox 都进行了这项优化。在执行脚本时,其他线程会解析文档的其余部分,找出并加载需要通过网络加载的其他资源。通过这种方式,资源可以在并行连接上加载,从而提高总体速度。请注意,预解析器不会修改 DOM 树,而是将这项工作交由主解析器处理;预解析器只会解析外部资源(例如外部脚本、样式表和图片)的引用。

# 样式表

理论上来说,应用样式表不会更改 DOM 树,因此似乎没有必要等待样式表并停止文档解析。但这涉及到一个问题,就是脚本在文档解析阶段会请求样式信息。如果当时还没有加载和解析样式,脚本就会获得错误的回复.

# 渲染树构建

在构建 DOM 树的的同时,浏览器还会构建另一个树结构:渲染树。渲染树是文档的可视化表示。它的作用是让浏览器能按照正确的顺序绘制内容

每一个渲染树都代表了一个矩形的区域,通常对应于相关节点的 CSS 框。它包含诸如宽度、高度和位置等几何信息。

# 渲染树和 DOM 树的关系

渲染树是和 DOM 元素相对应的,但并非一一对应。非可视化的 DOM 元素不会插入呈现树中,例如 head 元素。如果元素的 display 属性值为 none ,那么也不会显示在渲染树中(但是 visibility 属性值为 hidden 的元素仍会显示)

# 构建渲染树的流程

解析样式和创建呈现器的过程称为 附加。每个 DOM 节点都有一个 attach 方法。附加是同步进行的,将节点插入 DOM 树需要调用新的节点 attach 方法。

# 样式计算

构建呈现树时,需要计算每一个呈现对象的可视化属性。这是通过计算每个元素的样式属性来完成的。

样式表的来源包括浏览器的默认样式表、由网页作者提供的样式表以及由浏览器用户提供的用户样式表

样式计算存在以下难点:

  • 样式数据是一个超大的结构,存储了无数的样式属性,这可能造成内存问题

  • 如果不进行优化,为每一个元素查找匹配的规则会造成性能问题。要为每一个元素遍历整个规则列表来寻找匹配规则,这是一项浩大的工程。选择器会具有很复杂的结构,这就会导致某个匹配过程一开始看起来很可能是正确的,但最终发现其实是徒劳的,必须尝试其他匹配路径。

  • 应用规则涉及到相当复杂的层叠规则(用于定义这些规则的层次)。

# CSS 规则匹配

样式表解析完毕后,系统会根据选择器将 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”键,在下面可以找到“p.error”的规则。div 元素在 ID 表(键为 ID)和标记表中有相关的规则。剩下的工作就是找出哪些根据键提取的规则是真正匹配的了。

例如,如果 div 的对应规则如下:

table div {margin:5px}

这条规则仍然会从标记表中提取出来,因为键是最右边的选择器,但这条规则并不匹配我们的 div 元素,因为 div 没有 table 祖先。

# 布局

呈现器在创建完成并添加到渲染树时,并不包含位置和大小信息。计算这些值的过程称为布局或重排。

HTML 采用基于流的布局模型,这意味着大多数情况下只要一次遍历就能计算出几何信息。处于流中靠后位置元素通常不会影响靠前位置元素的几何特征,因此布局可以按从左至右、从上至下的顺序遍历文档。但是也有例外情况,比如 HTML 表格的计算就需要不止一次的遍历

所有的呈现器都有一个 layout 或者 reflow 方法,每一个呈现器都会调用其需要进行布局的子代的 layout 方法

# 绘制

在绘制阶段,系统会遍历呈现树,并调用呈现器的 paint 方法,将渲染树的内容显示在屏幕上

# 绘制顺序

  1. 背景颜色

  2. 背景图片

  3. 边框

  4. 子代

  5. 轮廓

# 动态变化

在发生变化时,浏览器会尽可能做出最小的响应。因此,元素的颜色改变后,只会对该元素进行重绘。元素的位置改变后,只会对该元素及其子元素(可能还有同级元素)进行布局和重绘。添加 DOM 节点后,会对该节点进行布局和重绘。一些重大变化(例如增大“html”元素的字体)会导致缓存无效,使得整个呈现树都会进行重新布局和绘制。

# 总结

解析 HTML 的过程实质就是根据文档结构转换成可以表示文档结构的语法树的过程

解析HTML文档的过程

  • 浏览器采用流式布局模型(Flow Based Layou)

  • 解析HTML,生成DOM树;解析CSS,生成CSSOM树

  • 将DOM树和CSSOM树结合生成渲染树(Render Tree)

  • Layout(回流):根据生成的渲染树,进行回流(Layout),得到的几何信息(位置、大小)

  • Painting(重绘):根据渲染树及回流得到的几何信息,得到节点绝对像素

  • Display:将像素发送给GPU,展示在页面上。(这一步其实还有很多内容,比如会在GPU将多个合成层合并为同一个层,并展示在页面中。而css3硬件加速的原理则是新建合成层,这里我们不展开,之后有机会会写一篇博客)

# 引用

浏览器的工作原理:新式网络浏览器幕后揭秘 (opens new window)