三. 浏览器内部工作原理-解析方向(一)

前言

为方便读者阅读,贴出以下参考链接,在此感谢前辈笔记。

浏览器解析过程分析:前端必读:浏览器内部工作原理_知识库_博客园 (cnblogs.com)

浏览器工作过程分析:万字详文:深入理解浏览器原理 - 知乎 (zhihu.com)

树的构建流程

image.png

HTML 和 CSS 的页面解析是从上之下的,线程的。文档流从上往下,若先碰到了 CSS,那么开始解析,接着不被阻塞的继续向下解析 HTML,这样既符合并行解析,又符合文档流自上往下解析规则。浏览器先下载 HTML 文件开始解析,遇到 CSS 标签就开始下载 CSS 并解析,这个过程不会阻塞 DOM 的构建。最后 DOM 树和 CSS 规则树生成渲染树,HTML 解析完成。

注意几点:

  1. CSS 加载不会阻塞 DOM 树的解析
  2. CSS 加载会阻塞 DOM 树的渲染(不阻塞解析),即合并成渲染树的过程,因为样式可能会导致重排重绘
  3. CSS 加载会阻塞后面 JS 语句的执行

解析、渲染这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的 html 都解析完成之后再去构建和布局 render 树。它是解析完一部分内容就显示一部分内容,同时还可能通过网络下载其余内容。

1.1 HTML Parser

1.1.1 解析

解析的目的是:将文档转换为具有一定意义的结构,编码时可以理解和使用的东西。解析的结果通常是表达文档结构的节点树,称为解析树或语法树。

解析的目的在于让目标更接近于需要使用的形式,或更具体的描述一些信息。如高级语言先转换为汇编等低级语言,最后再转换成机器码,又或者是家具的运输与组装,零件形式更接近于运输模式,成品形式更接近于使用模式,由成品拆分成零件就是使其更接近于运输模式,同样的道理将文档解析成另外一个格式会更适合某一个场景的使用。

如解析“2+3-1”这个表达式,可能返回这样一棵树。

image.png

解析过程与翻译过程类似,需要具备两个条件,字典和语法规则。

文法(Grammars):解析基于文档依据的语法规则——文档的语言或格式。每种可被解析的格式必须具有由词汇及语法规则组成的特定的文法,称为上下文无关文法。简单来说就是由词汇表、语法规则组成的对某一个格式的限制,与当前文件内容无关。

文法不等于语法规则,文法是词汇表和语法规则的结合,如语法规则定义为“字后面跟着表示事物的名词,字跟在动词后面,字后面跟着形容词”,词汇表则有“谁,得,的,地,花,走,快”,文法规则就是词汇表和语法规则的结合,包含任何一个可能选择,如“谁的花,走得快,的,得,地”等。

解析可以分成两个子过程:

  • 词法分析
  • 语法分析

词法分析是将文档中的字词按照一定地规则(词汇表)分成符号,符号对应的便是词汇表(基本有效单元的集合)中的某一项。对于人类语言来说,它相当于我们字典中出现的所有单词。

语法分析是根据语法规则匹配词法分析后的符号,生成特定状态的过程。

词法分析器(有时也叫分词器)负责将输入分解为合法的符号,语法分析器则根据语言的语法规则分析文档结构,从而构建解析树,词法分析器知道怎么跳过空白和换行之类的无关字符。

image.png

解析过程是迭代的,语法分析器从词法分析器处取到一个新的符号,并试着用这个符号匹配一条语法规则,如果匹配了一条规则,这个符号对应的节点将被添加到解析树上,然后语法分析器分析下一个符号。如果没有匹配到规则,语法分析器将在内部保存该符号,并从词法分析器取下一个符号,直到所有内部保存的符号能够匹配一项语法规则。如果最终没有找到匹配的规则,解析器将抛出一个异常,这意味着文档无效或是包含语法错误。

词法分析器和语法分析器的工作是同步的,正如组装家具时,找到一个一个零件,根据说明书进行组装,如果没有找齐零件,是无法组装某一个个体的。

1.1.2 转换

转换对工作人员来说是一个隐式的过程,正如在浏览器开发者控制台运行一些代码、打印日志信息一样,开发者可能感受不到转换到机器码的过程。但是编写的源代码并不是最终的目标,底层机器码才是机器识别、运行的格式。

很多时候,解析树并不是最终结果。解析一般在转换中使用——将输入文档转换为另一种格式。编译就是个例子,编译器在将一段源码编译为机器码的时候,先将源码解析为解析树,然后将该树转换为一个机器码文档。

简单来说转换过程在编译运行时就存在,将源代码转换为机器码,转换时是无感的。

image.png

详细的例子可以看看:前端必读:浏览器内部工作原理_知识库_博客园 (cnblogs.com),如不熟悉抽象语法树的概念,可以看看:手把手带你入门 AST 抽象语法树 - 掘金 (juejin.cn)

1.1.3 深入

HTML解析器(HTML Parser):HTML解析器的工作是将html标识解析为解析树。

HTML文法定义(The HTML grammar definition):W3C组织制定规范定义了HTML的词汇表和语法。

非上下文无关文法(Not a context free grammar):正如在解析简介中提到的,上下文无关文法的语法可以用类似BNF的格式来定义。

HTML 有一个正式的格式定义—— DTD(Document Type Definition文档类型定义),但它并不是上下文无关文法,HTML 更接近于 XML,现在有很多可用的xml 解析器,html 有个 xml 的变体——xhtml,不同在于 html 更宽容,它允许忽略一些特定标签,有时可以省略开始或结束标签。总的来说,它是一种 soft 语法,不像 xml 呆板、固执。

显然,这个看起来很小的差异却带来了很大的不同。一方面,这是 html 流行的原因——它的宽容使 web 开发人员的工作更加轻松,但另一方面,这也使很难去写一个格式化的文法。所以,html 的解析并不简单,它既不能用传统的解析器解析,也不能用 xml 解析器解析。

HTML DTD

html 适用 DTD 格式进行定义,这一格式是用于定义 SGML 家族的语言,包括了对所有允许元素及它们的属性和层次关系的定义。正如前面提到的,html DTD 并没有生成一种上下文无关文法。DTD 有一些变种,标准模式只遵守规范,而其他模式则包含了对浏览器过去所使用标签的支持,这么做是为了兼容以前内容。最新的标准 DTD 在http://www.w3.org/TR/html4/strict.dtd

DOM

解析输出的树也就是解析树,是由 DOM 元素及属性节点组成的。DOM 是文档对象模型的缩写,它是 html 文档的对象表示,作为 html 元素的外部接口供 JS 等调用。DOM 和标签基本是一一对应的关系。

解析器类型(Types of parsers)

有两种基本的解析器——自顶向下解析及自底向上解析。比较直观的解释是,自顶向下解析,查看语法的最高层结构并试着匹配其中一个;自底向上解析则从输入开始,逐步将其转换为语法规则,从底层规则开始直到匹配高层规则。

来看一下这两种解析器如何解析 “2+3-1” 的例子:自顶向下解析器从最高层规则开始——它先识别出“2+3”,将其视为一个表达式,然后识别出 “2+3-1” 为一个表达式(识别表达式的过程中匹配了其他规则,但出发点是最高层规则)。自底向上解析会扫描输入直到匹配了一条规则,然后用该规则取代匹配的输入,直到解析完所有输入。部分匹配的表达式被放置在解析堆栈中。

自顶向下与递归过程类似,从整体开始逐步检查子问题,而自底向上与递推过程类似,从子问题开始推向整体。

自动化解析(Generating parsers automatically)

解析器生成器这个工具可以自动生成解析器,只需要指定语言的文法——词汇表及语法规则,它就可以生成一个解析器。创建一个解析器需要对解析有深入的理解,而且手动的创建一个由较好性能的解析器并不容易,所以解析生成器很有用。Webkit使用两个知名的解析生成器——用于创建语法分析器的Flex创建解析器的Bison(你可能接触过Lex和Yacc)。Flex的输入是一个包含了符号定义的正则表达式,Bison的输入是用BNF格式表示的语法规则。

稍微总结:HTML 解析器按照 HTML DTD 文法定义进行 HTML 解析,可以认为解析生成的 DOM 树与解析树一一映射,DOM 树就是解析树。

1.1.4 解析算法

HTML 不能被一般的自顶向下或自底向上的解析器所解析,不能使用正则解析技术,浏览器为 HTML 定制了专属的解析器,原因是:

  1. 这门语言本身的宽容特性

  2. 浏览器对一些常见的非法html有容错机制

  3. 解析过程是往复的,通常源码不会在解析过程中发生改变,但在 HTML 中,脚本标签包含的 “document.write” 可能添加标签,这说明在解析过程中实际上修改了输入,所以不能使用正则解析技术

HTML5 规范中描述了这个解析算法,算法包括两个阶段——符号化及构建树。

符号化是词法分析的过程,将输入解析为符号,HTML 的符号包括开始标签、结束标签、属性名及属性值。符号识别器识别出符号后,将其传递给树构建器,并读取下一个字符,以识别下一个符号,直到处理完所有输入。

符号识别算法(The tokenization algorithm)

算法输出 HTML 符号,该算法用状态机表示。每次读取输入流中的一个或多个字符,并根据这些字符转移到下一个状态,当前的符号状态及构建树状态共同影响结果,这意味着,读取同样的字符,可能因为当前状态的不同,得到不同的结果以进入下一个正确的状态。

这个算法很复杂,这里用一个简单的例子来解释这个原理。

1
2
3
4
5
<html>
<body>
Hello world
</body>
</html>

初始状态为“Data State”,当遇到“<”字符,状态变为“Tag open state”,读取一个a-z的字符将产生一个开始标签符号,状态相应变为“Tag name state”,一直保持这个状态直到读取到“>”,每个字符都附加到这个符号名上,例子中创建的是一个html符号。

当读取到“>”,当前的符号就完成了,此时状态回到“Data state”,“”重复这一处理过程。到这里,html和body标签都识别出来了。现在,回到“Data state”,读取“Hello world”中的字符“H”将创建并识别出一个字符符号,这里会为“Hello world”中的每个字符生成一个字符符号

这样直到遇到“”中的“<”。现在,又回到了“Tag open state”,读取下一个字符“/”将创建一个闭合标签符号,并且状态转移到“Tag name state”,还是保持这一状态,直到遇到“>”。然后,产生一个新的标签符号并回到“Data state”。后面的“”将和“”一样处理。

image.png

简单总结:因为 HTML 符号包括开始标签、结束标签、属性名及属性值,所以解析每一个符号都需要三个状态来决定。状态机+符号化+树构建器。符号并不仅仅只是一个字符,可以是标签符号包括标签名、属性等,除标签符号外,为每一个内容字符创建一个字符符号(Data State)。

树的构建算法(Tree construction algorithm)

在树的构建阶段,将修改以Document为根的DOM树,将元素附加到树上。每个由符号识别器识别生成的节点将会被树构造器进行处理,规范中定义了每个符号相对应的Dom元素,对应的Dom元素将会被创建。这些元素除了会被添加到Dom树上,还将被添加到开放元素堆栈中。这个堆栈用来纠正嵌套的未匹配和未闭合标签,这个算法也是用状态机来描述,所有的状态采用插入模式。

首先是“initial mode”,接收到html符号后将转换为“before html”模式,在这个模式中对这个符号进行再处理。此时,创建了一个HTMLHtmlElement元素,并将其附加到根Document对象上。

状态此时变为“before head”,接收到body符号时,即使这里没有head符号,也将自动创建一个HTMLHeadElement元素并附加到树上。

现在,转到“in head”模式,然后是“after head”。到这里,body符号会被再次处理,将创建一个HTMLBodyElement并插入到树中,同时,转移到“in body”模式。

然后,接收到字符串“Hello world”的字符符号,第一个字符将导致创建并插入一个text节点,其他字符将附加到该节点。

接收到body结束符号时,转移到“after body”模式,接着接收到html结束符号,这个符号意味着转移到了“after after body”模式,当接收到文件结束符时,整个解析过程结束。

image.png

简单总结:树构建器根据符号创建对应的DOM元素,附加到以Document为根的DOM树上,并对一些常见的 HTML 错误进行处理。

1.1.5 解析结束时的处理(Action when the parsing is finished)

在这个阶段,浏览器将文档标记为可交互的,并开始解析处于延时模式中的脚本——这些脚本在文档解析后执行。文档状态将被设置为完成,同时触发一个 load 事件。

HTML5 规范中有符号化及构建树的完整算法:链接

1.1.6 浏览器容错(Browsers error tolerance)

详细可查看原文:前端必读:浏览器内部工作原理_知识库_博客园 (cnblogs.com)

1.2 CSS Parser

CSS 的加载并不会阻塞 DOM 的解析,但是会阻塞 DOM 的渲染。因为加载 CSS 时,可能会修改当前解析位置以后的 DOM 节点的样式,如果 CSS 加载不阻塞 DOM 树渲染,那么当 CSS 加载完之后,DOM 树可能又得重新重排 layout(回流reflow)或者重绘了,这就造成了一些没有必要的损耗。

不同于HTML,CSS属于上下文无关文法,可以用前面所描述的解析器来解析。CSS规范定义了CSS的词法及语法文法。可以参考词汇表和语法规则:前端必读:浏览器内部工作原理_知识库_博客园 (cnblogs.com)

每个符号都由正则表达式定义了词法文法(词汇表):

1
2
3
4
5
6
7
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”是识别器的缩写,相当于一个class名,“name”是一个元素id(用“#”引用)。

语法用BNF进行描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
ruleset
: selector [ ',' S* selector ]*
'{' S* declaration [ ';' S* declaration ]* '}' S*
;
selector
: simple_selector [ combinator selector | S+ [ combinator selector ] ]
;
class
: '.' IDENT
;
element_name
: IDENT | '*'
;

一个规则集合具有一个或是可选个数的多个选择器,这些选择器以逗号和空格(S表示空格)进行分隔。每个规则集合包含大括号及大括号中的一条或多条以分号隔开的声明。声明和选择器在后面进行定义。

1.2.1 Webkit CSS解析器(Webkit CSS parser)

Webkit使用Flex和Bison解析生成器从CSS语法文件中自动生成解析器。回忆一下解析器的介绍,Bison创建一个自底向上的解析器,Firefox使用自顶向下解析器。它们都是将每个css文件解析为样式表对象,每个对象包含css规则,css规则对象包含选择器和声明对象,以及其他一些符合css语法的对象。

用于创建语法分析器的Flex及创建解析器的Bison。Flex的输入是一个包含了符号定义的正则表达式,Bison的输入是用BNF格式表示的语法规则。

image.png

注意:WebKit使用的是自底向上的解析器

处理脚本及样式表的顺序(The order of processing scripts and style sheets)

脚本:web的模式是同步的,开发者希望解析到一个script标签时立即解析执行脚本,并阻塞文档的解析直到脚本执行完。如果脚本是外引的,则网络必须先请求到这个资源——这个过程也是同步的,会阻塞文档的解析直到资源被请求到。这个模式保持了很多年,并且在html4及html5中都特别指定了。开发者可以将脚本标识为defer,以使其不阻塞文档解析,并在文档解析结束后执行。HTML5增加了标记脚本为异步的选项,以使脚本的解析执行使用另一个线程。

async是在外部JS加载完成后,浏览器空闲时,Load事件触发前执行;而Defer是在JS加载完成后,整个文档解析完成后执行。 Defer更像是将<script>标签放在</body>之后的效果,但是它由于是异步加载JS文件,所以可以节省时间。简单来说就是async和defer都是开启新线程(http线程)请求JS文件,和解析DOM并行不冲突,async在请求完成后就开始执行JS文件,而defer则是在整个DOM解析完成后再执行JS文件。

预解析(Speculative parsing)

Webkit和Firefox都做了这个优化,当执行脚本时,另一个线程解析剩下的文档,并加载后面需要通过网络加载的资源。这种方式可以使资源并行加载从而使整体速度更快。需要注意的是,预解析并不改变Dom树,它将这个工作留给主解析过程,自己只解析外部资源的引用,比如外部脚本、样式表及图片。

StyleSheets样式表采用另一种模式。理论上,既然样式表不改变Dom树,也就没有必要停下文档的解析等待它们,然而存在一个问题,脚本可能在文档的解析过程中请求样式信息,如果样式还没有加载和解析,脚本将得到错误的值,显然这将会导致很多问题,这看起来是个边缘情况,但确实很常见。Firefox在存在样式表还在加载和解析时阻塞所有的脚本,而Chrome只在当脚本试图访问某些可能被未加载的样式表所影响的特定的样式属性时才阻塞这些脚本。

简单总结:WebKit用Flex生成CSS语法规则,Bison生成CSS解析器。样式表不阻塞DOM解析,但是阻塞DOM渲染,FireFox在样式文件未加载完成前阻塞JS脚本,目的是防止样式文件未加载完成而JS脚本需要获取样式文件信息的错误,Chrome只阻塞那些访问未加载完成的样式文件的JS脚本,样式加载完成即不堵塞。