通过 学习vue源码(6)熟悉模板编译原理的学习,我们知道解析器在整个模板编译中的位置。我们只有将模板解析成AST后,才能基于AST做优化或者生成代码字符串,那么解析器是如何将模板解析成AST的呢?
这次,我们将详细介绍解析器内部的运行原理。
1 解析器的作用
解析器要实现的功能是将模板解析成AST。
例如:
<div>
<p>{{name}}</p>
</div>
上面的代码是一个比较简单的模板,它转换成AST后的样子如下:
{
tag: "div"
type: 1,
staticRoot: false,
static: false,
plain: true,
parent: undefined,
attrsList: [],
attrsMap: {},
children: [
{
tag: "p"
type: 1,
staticRoot: false,
static: false,
plain: true,
parent: {tag: "div", ...},
attrsList: [],
attrsMap: {},
children: [{
type: 2,
text: "{{name}}",
static: false,
expression: "_s(name)"
}]
}
]
}
其实AST并不是什么很神奇的东西,不要被它的名字吓倒。它只是用JS中的对象来描述一个节点,一个对象代表一个节点,对象中的属性用来保存节点所需的各种数据。比如,parent属性保存了父节点的描述对象,children属性是一个数组,里面保存了一些子节点的描述对象。再比如,type属性代表一个节点的类型等。当很多个独立的节点通过parent属性和children属性连在一起时,就变成了一个树,而这样一个用对象描述的节点树其实就是AST。
2 解析器内部运行原理
事实上,解析器内部也分了好几个子解析器,比如HTML解析器、文本解析器以及过滤器解析器,其中最主要的是HTML解析器。顾名思义,HTML解析器的作用是解析HTML,它在解析HTML的过程中会不断触发各种钩子函数。这些钩子函数包括开始标签钩子函数、结束标签钩子函数、文本钩子函数以及注释钩子函数。
伪代码如下:
parseHTML(template, {
start (tag, attrs, unary) {
// 每当解析到标签的开始位置时,触发该函数
},
end () {
// 每当解析到标签的结束位置时,触发该函数
},
chars (text) {
// 每当解析到文本时,触发该函数
},
comment (text) {
// 每当解析到注释时,触发该函数
}
})
你可能不能很清晰地理解,下面我们举个简单的例子:
<div><p>我是Berwin</p></div>
当上面这个模板被HTML解析器解析时,所触发的钩子函数依次是:start、start、chars、end、end。
也就是说,解析器其实是从前向后解析的。解析到<div>
时,会触发一个标签开始的钩子函数start;然后解析到<p>
时,又触发一次钩子函数start;接着解析到我是Berwin这行文本,此时触发了文本钩子函数chars;然后解析到</p>
,触发了标签结束的钩子函数end;接着继续解析到</div>
,此时又触发一次标签结束的钩子函数end,解析结束。
因此,我们可以在钩子函数中构建AST节点。在start钩子函数中构建元素类型的节点,在chars钩子函数中构建文本类型的节点,在comment钩子函数中构建注释类型的节点。
当HTML解析器不再触发钩子函数时,就代表所有模板都解析完毕,所有类型的节点都在钩子函数中构建完成,即AST构建完成。
我们发现,钩子函数start有三个参数,分别是tag、attrs和unary,它们分别代表标签名、标签的属性以及是否是自闭合标签。
而文本节点的钩子函数chars和注释节点的钩子函数comment都只有一个参数,只有text。这是因为构建元素节点时需要知道标签名、属性和自闭合标识,而构建注释节点和文本节点时只需要知道文本即可。
什么是自闭合标签?举个简单的例子,input标签就属于自闭合标签:
<input type="text" />
,而div标签就不属于自闭合标签:
<div></div>。
在start钩子函数中,我们可以使用这三个参数来构建一个元素类型的AST节点,例如:
function createASTElement (tag, attrs, parent) {
return {
type: 1,
tag,
attrsList: attrs,
parent,
children: []
}
}
parseHTML(template, {
start (tag, attrs, unary) {
let element = createASTElement(tag, attrs, currentParent)
}
})
在上面的代码中,我们在钩子函数start中构建了一个元素类型的AST节点。
如果是触发了文本的钩子函数,就使用参数中的文本构建一个文本类型的AST节点,例如:
parseHTML(template, {
chars (text) {
let element = {type: 3, text}
}
})
如果是注释,就构建一个注释类型的AST节点,例如:
parseHTML(template, {
comment (text) {
let element = {type: 3, text, isComment: true}
}
})
你会发现,看到的AST是有层级关系的,一个AST节点具有父节点和子节点,但是shang介绍的创建节点的方式,节点是被拉平的,没有层级关系。因此,我们需要一套逻辑来实现层级关系,让每一个AST节点都能找到它的父级。下面我们介绍一下如何构建AST层级关系。
构建AST层级关系其实非常简单,我们只需要维护一个栈(stack)即可,用栈来记录层级关系,这个层级关系也可以理解为DOM的深度。
HTML解析器在解析HTML时,是从前向后解析。每当遇到开始标签,就触发钩子函数start。每当遇到结束标签,就会触发钩子函数end。
基于HTML解析器的逻辑,我们可以在每次触发钩子函数start时,把当前构建的节点推入栈中;每当触发钩子函数end时,就从栈中弹出一个节点。
这样就可以保证每当触发钩子函数start时,栈的最后一个节点就是当前正在构建的节点的父节点,如图1所示。
图1 使用栈记录DOM层级关系(英文为代码体)
下面我们用一个具体的例子来描述如何从0到1构建一个带层级关系的AST。
假设有这样一个模板:
<div>
<h1>我是Berwin</h1>
<p>我今年23岁</p>
</div>
上面这个模板被解析成AST的过程如图9-2所示。
图9-2给出了构建AST的过程,图中的黑底白数字代表解析的步骤,具体如下。
(1) 模板的开始位置是div的开始标签,于是会触发钩子函数start。start触发后,会先构建一个div节点。此时发现栈是空的,这说明div节点是根节点,因为它没有父节点。最后,将div节点推入栈中,并将模板字符串中的div开始标签从模板中截取掉。
(2) 这时模板的开始位置是一些空格,这些空格会触发文本节点的钩子函数,在钩子函数里会忽略这些空格。同时会在模板中将这些空格截取掉。
(3) 这时模板的开始位置是h1的开始标签,于是会触发钩子函数start。与前面流程一样,start触发后,会先构建一个h1节点。此时发现栈的最后一个节点是div节点,这说明h1节点的父节点是div,于是将h1添加到div的子节点中,并且将h1节点推入栈中,同时从模板中将h1的开始标签截取掉。
(4) 这时模板的开始位置是一段文本,于是会触发钩子函数chars。chars触发后,会先构建一个文本节点,此时发现栈中的最后一个节点是h1,这说明文本节点的父节点是h1,于是将文本节点添加到h1节点的子节点中。由于文本节点没有子节点,所以文本节点不会被推入栈中。最后,将文本从模板中截取掉。
(5) 这时模板的开始位置是h1结束标签,于是会触发钩子函数end。end触发后,会把栈中最后一个节点弹出来。
(6) 与第(2)步一样,这时模板的开始位置是一些空格,这些空格会触发文本节点的钩子函数,在钩子函数里会忽略这些空格。同时会在模板中将这些空格截取掉。
(7) 这时模板的开始位置是p开始标签,于是会触发钩子函数start。start触发后,会先构建一个p节点。由于第(5)步已经从栈中弹出了一个节点,所以此时栈中的最后一个节点是div,这说明p节点的父节点是div。于是将p推入div的子节点中,最后将p推入到栈中,并将p的开始标签从模板中截取掉。
(8) 这时模板的开始位置又是一段文本,于是会触发钩子函数chars。当chars触发后,会先构建一个文本节点,此时发现栈中的最后一个节点是p节点,这说明文本节点的父节点是p节点。于是将文本节点推入p节点的子节点中,并将文本从模板中截取掉。
(9) 这时模板的开始位置是p的结束标签,于是会触发钩子函数end。当end触发后,会从栈中弹出一个节点出来,也就是把p标签从栈中弹出来,并将p的结束标签从模板中截取掉。
(10) 与第(2)步和第(6)步一样,这时模板的开始位置是一些空格,这些空格会触发文本节点的钩子函数并且在钩子函数里会忽略这些空格。同时会在模板中将这些空格截取掉。
(11) 这时模板的开始位置是div的结束标签,于是会触发钩子函数end。其逻辑与之前一样,把栈中的最后一个节点弹出来,也就是把div弹了出来,并将div的结束标签从模板中截取掉。
(12)这时模板已经被截取空了,也就代表着HTML解析器已经运行完毕。这时我们会发现栈已经空了,但是我们得到了一个完整的带层级关系的AST语法树。这个AST中清晰写明了每个节点的父节点、子节点及其节点类型。
3 HTML解析器
通过前面的介绍,我们发现构建AST非常依赖HTML解析器所执行的钩子函数以及钩子函数中所提供的参数,你一定会非常好奇HTML解析器是如何解析模板的,接下来我们会详细介绍HTML解析器的运行原理。
1 运行原理
事实上,解析HTML模板的过程就是循环的过程,简单来说就是用HTML模板字符串来循环,每轮循环都从HTML模板中截取一小段字符串,然后重复以上过程,直到HTML模板被截成一个空字符串时结束循环,解析完毕,如图9-2所示。
在截取一小段字符串时,有可能截取到开始标签,也有可能截取到结束标签,又或者是文本或者注释,我们可以根据截取的字符串的类型来触发不同的钩子函数。
循环HTML模板的伪代码如下:
function parseHTML(html, options) {
while (html) {
// 截取模板字符串并触发钩子函数
}
}
为了方便理解,我们手动模拟HTML解析器的解析过程。例如,下面这样一个简单的HTML模板:
<div>
<p>{{name}}</p>
</div>
它在被HTML解析器解析的过程如下。
最初的HTML模板:
`<div>
<p>{{name}}</p>
</div>`
第一轮循环时,截取出一段字符串
,并且触发钩子函数start,截取后的结果为:
`
<p>{{name}}</p>
</div>`
第二轮循环时,截取出一段字符串:
并且触发钩子函数chars,截取后的结果为:
`<p>{{name}}</p>
</div>`
第三轮循环时,截取出一段字符串
,并且触发钩子函数start,截取后的结果为:
`{{name}}</p>
</div>`
第四轮循环时,截取出一段字符串{{name}},并且触发钩子函数chars,截取后的结果为:
`</p>
</div>`
第五轮循环时,截取出一段字符串
,并且触发钩子函数end,截取后的结果为:
`
</div>`
第六轮循环时,截取出一段字符串:
`
`
并且触发钩子函数chars,截取后的结果为:
`</div>`
第七轮循环时,截取出一段字符串
,并且触发钩子函数end,截取后的结果为:
``
解析完毕。
HTML解析器的全部逻辑都是在循环中执行,循环结束就代表解析结束。接下来,我们要讨论的重点是HTML解析器在循环中都干了些什么事。
你会发现HTML解析器可以很聪明地知道它在每一轮循环中应该截取哪些字符串,那么它是如何做到这一点的呢?
通过前面的例子,我们发现一个很有趣的事,那就是每一轮截取字符串时,都是在整个模板的开始位置截取。我们根据模板开始位置的片段类型,进行不同的截取操作。
例如,上面例子中的第一轮循环:如果是以开始标签开头的模板,就把开始标签截取掉。再例如,上面例子中的第四轮循环:如果是以文本开始的模板,就把文本截取掉。
这些被截取的片段分很多种类型,示例如下。
- 开始标签,例如
<div>
。 - 结束标签,例如
</div>
。 - HTML注释,例如
<!-- 我是注释 -->
。 - DOCTYPE,例如
<!DOCTYPE html>
。 - 条件注释,例如
<!--[if !IE]>-->我是注释<!--<![endif]-->
。 - 文本,例如
我是Berwin
。 - 通常,最常见的是开始标签、结束标签、文本以及注释。
2 截取开始标签
上一节中我们说过,每一轮循环都是从模板的最前面截取,所以只有模板以开始标签开头,才需要进行开始标签的截取操作。
那么,如何确定模板是不是以开始标签开头?
在HTML解析器中,想分辨出模板是否以开始标签开头并不难,我们需要先判断HTML模板是不是以<开头。
如果HTML模板的第一个字符不是<,那么它一定不是以开始标签开头的模板,所以不需要进行开始标签的截取操作。
如果HTML模板以<开头,那么说明它至少是一个以标签开头的模板,但这个标签到底是什么类型的标签,还需要进一步确认。
如果模板以<开头,那么它有可能是以开始标签开头的模板,同时它也有可能是以结束标签开头的模板,还有可能是注释等其他标签,因为这些类型的片段都以<开头。那么,要进一步确定模板是不是以开始标签开头,还需要借助正则表达式来分辨模板的开始位置是否符合开始标签的特征。
那么,如何使用正则表达式来匹配模板以开始标签开头?我们看下面的代码:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
// 以开始标签开始的模板
'<div></div>'.match(startTagOpen) // ["<div", "div", index: 0, input: "<div></div>"]
// 以结束标签开始的模板
'</div><div>我是Berwin</div>'.match(startTagOpen) // null
// 以文本开始的模板
'我是Berwin</p>'.match(startTagOpen) // null
通过上面的例子可以看到,只有'<div></div>'
可以成功匹配,而以</div>
开头的或者以文本开头的模板都无法成功匹配。
我们介绍了当HTML解析器解析到标签开始时,会触发钩子函数start,同时会给出三个参数,分别是标签名(tagName)、属性(attrs)以及自闭合标识(unary)。
因此,在分辨出模板以开始标签开始之后,需要将标签名、属性以及自闭合标识解析出来。
在分辨模板是否以开始标签开始时,就可以得到标签名,而属性和自闭合标识则需要进一步解析。
当完成上面的解析后,我们可以得到这样一个数据结构:
const start = '<div></div>'.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: []
}
}
这里有一个细节很重要:在前面的例子中,我们匹配到的开始标签并不全。例如:
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
'<div></div>'.match(startTagOpen)
// ["<div", "div", index: 0, input: "<div></div>"]
'<p></p>'.match(startTagOpen)
// ["<p", "p", index: 0, input: "<p></p>"]
'<div class="box"></div>'.match(startTagOpen)
// ["<div", "div", index: 0, input: "<div class="box"></div>"]
可以看出,上面这个正则表达式虽然可以分辨出模板是否以开始标签开头,但是它的匹配规则并不是匹配整个开始标签,而是开始标签的一小部分。
事实上,开始标签被拆分成三个小部分,分别是标签名、属性和结尾,如图3所示。图3 开始标签被拆分成三个小部分(代码用代码体)
通过“标签名”这一段字符,就可以分辨出模板是否以开始标签开头,此后要想得到属性和自闭合标识,则需要进一步解析。1. 解析标签属性在分辨模板是否以开始标签开头时,会将开始标签中的标签名这一小部分截取掉,因此在解析标签属性时,我们得到的模板是下面伪代码中的样子:
' class="box"></div>'
通常,标签属性是可选的,一个标签的属性有可能存在,也有可能不存在,所以需要判断标签是否存在属性,如果存在,对它进行截取。
下面的伪代码展示了如何解析开始标签中的属性,但是它只能解析一个属性:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = ' class="box"></div>'
let attr = html.match(attribute)
html = html.substring(attr[0].length)
console.log(attr)
// [' class="box"', 'class', '=', 'box', undefined, undefined, index: 0, input: ' class="box"></div>']
如果标签上有很多属性,那么上面的处理方式就不足以支撑解析任务的正常运行。例如下面的代码:
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = ' class="box" id="el"></div>'
let attr = html.match(attribute)
html = html.substring(attr[0].length)
console.log(attr)
// [' class="box"', 'class', '=', 'box', undefined, undefined, index: 0, input: ' class="box" id="el"></div>']
可以看到,这里只解析出了class属性,而id属性没有解析出来。
此时剩余的HTML模板是这样的:
' id="el"></div>'
所以属性也可以分成多个小部分,一小部分一小部分去解析与截取。
解决这个问题时,我们只需要每解析一个属性就截取一个属性。如果截取完后,剩下的HTML模板依然符合标签属性的正则表达式,那么说明还有剩余的属性需要处理,此时就重复执行前面的流程,直到剩余的模板不存在属性,也就是剩余的模板不存在符合正则表达式所预设的规则。
例如:
const startTagClose = /^\s*(\/?)>/
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
let html = ' class="box" id="el"></div>'
let end, attr
const match = {tagName: 'div', attrs: []}
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
html = html.substring(attr[0].length)
match.attrs.push(attr)
}
上面这段代码的意思是,如果剩余HTML模板不符合开始标签结尾部分的特征,并且符合标签属性的特征,那么进入到循环中进行解析与截取操作。
通过match方法解析出的结果为:
{
tagName: 'div',
attrs: [
[' class="box"', 'class', '=', 'box', null, null],
[' id="el"', 'id','=', 'el', null, null]
]
}
可以看到,标签中的两个属性都已经解析好并且保存在了attrs中。
此时剩余模板是下面的样子:
"></div>"
我们将属性解析后的模板与解析之前的模板进行对比:
// 解析前的模板
' class="box" id="el"></div>'
// 解析后的模板
'></div>'
// 解析前的数据
{
tagName: 'div',
attrs: []
}
// 解析后的数据
{
tagName: 'div',
attrs: [
[' class="box"', 'class', '=', 'box', null, null],
[' id="el"', 'id','=', 'el', null, null]
]
}
可以看到,标签上的所有属性都已经被成功解析出来,并保存在attrs属性中。