重学前端(4)- 浏览器是如何工作的(2)
今天我们主要来看两个过程:如何解析请求回来的 HTML 代码,DOM 树又是如何构建的。
HTML 的结构不算太复杂,我们日常开发需要的 90% 的“词”(指编译原理的术语 token,表示最小的有意义的单元),种类大约只有标签开始、属性、标签结束、注释、CDATA 节点几种。
实际上有点麻烦的是,由于 HTML 跟 SGML 的千丝万缕的联系,我们需要做不少容错处理。“<?”和“<%”什么的也是必须要支持好的,报了错也不能吭声。
1. 词(token)是如何被拆分的
首先我们来看看一个非常标准的标签,会被如何拆分:
<p class="a">text text text</p>
如果我们从最小有意义单元的定义来拆分,第一个词(token)是什么呢?显然,作为一个词(token),整个 p 标签肯定是过大了(它甚至可以嵌套)。那么,只用 p 标签的开头是不是合适吗?我们考虑到起始标签也是会包含属性的,最小的意义单元其实是“<p” ,所以“ <p” 就是我们的第一个词(token)。我们继续拆分,可以把这段代码依次拆成词(token):
- <p“标签开始”的开始;
- class=“a” 属性;
- > “标签开始”的结束;
- text text text 文本;
- </p> 标签结束。
这是一段最简单的例子,类似的还有什么呢?现在我们可以来来看看这些词(token)长成啥样子:
根据这样的分析,现在我们讲讲浏览器是如何用代码实现,我们设想,代码开始从 HTTP 协议收到的字符流读取字符。
在接受第一个字符之前,我们完全无法判断这是哪一个词(token),不过,随着我们接受的字符越来越多,拼出其他的内容可能性就越来越少。比如,假设我们接受了一个字符“ < ” 我们一下子就知道这不是一个文本节点啦。之后我们再读一个字符,比如就是 x,那么我们一下子就知道这不是注释和 CDATA 了,接下来我们就一直读,直到遇到“>”或者空格,这样就得到了一个完整的词(token)了。
实际上,我们每读入一个字符,其实都要做一次决策,而且这些决定是跟“当前状态”有关的。在这样的条件下,浏览器工程师要想实现把字符流解析成词(token),最常见的方案就是使用状态机。
2. 状态机
绝大多数语言的词法部分都是用状态机实现的。那么我们来把部分词(token)的解析画成一个状态机看看:
这里我们为了理解原理,用这个简单的状态机就足够说明问题了。
状态机的初始状态,我们仅仅区分 “< ”和 “非 <”:
- 如果获得的是一个非 < 字符,那么可以认为进入了一个文本节点;
- 如果获得的是一个 < 字符,那么进入一个标签状态。
不过当我们在标签状态时,则会面临着一些可能性。
- 比如下一个字符是“ ! ” ,那么很可能是进入了注释节点或者 CDATA 节点。
- 如果下一个字符是 “/ ”,那么可以确定进入了一个结束标签。
- 如果下一个字符是字母,那么可以确定进入了一个开始标签。
- 如果我们要完整处理各种 HTML 标准中定义的东西,那么还要考虑“ ? ”“% ”等内容。
我们可以看到,用状态机做词法分析,其实正是把每个词的“特征字符”逐个拆开成独立状态,然后再把所有词的特征字符链合并起来,形成一个联通图结构。
构建 DOM 树
我们这样来设计 HTML 的语法分析器,receiveInput 负责接收词法部分产生的词(token),通常可以由 emmitToken 来调用。
在接收的同时,即开始构建 DOM 树,所以我们的主要构建 DOM 树的算法,就写在 receiveInput 当中。当接收完所有输入,栈顶就是最后的根节点,我们 DOM树的产出,就是这个 stack 的第一项。
为了构建 DOM 树,我们需要一个 Node 类,接下来我们所有的节点都会是这个 Node 类的实例。
在完全符合标准的浏览器中,不一样的 HTML 节点对应了不同的 Node 的子类。
前面我们的词(token)中,以下两个是需要成对匹配的:
- tag start
- tag end
构建 DOM 树:
- 栈顶元素就是当前节点;
- 遇到属性,就添加到当前节点;
- 遇到文本节点,如果当前节点是文本节点,则跟文本节点合并,否则入栈成为当前节点的子节点;
- 遇到注释节点,作为当前节点的子节点;
- 遇到 tag start 就入栈一个节点,当前节点就是这个节点的父节点;
- 遇到 tag end 就出栈一个节点(还可以检查是否匹配)。
最后
在解析代码的环节里,我们一起详细地分析了一个词(token)被拆分的过程。
在构建 DOM 树的环节中,基本思路是使用栈来构建 DOM 树