现代浏览器的工作原理(二)

解析HTML文档流程

  现代浏览器解析html文档的流程如图1所示(图片摘自HTML5标准文档)。

现代浏览器解析html文档的流程

图 1 现代浏览器解析html文档的流程

  上述解析流程中各个处理阶段的功能说明如下:
  (1)Byte Stream Decoder(字节流解码器):将获取到的html文档字节流解析成Unicode编码表示的字符流。关于更多解码细节,可以查看html5标准文档
  (2)Input Stream Preprocessor(输入字符流预处理器):对解码产生的Unicode字符流进行预处理,如忽略”回车”(CR)后面紧跟的”换行”(LF),然后将所有的”回车”(CR)替换成”换行(LF)”。关于更多预处理字符流的操作,可以查看html5标准文档
  (3)Tokenizer(标记生成器):处理html字符流,生成html标记并将标记传送给树生成器。html标记有以下几种:DOCTYPE、起始标签、结束标签、注释、字符、EOF(文件结束标志)。关于标记生成器的更多细节操作,可以查看html5标准文档
  (4)Tree Construction(树构建):根据标记生成器产生的一系列html标记,构建DOM树。关于树构建过程的更多细节,可以查看html5标准文档
  (5)Script Execution(执行脚本):执行javascript脚本。

标记化算法

  标记生成器使用标记化算法从html字符流中提取出html标记用于构建DOM树。由于html语法不是一个与上下文无关的语法,并且解析的过程也可能动态改变html文档(如在树构建的过程中执行document.write()),于是用状态机的形式来表示标记化算法。状态机中的每个状态接受一个或者多个输入字符流中的字符,并且根据这些字符来确定下一个状态。每一次状态的改变是由当前标记状态以及当前树构建状态来决定的。标记化算法具体的实现非常复杂,本文拟通过一个简单的示例来说明标记化算法的基本原理,对其更深入细致的实现不作介绍。关于算法的更多细节,可以查看html5标准文档
  使用标记化算法将下面的html片段标记化:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<title>解析html文档</title>
</head>
<body>
<h1>Hello, world!</h1>
</body>
</html>

  上述html片段的标记化过程中各个状态切换流程如下:
  (1)初始状态为”数据状态”(Data state)。
  (2)当遇到字符’<’时,状态切换到”标签打开状态”(Tag open state)。
  (3)当遇到字符’!’时,状态切换至”标记声明打开状态”(Markup declaration open state)。在该状态下,如果后续两个字符都是连字符’-‘,则创建一个注释标记,设置标记的数据为空,并且跳转到”注释开始状态”(Comment start state)。如果后续7个字符可以组成字符串’DOCTYPE’(不区分大小写),则当前状态会跳转到”DOCTYPE状态”(DOCTYPE state)。根据上述代码片段,当前状态会跳转到”DOCTYPE状态”。
  (4)下一个字符为空格,当前状态切换至”DOCTYPE名称之前状态”(Before DOCTYPE name state)。
  (5)当遇到字符串’html’时,创建一个新的DOCTYPE标记,标记的名字为’html’。然后当前状态切换至”DOCTYPE名字状态”(DOCTYPE name state)。
  (6)当遇到字符’>’,当前状态跳转到”数据状态”并且释放当前的DOCTYPE标记。
  (7)当遇到字符’<’, 当前状态切换到”标签打开状态”。
  (8)遇到字符’h’,创建一个新的起始标签标记,设置标记的标签名为空,当前状态切换至”标签名称状态”(Tag name state)。
  (9)重新从字符’h’开始解析,将解析的字符一个一个添加到创建的起始标签标记的标签名中,直到遇到字符’>’。此时当前状态切换至”数据状态”并释放当前标记,当前标记的标签名为’html’。
  (10)解析后续的’<head>’和’<title>’的方式与’<html>’一致,创建并释放对应的起始标签标记,解析完毕后,当前状态处于”数据状态”。
  (11)遇到字符串’解析html文档’,针对每一个字符,创建并释放一个对应的字符标记,解析完毕后,当前状态仍然处于”数据状态”。
  (12)遇到字符’<’, 进入”标签打开状态”。
  (13)遇到字符’/‘, 进入”结束标签打开状态”(End tag open state)。
  (14)遇到字符’t’,创建一个新的结束标签标记,设置标记的标签名为空,当前状态切换至”标签名称状态”(Tag name state)。
  (15)重新从字符’t’开始解析,将解析的字符一个一个添加到创建的结束标签标记的标签名中,直到遇到字符’>’。此时当前状态切换至”数据状态”并释放当前标记,当前标记的标签名为’title’。
  (16)解析’</head>’的方式与’</title>’一样。
  (17)对于后续的html标签和文本的解析,可以参照(1)~(16)的流程来解析。
  (18)所有的html标签和文本解析完成后,状态切换至”数据状态”,一旦遇到文件结束标志符(EOF),则释放EOF标记。

树构建算法

  当html解析器被创建的同时,浏览器会创建一个Document对象。在树构建阶段,以Document为根节点的DOM树会得到不断的修改或扩充。标记生成器产生的每个标记被送到树构建器进行处理。html5标准中定义了每类标记对应的DOM元素,当树构建器接收到某个标记时就会创建该标记对应的DOM元素并将该元素插入到DOM树中。为了纠正元素标签嵌套错位的问题和处理未关闭的元素标签,树构建器创建的新DOM元素还会被插入到一个开放元素栈中。树构建算法也可以采用状态机的方式来描述,每个状态都对应一个状态变量insertion mode,该变量可以取值’initial’、’before html’、’before head’、’after after body’等。
  使用树构建算法处理以下html片段生成的标记:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<title>解析html文档</title>
</head>
<body>
<h1>Hello, world!</h1>
</body>
</html>

  针对标记化算法创建的标记,使用树构建算法处理的过程中,各个状态的切换流程如下所示:
  (1)树构建的初始状态是’initial’模式。
  (2)树构建器接收到标记生成器释放出来的DOCTYPE标记后,树构建器会创建一个DocumentType节点附加在Document节点上,DocumentType节点的name属性为DOCTYPE标记的名字。切换当前的状态为’before html’模式。
  (3)接收到标记生成器释放的起始标签标记(标记名为’html’)后,树构建器会创建一个HTMLHtmlElement元素(实现了HTMLHtmlElement接口)并将该元素作为Document的子节点插入树中。新创建的HTMLHtmlElement元素也会被插入到一个开放元素栈中。当前状态切换为’before head’模式。
  (4)接收到标记生成器释放的起始标签标记(标签名为’head’)后,树构建器会创建一个HTMLHeadElement元素(实现了HTMLHeadElement接口)并将该元素作为HTMLHtmlElement元素的子节点插入树中。新创建的HTMLHeadElement元素也会被插入到一个开放元素栈中。当前状态切换为’in head’模式。
  (5)接收到标记生成器释放的起始标签标记(标签名为’title’)后,树构建器会创建一个HTMLTitleElement元素(实现了HTMLTitleElement接口)并将该元素作为HTMLHeadElement元素的子节点插入树中。新创建的HTMLTitleElement元素也会被插入到一个开放元素栈中。当前状态切换为’text’模式。
  (6)在’text’模式下,树构建器会创建一个Text节点(实现了Text接口)并将标记生成器释放的字符标记对应的字符添加到Text节点中。处理完字符标记后,Text节点会作为HTMLTitleElement元素的子节点插入到树中。
  (7)在’text’模式下,接收到标记生成器释放的结束标签标记(标签名为’title’)后,树构建器会将之前插入开放元素栈的HTMLTitleElement元素弹出。当前状态会返回状态机进入’text’模式之前的模式,本例为’in head’模式。
  (8)接收到标记生成器释放的结束标签标记(标签名为’head’)后,树构建起会将之前插入开放元素栈的HTMLHeadElement元素弹出。当前状态切换至’after head’模式。
  (9)接收到标记生成器释放的起始标签标记(标签名为’body’)后,树构建器会创建一个HTMLBodyElement元素(实现了HTMLBodyElement接口)并将该元素作为HTMLHtmlElement元素的子节点插入树中。新创建的HTMLBodyElement元素也会被插入到一个开放元素栈中。当前状态切换为’in body’模式。
  (10)接收到标记生成器释放的起始标签标记(标签名为’h1’)后,树构建器会创建一个HTMLHeadingElement元素(实现了HTMLHeadingElement接口)并将该元素作为HTMLBodyElement元素的子节点插入树中。新创建的HTMLHeadingElement元素也会被插入到一个开放元素栈中。
  (11)接收到标记生成器释放的字符标记后,树构建器会创建一个Text节点(实现了Text接口)并将标记生成器释放的字符标记对应的字符添加到Text节点中。处理完字符标记后,Text节点会作为HTMLHeadingElement元素的子节点插入到树中。
  (12)接收到标记生成器释放的结束标签标记(标签名为’h1’)后,树构建器会将之前插入开放元素栈的HTMLHeadingElement元素弹出。
  (13)接收到标记生成器释放的结束标签标记(标签名为’body’)后,树构建起会将之前插入开放元素栈的HTMLBodyElement元素弹出。当前状态切换至’after body’模式。
  (14)接收到标记生成器释放的结束标签标记(标签名为’html’)后,树构建器会将之前插入开放元素栈的HTMLHtmlElement元素弹出。当前状态切换至’after after body’模式。
  (15)接收到标记生成器释放的EOF标记后,树构建器会停止构建。
  (16)整个html文档解析过程完成。
  树构建器生成的DOM结构如图2所示:

DOM树结构

图 2 树构建器生成的DOM树

解析完成后浏览器的动作

  当解析完html文档之后,浏览器会将文档状态标记为”可交互”,然后解析并执行处于”defer”模式下的javascript脚本。当脚本执行完毕之后,浏览器会在Document对象上触发”DOMContentLoaded”事件。当文档依赖的所有其他外部资源(如图片等)下载解析并完毕之后,浏览器会先将文档状态标记为”完成”,然后在Window对象上触发’load’事件。

script标签的defer和async属性

  script标签的defer和async属性可以决定脚本的执行时机。对于”module”类型的脚本来说,设置defer属性并不起作用,该类型的脚本对应的script标签只支持async属性。
  对于非”module”类型的脚本而言,当script标签设置了async属性时,浏览器解析到该标签时会另起一个线程获取脚本(当脚本是外部文件时),待脚本准备好后,浏览器会立即解析并执行脚本,在脚本执行过程中,如果DOM解析未完成,则DOM解析暂停;当script标签设置了defer属性时,浏览器解析到该标签时同样会另起一个线程获取脚本(当脚本是外部文件时),与设置async属性不同的是,待脚本准备好后,浏览器并不会立即执行脚本,而是等到DOM解析完毕之后开始执行脚本;当没有设置defer或者async属性时,浏览器解析到script标签时会立即解析并执行脚本,与此同时,DOM的解析被阻塞;当script标签同时设置defer和async属性时,async的优先级高于defer。
  对于”module”类型的脚本而言,当script标签设置了async属性,浏览器解析到script标签时会另起一个线程获取该module所依赖的其他module,待所有module准备好后,浏览器会立即执行脚本,此时,如果DOM解析未完成,则DOM解析过程暂停;当script标签未设置async属性时,浏览器解析到script标签时同样会另起一个线程获取该module所依赖的其他module,待所有module准备好后,浏览器并不会立即执行脚本,而是等到DOM解析完成后再执行脚本。
  script标签的defer和async属性对脚本执行和DOM解析带来的影响参见图3(引用自html5标准文档)。

图 3 defer和async属性对html解析带来的影响

参考文献

  1. https://www.html5rocks.com/en/tutorials/internals/howbrowserswork/
  2. https://www.w3.org/TR/html5/syntax.html