tip:本系列博客的代码部分(示例等除外),均出自vue源码内容,版本为2.6.14。但是为了增加易读性,会对不相关内容做选择性省略。如果大家想了解完整的源码,建议自行从官方下载。
【VUE】源码分析 - computed计算属性的实现原理_依然范特西fantasy的博客-CSDN博客
【VUE】源码分析 - watch侦听器的实现原理_依然范特西fantasy的博客-CSDN博客
【VUE】源码分析 - 数据劫持的基本原理_依然范特西fantasy的博客-CSDN博客
【VUE】源码分析 - 剖析$nextTick原理及VUE的刷新机制_依然范特西fantasy的博客-CSDN博客
引言
我们都知道,vue会生成vnode来代替DOM进行patch操作。而vnode其实就是render函数的产物。或许在使用vue的时候,你有通过render来代替template。那么你是否有思考过,二者之间又有什么奇妙的联系呢?
HTMLParser简介
HTMLParser并不是vue提出的概念。
简单来说,HTMLParser是浏览器的渲染进程中一个专门负责解析HTML文件的模块。浏览器并不能直接理解和使用HTML文档,因此就需要这样一种工具,能将HTML解析为浏览器能够理解的结构,也就是常说的DOM树。基于此DOM树,渲染引擎会继续做分层、绘制、合成等操作,最终将HTML转换为页面上一帧帧的内容。
vue只是借鉴了这样一种概念。但是区别在于,对于vue来说,最终目的并不是生成页面,而是生成render函数。而vue的后续操作,比如生成vnode,就是基于render函数进行的。
而要将template模板转换为render函数,抽象语法树AST就是绕不开的一个课题。AST的应用场景非常广泛,比如浏览器对JS的编译、babel对JS代码的转换、ESLint对代码的检测,都有AST的身影。对于vue来说,Parser的作用,就是将template模板转换为一颗AST,供后续生成render函数字符串使用。vue编译模板的大致流程,可以结合下图理解:
在浏览器中,HTMLParser代表的就是HTML解析为AST(也就是DOM树)的过程,包含词法分析和语法分析;而在vue中,将HTMLParser的概念做了简化,只代表词法分析阶段,也就是生成Token的过程。
构建AST的流程
首先通过浏览器中HTMLParser的大致流程,对AST的构建形成初步的认识:
1,将一个HTML标签分解为startTag,endTag和文本内容三部分对待;
2,创建一个栈结构,用来存放startTag;
3,遇到起始标签时入栈,遇到结束标签时出栈。每插入一个起始标签,都会以栈中上一个标签当作父节点;
4,对自闭合标签做特殊处理;
5,若出现嵌套问题,即结束标签与栈顶标签对应不上,则从栈顶一直循环出栈,直到找到相匹配的起始标签,并抛出错误;
用一个简单的例子来演示上述过程:
<div>
<div>123</div>
<span></span>
</div>
通过上述不断的出栈入栈,就能构建出一颗完整的AST结构。而vue构建AST也与之类似,只是在具体实现的细节上稍有出入。
vue中的HTMLParser
在vue的HTMLParser中,会将构建AST的任务通过钩子抛给parse函数去实现,而它的核心,就只需要通过template模板生成一个个Token,并将Token组装为相应的数据结构,传递给parse即可。
如上述代码截图,parse函数内部会调用parseHTML函数,并将start、end、chars、comment四个钩子函数通过参数的形式传递给parseHTML。而这四个钩子分别就对应着起始标签、结束标签、文本内容和注释内容的语法分析。
再来看HTMLParser的内部:
其中,html为template模板的字符串形式,stack为前面所说到的“栈结构”。而整个词法过程,都在这个while(html)语句中进行。整个过程,会从头开始解析html字符串,并逐渐截取掉已解析的html字符串,直到html字符串为空,也就代表整个html都解析完成。
至于vue是如何判断标签类型的,很简单,用正则表达式去匹配。关于vue中的正则表达式,我在如下的博客中有做介绍,感兴趣的可以点进去看一看:
【JS】正则表达式。以vue词法分析、句法分析为例_依然范特西fantasy的博客-CSDN博客
当然,不了解正则也没关系,你只需要知道它会匹配到相应的标签类型即可。
可以看到,在while语句中,就是通过正则的匹配,对不同的标签做相应的处理。
在本篇博客中,只会分析starTag和endTag的主流程。
startTag
我们先从起始标签看起。
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
continue
}
在对起始标签的处理中,会先去调用parseStartTag函数,该函数的代码如下:
function parseStartTag () {
const start = html.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
advance(start[0].length)
let end, attr
while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
attr.start = index
advance(attr[0].length)
attr.end = index
match.attrs.push(attr)
}
if (end) {
match.unarySlash = end[1]
advance(end[0].length)
match.end = index
return match
}
}
}
1,首先会去匹配正则表达式,如果能成功匹配,则该正则还会捕获tagName信息;
2,创建一个match字面量对象,先给tagName和start属性赋值。tagName就为正则所匹配的tagName,也就是当前匹配的标签名。而index即为当前标签处于html字符串的下标位置;
3,将html前进所匹配内容长度个字符。advance代码很简单,如下所示:
function advance(n) {
index += n;
html = html.substring(n);
}
4,对截取过后的html字符串,再做一个标签内部的while循环。而该循环的作用,简单来说,就是获取起始标签结束之前的所有属性内容(在起始标签的匹配中,也是分为了三部分:起始标签的开始startTagOpen,标签内的属性attribute,起始标签的结束startTagClose);
5,在标签内部的循环中,会通过正则获取到属性的信息,并将这些信息统统push到match对象的attr数组中。匹配的过程与主循环类似,都是匹配、捕获、截取(html前进)。直到匹配到startTagClose,退出标签的内部循环;
6,退出parseStartTag函数,并返回match对象。同样,需要做advance将html截取掉解析的部分。而在此给match对象添加上的unarySlash属性,即为通过startTagClose捕获到的 > 之前的 / 。如果该值为 / ,则代表标签自闭合,否则则代表为普通标签。
而最终返回的match对象,给大家一个实例:
'<div v-if="isSucceed" v-for="v in map"></div>'
//其startTag会匹配成如下
match = {
tagName: "div",
attrs: [
[' v-if="isSucceed"', "v-if", "=", "isSucceed", undefined, undefined],
[' v-for="v in map"', "v-for", "=", "v in map", undefined, undefined],
],
start: index,
unarySlash: undefined,
end: index,
};
也就是包含许多语法片段Token的对象。
结束了parseStartTag函数之后,我们再回到主循环体当中:
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
continue
}
如果startTagMatch有值,也就是返回的match对象。此时,对match对象做handleStartTag处理,以下就是handleStartTag函数内容(有做删减):
function handleStartTag (match) {
const tagName = match.tagName
const unarySlash = match.unarySlash
const unary = isUnaryTag(tagName) || !!unarySlash
const l = match.attrs.length
const attrs = new Array(l)
for (let i = 0; i < l; i++) {
const args = match.attrs[i]
const value = args[3] || args[4] || args[5] || ''
attrs[i] = {
name: args[1],
value: decodeAttr(value)
}
}
if (!unary) {
stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end })
lastTag = tagName
}
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end)
}
}
该函数的主逻辑并不难懂:1,将attrs数组的每一项处理为更规范的name、value形式;2,通过判断是否为自闭合标签,决定是否将标签添加至stack栈(因为自闭合标签不需要入栈,其不会有内容,也不会有结束标签);3,调用start,也就是parse函数传递过来的函数钩子。
也就是说它一共做了三件事情:包装attrs、入栈、调用start钩子。
其最终的产出如下:
{
tag: "div",
lowerCasedTag:'div',
attrs: [
{
name: "v-if",
value: "isSucceed",
},
{
name: "v-for",
value: "v in map",
},
],
start: index,
end: index,
};
以上,就是整个startTag的词法分析过程。最终会将其入栈,并把生成的一些属性通过参数传递给parse的钩子函数start。
endTag
接下来我们看结束标签。
const endTagMatch = html.match(endTag)
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
与startTag的处理逻辑很类似,依旧是先匹配,然后再做parseEndTag处理。而不同点在于,结束标签中,不会存在众多属性,因此endTag不需要做复杂的属性处理,只需要单独对当前标签做解析即可。
function parseEndTag (tagName, start, end) {
let pos, lowerCasedTagName
// Find the closest opened tag of the same type
if (tagName) {
lowerCasedTagName = tagName.toLowerCase()
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
} else {
// If no tag name is provided, clean shop
pos = 0
}
if (pos >= 0) {
// Close all the open elements, up the stack
for (let i = stack.length - 1; i >= pos; i--) {
if (process.env.NODE_ENV !== 'production' &&
(i > pos || !tagName) &&
options.warn
) {
options.warn(
`tag <${stack[i].tag}> has no matching end tag.`,
{ start: stack[i].start, end: stack[i].end }
)
}
if (options.end) {
options.end(stack[i].tag, start, end)
}
}
// Remove the open elements from the stack
stack.length = pos
lastTag = pos && stack[pos - 1].tag
} else if (lowerCasedTagName === 'br') {
if (options.start) {
options.start(tagName, [], true, start, end)
}
} else if (lowerCasedTagName === 'p') {
if (options.start) {
options.start(tagName, [], false, start, end)
}
if (options.end) {
options.end(tagName, start, end)
}
}
}
对于parseEndTag函数来说,主要逻辑就是去匹配stack中的起始标签,或者当匹配不上的时候做一些错误抛出处理。我们还是一步步分析:
首先是对pos属性做处理。该属性也就代表着结束标签在栈中匹配项的位置。一共有三种情况:a.匹配到了某一项,但是不确定是否为正确的位置是否正确,不过可以确定地是pos一定大于0;b.没有匹配到,则当循环结束后,pos的值应该为-1;c.tagName不存在,则此时将pos的值置为0。至于前面两点应该比较好懂,而c情况为何如此,我们会在之后做分析。
处理完pos之后,在根据pos的值来做相应操作。首先分析pos小于0的情况:意味着某个结束标签未能匹配到相应的开始标签。对于这种情况,vue并不会抛出错误,但是会处理两种特殊情情况:</br>和</p>。当解析到这两个标签的时候,会将其当作正常的起始标签处理,其目的是为了与浏览器的默认行为保持统一。其实不止这一处,在很多地方也会有类似的代码,根本目的就是不改变浏览器的默认行为,这也是一个优秀的框架必备因素之一:尽量不要改变一些默认行为。
接着我们再来分析pos大于等于0的情况。其实也不复杂,就是从stack的栈顶一直循环到pos的位置,然后对每一个都做报错处理,并将其出栈。为什么能确定从pos到栈顶的所有元素都是不匹配的元素呢?回过头再去看看pos的处理就明白了:因为pos的位置就对应的是从栈顶一直向下寻找,找到的第一个匹配元素的位置。那么如果当前的pos不为栈顶元素的index,就代表从pos一直到栈顶都是不匹配元素。因此,只需要做错误抛出,并将嵌套混乱的元素出栈即可。而对于结束标签与栈顶匹配的元素呢,依旧可以通过此逻辑实现:此时的 i不会大于pos,因此不会报错,只是正常出栈。
至此我们就分析完了parseEndTag函数的主逻辑。但是我们遗留了一个问题还未解答:
为何会存在tagName的情况,并且为何还要将此中情形下的pos赋值为0?
细心的同学可能在之前的代码就发现了奥妙。因为parseEndTag不仅仅会在主循环体内调用,还会在很多其他地方调用。而对于tagName为undefined的情景,是当循环结束时,也就是html解析完成时,紧接着while语句会调用一个不传参的parseEndTag函数,在函数的内部,对于此种情况会将pos赋值为0,从而导致stack从下标为0一直到栈顶,也就是栈内所有元素都会被作为错误情况对待。
大家可以思考以下这是为什么。其实很简单,当html解析完成之时,若此时栈未清空,则代表栈内的元素肯定为不闭合的标签,而对于这些在解析完成之后仍不闭合的标签,肯定是需要做错误抛出的。vue很巧妙的复用了parseEndTag函数来实现了最终错误清算的功能。
以上startTag和endTag的解析,也就是整个HTMLParser最核心的模块了。当然,我们还有注释内容、doctype标签、文本内容、纯文本标签并没有展开,但是其实它们的实现上也都是大同小异的。大家可以通过上述解析逻辑,自行阅读源码,完整的了解整个HTMLParser流程。
vue的parse函数
HTMLParser是词法分析的过程,而parse函数则是语法分析的过程。简单来说,就是通过对HTMLParser生成的语法片段的一些加工处理,最终生成AST。当然,这二者并不是分隔开执行的。大家或许有注意到,在startTag和endTag的最后一步,总是会调用start或end钩子,而这些钩子,就是用来生成AST。也就是说,二者是在功能上做区分,但是在执行顺序上不做区分。
当然,parse函数的逻辑相对来说更复杂,牵涉到各个指令的解析。比如v-if、v-for、v-model、v-on等,就是在parse中做初步解析的。可以说,HTMLParser只是完成了整个模板解析阶段的一小步。关于parse函数,我会在之后的博客做详细的分析。
文中内容均带有个人理解,并不保证权威。若有错误,欢迎随时批评指正。