Vue源码之:模板编译三大阶段【上】
参考文档:
https://vue-js.com/learn-vue/
https://github.com/answershuto/learnVue'
前言
在前几篇文章中,我们介绍了
Vue
中的虚拟DOM
以及虚拟DOM
的patch(DOM-Diff)
过程,而虚拟DOM
存在的必要条件是得先有VNode
,那么VNode
又是从哪儿来的呢?这就是接下来几篇文章要说的模板编译。你可以这么理解:把用户写的模板进行编译,就会产生VNode
什么是模板编译
我们把写在
<template></template>
标签中的类似于原生HTML
的内容称之为模板。这时你可能会问了,为什么说是“类似于原生HTML
的内容”而不是“就是HTML
的内容”?因为我们在开发中,在<template></template>
标签中除了写一些原生HTML
的标签,我们还会写一些变量插值,如,或者写一些Vue
指令,如v-on
、v-if
等。而这些东西都是在原生HTML
语法中不存在的,不被接受的。但是事实上我们确实这么写了,也被正确识别了,页面也正常显示了,这又是为什么呢?
这就归功于
Vue
的模板编译了,Vue
会把用户在<template></template>
标签中写的类似于原生HTML
的内容进行编译,把原生HTML
的内容找出来,再把非原生HTML
找出来,经过一系列的逻辑处理生成渲染函数,也就是render
函数,而render
函数会将模板内容生成对应的VNode
,而VNode
再经过前几篇文章介绍的patch
过程从而得到将要渲染的视图中的VNode
,最后根据VNode
创建真实的DOM
节点并插入到视图中, 最终完成视图的渲染更新。
而把用户在
template></template>
标签中写的类似于原生HTML
的内容进行编译,把原生HTML
的内容找出来,再把非原生HTML
找出来,经过一系列的逻辑处理生成渲染函数,也就是render
函数的这一段过程称之为模板编译过程。
模板编译三大阶段:
1、模板解析【将用户所写的模板字符串解析成AST抽象语法树】
2、优化阶段【标记静态节点和静态根节点】
3、代码生成阶段【生成render
函数字符串】
整体的渲染流程
所谓渲染流程,就是把用户写的类似于原生HTML的模板经过一系列处理最终反应到视图中称之为整个渲染流程。这个流程在上文中其实已经说到了,下面我们以流程图的形式宏观的了解一下,流程图如下:
从图中我们也可以看到,模板编译过程就是把用户写的模板经过一系列处理最终生成render
函数的过程。
模板编译内部流程
那么模板编译内部是怎么把用户写的模板经过处理最终生成render
函数的呢?这内部的过程是怎样的呢?
抽象语法树AST
Vue
如何从<template></template>
标签中写的模板字符串中提取出元素的标签,属性,变量等,就要借助一个叫做抽象语法树的东西
所谓抽象语法树,在计算机科学中,抽象语法树(AbstractSyntaxTree,AST
),或简称语法树(Syntax tree
),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于if-condition-then
这样的条件跳转语句,可以使用带有两个分支的节点来表示。——来自百度百科
https://astexplorer.net/
具体流程
将一堆字符串模板解析成抽象语法树AST
后,我们就可以对其进行各种操作处理了,处理完后用处理后的AST
来生成render
函数。其具体流程可大致分为三个阶段
1、模板解析阶段:将一堆模板字符串用正则等方式解析成抽象语法树AST
;
2、优化阶段:遍历AST
,找出其中的静态节点,并打上标记;
3、代码生成阶段:将AST
转换成渲染函数;
这三个阶段在源码中分别对应三个模块,下面给出三个模块的源代码在源码中的路径:
1、模板解析阶段——解析器——源码路径:src/compiler/parser/
index.js`;
2、优化阶段——优化器——源码路径:src/compiler/optimizer.js
;
3、代码生成阶段——代码生成器——源码路径:src/compiler/codegen/index.js
; 其对应的源码如下:
// 源码位置: /src/complier/index.js
function baseCompile (
template: string,
options: CompilerOptions
): CompiledResult {
/*parse解析得到ast树*/
const ast = parse(template.trim(), options)
/*
将AST树进行优化
优化的目标:生成模板AST树,检测不需要进行DOM改变的静态子树。
一旦检测到这些静态树,我们就能做以下这些事情:
1.把它们变成常数,这样我们就再也不需要每次重新渲染时创建新的节点了。
2.在patch的过程中直接跳过。
*/
optimize(ast, options)
/*根据ast树生成所需的code(内部包含render与staticRenderFns)*/
const code = generate(ast, options)
return {
ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
}
可以看到 baseCompile
的代码非常的简短主要核心代码。
1、const ast =parse(template.trim(), options):parse
会用正则等方式解析 template
模板中的指令、class
、style
等数据,形成AST
。
2、optimize(ast, options): optimize
的主要作用是标记静态节点,这是 Vue
在编译过程中的一处优化,挡在进行patch
的过程中,DOM-Diff
算法会直接跳过静态节点,从而减少了比较的过程,优化了 patch
的性能。
3、const code =generate(ast, options): 将 AST
转化成render
函数字符串的过程,得到结果是render
函数的字符串以及staticRenderFns
字符串。
最终baseCompile
的返回值
{ ast: ast,
render: code.render,
staticRenderFns: code.staticRenderFns
}
最终返回了抽象语法树( ast
),渲染函数( render
),静态渲染函数( staticRenderFns
),且render
的值为code.render
,staticRenderFns
的值为code.staticRenderFns
,也就是说通过 generate
处理 ast
之后得到的返回值 code
是一个对象。
下面再给出模板编译内部具体流程图,便于理解。流程图如下:
模板解析阶段
在解析整个模板的时候它的流程应该是这样子的:HTML解析器是主线,先用HTML解析器进行解析整个模板,在解析过程中如果碰到文本内容,那就调用文本解析器来解析文本,如果碰到文本中包含过滤器那就调用过滤器解析器来解析。如下图所示:
回到源码
解析器的源码位于/src/complier/parser
文件夹下,其主线代码如下:
// 代码位置:/src/complier/parser/index.js
/**
* Convert HTML string to AST.
*/
exportfunction parse(template, options) {
// ...
parseHTML(template, {
warn,
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
canBeLeftOpenTag: options.canBeLeftOpenTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
shouldKeepComment: options.comments,
start (tag, attrs, unary) {
if (inVPre) {
...
} else {
/*处理属性*/
processAttrs(element)
}
},
end () {
},
//这个地方处理 parseText
chars (text: string) {
if (text) {
let expression
if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
children.push({
type: 2,
expression,
text
})
}
}
},
comment (text: string) {
}
})
return root
}
/*处理属性*/
function processAttrs (el) {
/*获取元素属性列表*/
const list = el.attrsList
let i, l, name, rawName, value, modifiers, isProp
for (i = 0, l = list.length; i < l; i++) {
.....
/*如果属性是v-bind的*/
if (bindRE.test(name)) { // v-bind
/*这样处理以后v-bind:aaa得到aaa*/
name = name.replace(bindRE, '')
.....
/*解析过滤器*/
value = parseFilters(value)
....
}
} else {
/*处理常规的字符串属性*/
// literal attribute
if (process.env.NODE_ENV !== 'production') {
const expression = parseText(value, delimiters)
....
}
}
}
}
从上面代码中可以看到,parse
函数就是解析器的主函数,在parse
函数内调用了parseHTML
函数对模板字符串进行解析,在parseHTML
函数解析模板字符串的过程中,如果遇到文本信息,就会调用文本解析器parseText
函数进行文本解析;如果遇到文本中包含过滤器,就会调用过滤器解析器parseFilters
函数进行解析。
HTML解析器内部运行流程
在源码中,HTML
解析器就是parseHTML
函数,在模板解析主线函数parse
中调用了该函数,并传入两个参数,代码如上:
从代码中我们可以看到,调用parseHTML
函数时为其传入的两个参数分别是:
1、template
:待转换的模板字符串;
2、options
:转换时所需的选项;
第一个参数是待转换的模板字符串,无需多言;重点看第二个参数,第二个参数提供了一些解析HTML
模板时的一些参数,同时还定义了4个钩子函数。这4个钩子函数有什么作用呢?我们说了模板编译阶段主线函数parse
会将HTML
模板字符串转化成AST
,而parseHTML
是用来解析模板字符串的,把模板字符串中不同的内容出来之后,那么谁来把提取出来的内容生成对应的AST
呢?答案就是这4个钩子函数
把这4个钩子函数作为参数传给解析器parseHTML
,当解析器解析出不同的内容时调用不同的钩子函数从而生成不同的AST
。
paseHTML
源码如下:
function parseHTML(html, options) { const stack = [] // 维护AST节点层级的栈
const expectHTML = options.expectHTML
const isUnaryTag = options.isUnaryTag || no
const canBeLeftOpenTag = options.canBeLeftOpenTag || no //用来检测一个标签是否是可以省略闭合标签的非自闭合标签
let index = 0 //解析游标,标识当前从何处开始解析模板字符串
let last, // 存储剩余还未解析的模板字符串
lastTag // 存储着位于 stack 栈顶的元素
// 开启一个 while 循环,循环结束的条件是 html 为空,即 html 被 parse 完毕
while (html) {
last = html;
// 确保即将 parse 的内容不是在纯文本标签里 (script,style,textarea)
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
/**
* 如果html字符串是以'<'开头,则有以下几种可能
* 开始标签:<div>
* 结束标签:</div>
* 注释:<!-- 我是注释 -->
* 条件注释:<!-- [if !IE] --> <!-- [endif] -->
* DOCTYPE:<!DOCTYPE html>
* 需要一一去匹配尝试
*/
if (textEnd === 0) {
// 解析是否是注释
if (comment.test(html)) {
}
// 解析是否是条件注释
if (conditionalComment.test(html)) {
}
// 解析是否是DOCTYPE
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
}
// 解析是否是结束标签
const endTagMatch = html.match(endTag)
if (endTagMatch) {
}
// 匹配是否是开始标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
}
}
// 如果html字符串不是以'<'开头,则解析文本类型
let text, rest, next
if (textEnd >= 0) {
}
// 如果在html字符串中没有找到'<',表示这一段html字符串都是纯文本
if (textEnd < 0) {
text = html
html = ''
}
// 把截取出来的text转化成textAST
if (options.chars && text) {
options.chars(text)
}
} else {
// 父元素为script、style、textarea时,其内部的内容全部当做纯文本处理
}
//将整个字符串作为文本对待
if (html === last) {
options.chars && options.chars(html);
if (!stack.length && options.warn) {
options.warn(("Mal-formatted tag at end of template: "" + html + """));
}
break
}
}
// Clean up any remaining tags
parseEndTag();
//parse 开始标签
functionparseStartTag() {
}
//处理 parseStartTag 的结果
function handleStartTag(match) {
}
//parse 结束标签
function parseEndTag(tagName, start, end) {
}
}
当解析到开始标签时调用start
函数生成元素类型的AST
节点,代码如下;
// 当解析到标签的开始位置时,触发startstart (tag, attrs, unary) {
const element: ASTElement = {
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
parent: currentParent,
children: []
}
}
从上面代码中我们可以看到,start
函数接收三个参数,分别是标签名tag
、标签属性attrs
、标签是否自闭合unary
。当调用该钩子函数时,内部会调用createASTElement
函数来创建元素类型的AST节点
当解析到结束标签时调用end
函数;
当解析到文本时调用chars
函数生成文本类型的AST
节点;
// 当解析到标签的文本时,触发charschars (text) {
if (text) {
let expression
if (!inVPre && text !== ' ' && (expression = parseText(text, delimiters))) {
children.push({
type: 2,
expression,
text
})
} elseif (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
children.push({
type: 3,
text
})
}
}
}
当解析到标签的文本时,触发chars
钩子函数,在该钩子函数内部,首先会判断文本是不是一个带变量的动态文本,如“hello ”
。如果是动态文本,则创建动态文本类型的AST
节点;如果不是动态文本,则创建纯静态文本类型的AST
节点。
当解析到注释时调用comment
函数生成注释类型的AST节点;
comment (text: string, start, end) { // adding anyting as a sibling to the root node is forbidden
// comments should still be allowed, but ignored
if (currentParent) {
const child: ASTText = {
type: 3,
text,
isComment: true
}
if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
child.start = start
child.end = end
}
currentParent.children.push(child)
}
}
当解析到标签的注释时,触发comment
钩子函数,该钩子函数会创建一个注释类型的AST
节点。
一边解析不同的内容一边调用对应的钩子函数生成对应的AST
节点,最终完成将整个模板字符串转化成AST
,这就是HTML
解析器所要做的工作。
如何解析不同的内容
要从模板字符串中解析出不同的内容,那首先要知道模板字符串中都会包含哪些内容。那么通常我们所写的模板字符串中都会包含哪些内容呢?经过整理,通常模板内会包含如下内容:
文本,例如“难凉热血” HTML注释,例如 条件注释,例如我是注释 DOCTYPE,例如 开始标签,例如 结束标签,例如
解析HTML注释
解析注释比较简单,我们知道HTML注释是以<!--开
头,以-->
结尾,这两者中间的内容就是注释内容,那么我们只需用正则判断待解析的模板字符串html是否以<!--
开头,若是,那就继续向后寻找-->
,如果找到了,OK,注释就被解析出来了。代码如下:
const comment = /^<!--/if (comment.test(html)) {
// 若为注释,则继续查找是否存在'-->'
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
// 若存在 '-->',继续判断options中是否保留注释
if (options.shouldKeepComment) {
// 若保留注释,则把注释截取出来传给options.comment,创建注释类型的AST节点
options.comment(html.substring(4, commentEnd))
}
// 若不保留注释,则将游标移动到'-->'之后,继续向后解析
advance(commentEnd + 3)
continue
}
}
function advance (n) {
index += n // index为解析游标
html = html.substring(n)
}
在上面代码中,如果模板字符串html
符合注释开始的正则,那么就继续向后查找是否存在-->
,若存在,则把html从第4位("<!--
"长度为4)开始截取,直到-->
处,截取得到的内容就是注释的真实内容,然后调用4个钩子函数中的comment
函数,将真实的注释内容传进去,创建注释类型的AST
节点。
上面代码中有一处值得注意的地方,那就是我们平常在模板中可以在<template></template>
标签上配置comments
选项来决定在渲染模板时是否保留注释,对应到上面代码中就是options.shouldKeepComment
,如果用户配置了comments
选项为true
,则shouldKeepComment
为true
,则创建注释类型的AST
节点,如不保留注释,则将游标移动到'-->'
之后,继续向后解析。
解析条件注释
解析条件注释也比较简单,其原理跟解析注释相同,都是先用正则判断是否是以条件注释特有的开头标识开始,然后寻找其特有的结束标识,若找到,则说明是条件注释,将其截取出来即可,由于条件注释不存在于真正的DOM
树中,所以不需要调用钩子函数创建AST
节点。代码如下:
// 解析是否是条件注释
const conditionalComment = /^<![/
if (conditionalComment.test(html)) {
// 若为条件注释,则继续查找是否存在']>'
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
// 若存在 ']>',则从原本的html字符串中把条件注释截掉,
// 把剩下的内容重新赋给html,继续向后匹配
advance(conditionalEnd + 2)
continue
}
}
解析开始标签
相较于前三种内容的解析,解析开始标签会稍微复杂一点,但是万变不离其宗,它的原理还是相通的,都是使用正则去匹配提取。
首先使用开始标签的正则去匹配模板字符串,看模板字符串是否具有开始标签的特征,如下:
/** * 匹配开始标签的正则
*/
const ncname = '[a-zA-Z_][\w\-\.]*'
const qnameCapture = `((?:${ncname}\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const start = html.match(startTagOpen)
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
}
// 以开始标签开始的模板:
'<div></div>'.match(startTagOpen) => ['<div','div',index:0,input:'<div></div>']
// 以结束标签开始的模板:
'</div><div></div>'.match(startTagOpen) => null
// 以文本开始的模板:
'我是文本</p>'.match(startTagOpen) => null
在上面代码中,我们用不同类型的内容去匹配开始标签的正则,发现只有<div></div>
的字符串可以正确匹配,并且返回一个数组。
在前文中我们说到,当解析到开始标签时,会调用4个钩子函数中的start
函数,而start
函数需要传递3个参数,分别是标签名tag
、标签属性attrs
、标签是否自闭合unary
。标签名通过正则匹配的结果就可以拿到,即上面代码中的start[1]
,而标签属性attrs
以及标签是否自闭合unary
需要进一步解析。
1、解析标签属性
我们知道,标签属性一般是写在开始标签的标签名之后的,如下:
<div class="a" id="b"></div>
另外,我们在上面匹配是否是开始标签的正则中已经可以拿到开始标签的标签名,即上面代码中的start[0]
,那么我们可以将这一部分先从模板字符串中截掉,则剩下的部分如下:
class="a" id="b"></div>
那么我们只需用剩下的这部分去匹配标签属性的正则,就可以将标签属性提取出来了,如下:
const attribute = /^s*([^s"'<>/=]+)(?:s*(=)s*(?:"([^"]*)"+|'([^']*)'+|([^s"'=<>`]+)))?/let html = 'class="a" id="b"></div>'
let attr = html.match(attribute)
console.log(attr)
// ["class="a"", "class", "=", "a", undefined, undefined, index: 0, input: "class="a" id="b"></div>", groups: undefined]
可以看到,第一个标签属性class="a"
已经被拿到了。另外,标签属性有可能有多个也有可能没有,如果没有的话那好办,匹配标签属性的正则就会匹配失败,标签属性就为空数组;而如果标签属性有多个的话,那就需要循环匹配了,匹配出第一个标签属性后,就把该属性截掉,用剩下的字符串继续匹配,直到不再满足正则为止,代码如下:
const attribute = /^s*([^s"'<>/=]+)(?:s*(=)s*(?:"([^"]*)"+|'([^']*)'+|([^s"'=<>`]+)))?/const startTagClose = /^s*(/?)>/
const match = {
tagName: start[1],
attrs: [],
start: index
}
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push(attr)
}
在上面代码的while
循环中,如果剩下的字符串不符合开始标签的结束特征(startTagClose
)并且符合标签属性的特征的话,那就说明还有未提取出的标签属性,那就进入循环,继续提取,直到把所有标签属性都提取完毕。
所谓不符合开始标签的结束特征是指当前剩下的字符串不是以开始标签结束符开头的,我们知道一个开始标签的结束符有可能是一个>
(非自闭合标签),也有可能是/>
(自闭合标签),如果剩下的字符串(如></div>
)以开始标签的结束符开头,那么就表示标签属性已经被提取完毕了。
2、解析标签是否是自闭合
在HTML中,有自闭合标签(如<img src=""/>
)也有非自闭合标签(如<div></div>
),这两种类型的标签在创建AST节点是处理方式是有区别的,所以我们需要解析出当前标签是否是自闭合标签。
解析的方式很简单,我们知道,经过标签属性提取之后,那么剩下的字符串无非就两种,如下:
`
<!--非自闭合标签-->></div>
<!--自闭合标签-->/>
所以我们可以用剩下的字符串去匹配开始标签结束符正则,如下:
const startTagClose = /^s*(/?)>/let end = html.match(startTagClose)
'></div>'.match(startTagClose) // [">", "", index: 0, input: "></div>", groups: undefined]
'/>'.match(startTagClose) // ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
可以看到,非自闭合标签匹配结果中的end[1]
为""
,而自闭合标签匹配结果中的end[1]
为"/"
。所以根据匹配结果的
end[1]是否是""我们即可判断出当前标签是否为自闭合标签,源码如下:
const startTagClose = /^s*(/?)>/let end = html.match(startTagClose)
if (end) {
match.unarySlash = end[1]
advance(end[0].length)
match.end = index
return match
}
经过以上两步,开始标签就已经解析完毕了,完整源码如下:
const ncname = '[a-zA-Z_][\w\-\.]*'const qnameCapture = `((?:${ncname}\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^s*(/?)>/
functionparseStartTag () {
const start = html.match(startTagOpen)
// '<div></div>'.match(startTagOpen) => ['<div','div',index:0,input:'<div></div>']
if (start) {
const match = {
tagName: start[1],
attrs: [],
start: index
}
advance(start[0].length)
let end, attr
/**
* <div a=1 b=2 c=3></div>
* 从<div之后到开始标签的结束符号'>'之前,一直匹配属性attrs
* 所有属性匹配完之后,html字符串还剩下
* 自闭合标签剩下:'/>'
* 非自闭合标签剩下:'></div>'
*/
while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
advance(attr[0].length)
match.attrs.push(attr)
}
/**
* 这里判断了该标签是否为自闭合标签
* 自闭合标签如:<input type='text' />
* 非自闭合标签如:<div></div>
* '></div>'.match(startTagClose) => [">", "", index: 0, input: "></div>", groups: undefined]
* '/><div></div>'.match(startTagClose) => ["/>", "/", index: 0, input: "/><div></div>", groups: undefined]
* 因此,我们可以通过end[1]是否是"/"来判断该标签是否是自闭合标签
*/
if (end) {
match.unarySlash = end[1]
advance(end[0].length)
match.end = index
return match
}
}
}
通过源码可以看到,调用parseStartTag
函数,如果模板字符串符合开始标签的特征,则解析开始标签,并将解析结果返回,如果不符合开始标签的特征,则返回undefined。
解析完毕后,就可以用解析得到的结果去调用start
钩子函数去创建元素型的AST
节点了。
在源码中,Vue
并没有直接去调start
钩子函数去创建AST节点,而是调用了handleStartTag
函数,在该函数内部才去调的start
钩子函数,为什么要这样做呢?这是因为虽然经过parseStartTag
函数已经把创建AST节点必要信息提取出来了,但是提取出来的标签属性数组还是需要处理一下,下面我们就来看一下handleStartTag
函数都做了些什么事。handleStartTag
函数源码如下:
function handleStartTag (match) { const tagName = match.tagName
const unarySlash = match.unarySlash
if (expectHTML) {
// ...
}
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] || ''
const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlines
attrs[i] = {
name: args[1],
value: decodeAttr(value, shouldDecodeNewlines)
}
}
if (!unary) {
stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs })
lastTag = tagName
}
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end)
}
}
handleStartTag
函数用来对parseStartTag
函数的解析结果进行进一步处理,它接收parseStartTag
函数的返回值作为参数。
handleStartTag
函数的开始定义几个常量:
const tagName = match.tagName // 开始标签的标签名const unarySlash = match.unarySlash // 是否为自闭合标签的标志,自闭合为"",非自闭合为"/"
const unary = isUnaryTag(tagName) || !!unarySlash // 布尔值,标志是否为自闭合标签
const l = match.attrs.length // match.attrs 数组的长度
const attrs = new Array(l) // 一个与match.attrs数组长度相等的数组
解析结束标签
结束标签的解析要比解析开始标签容易多了,因为它不需要解析什么属性,只需要判断剩下的模板字符串是否符合结束标签的特征,如果是,就将结束标签名提取出来,再调用4个钩子函数中的end函数就好了。
首先判断剩余的模板字符串是否符合结束标签的特征,如下:
const ncname = '[a-zA-Z_][\w\-\.]*'const qnameCapture = `((?:${ncname}\:)?${ncname})`
const endTag = new RegExp(`^<\/${qnameCapture}[^>]*>`)
const endTagMatch = html.match(endTag)
'</div>'.match(endTag) // ["</div>", "div", index: 0, input: "</div>", groups: undefined]
'<div>'.match(endTag) // null
上面代码中,如果模板字符串符合结束标签的特征,则会获得匹配结果数组;如果不合符,则得到null。
接着再调用end
钩子函数,如下:
if (endTagMatch) { const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
解析文本
解析文本也比较容易,在解析模板字符串之前,我们先查找一下第一个<出现在什么位置,如果第一个<在第一个位置,那么说明模板字符串是以其它5种类型开始的;如果第一个<不在第一个位置而在模板字符串中间某个位置,那么说明模板字符串是以文本开头的,那么从开头到第一个<出现的位置就都是文本内容了;如果在整个模板字符串里没有找到<,那说明整个模板字符串都是文本。这就是解析思路,接下来我们对照源码来了解一下实际的解析过程,源码如下:
et textEnd = html.indexOf('<')// '<' 在第一个位置,为其余5种类型
if (textEnd === 0) {
// ...
}
// '<' 不在第一个位置,文本开头
if (textEnd >= 0) {
// 如果html字符串不是以'<'开头,说明'<'前面的都是纯文本,无需处理
// 那就把'<'以后的内容拿出来赋给rest
rest = html.slice(textEnd)
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// < in plain text, be forgiving and treat it as text
/**
* 用'<'以后的内容rest去匹配endTag、startTagOpen、comment、conditionalComment
* 如果都匹配不上,表示'<'是属于文本本身的内容
*/
// 在'<'之后查找是否还有'<'
next = rest.indexOf('<', 1)
// 如果没有了,表示'<'后面也是文本
if (next < 0) break
// 如果还有,表示'<'是文本中的一个字符
textEnd += next
// 那就把next之后的内容截出来继续下一轮循环匹配
rest = html.slice(textEnd)
}
// '<'是结束标签的开始 ,说明从开始到'<'都是文本,截取出来
text = html.substring(0, textEnd)
advance(textEnd)
}
// 整个模板字符串里没有找到`<`,说明整个模板字符串都是文本
if (textEnd < 0) {
text = html
html = ''
}
// 把截取出来的text转化成textAST
if (options.chars && text) {
options.chars(text)
}
值得深究的是如果<不在第一个位置而在模板字符串中间某个位置,那么说明模板字符串是以文本开头的,那么从开头到第一个<出现的位置就都是文本内容了,接着我们还要从第一个<的位置继续向后判断,因为还存在这样一种情况,那就是如果文本里面本来就包含一个<,例如1<2。为了处理这种情况,我们把从第一个<的位置直到模板字符串结束都截取出来记作rest,如下:
while ( !endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// < in plain text, be forgiving and treat it as text
/**
* 用'<'以后的内容rest去匹配endTag、startTagOpen、comment、conditionalComment
* 如果都匹配不上,表示'<'是属于文本本身的内容
*/
// 在'<'之后查找是否还有'<'
next = rest.indexOf('<', 1)
// 如果没有了,表示'<'后面也是文本
if (next < 0) break
// 如果还有,表示'<'是文本中的一个字符
textEnd += next
// 那就把next之后的内容截出来继续下一轮循环匹配
rest = html.slice(textEnd)
}
如何保证AST节点层级关系
上一章节我们介绍了HTML
解析器是如何解析各种不同类型的内容并且调用钩子函数创建不同类型的AST节点。此时你可能会有个疑问,我们上面创建的AST节点都是单独创建且分散的,而真正的DOM节点都是有层级关系的,那如何来保证AST节点的层级关系与真正的DOM节点相同呢?
关于这个问题,Vue也注意到了。Vue在HTML解析器的开头定义了一个栈stack
,这个栈的作用就是用来维护AST节点层级的,那么它是怎么维护的呢?通过前文我们知道,HTML解析器在从前向后解析模板字符串时,每当遇到开始标签时就会调用start
钩子函数,那么在start
钩子函数内部我们可以将解析得到的开始标签推入栈中,而每当遇到结束标签时就会调用end钩子函数,那么我们也可以在end钩子函数内部将解析得到的结束标签所对应的开始标签从栈中弹出。请看如下例子:
假如有如下模板字符串:
<div><p><span></span></p></div>
当解析到开始标签<div>
时,就把div
推入栈中,然后继续解析,当解析到<p>
时,再把p推入栈中,同理,再把span
推入栈中,当解析到结束标签</span>
时,此时栈顶的标签刚好是span
的开始标签,那么就用span
的开始标签和结束标签构建AST
节点,并且从栈中把span
的开始标签弹出,那么此时栈中的栈顶标签p
就是构建好的span
的AST
节点的父节点,如下图:
模板解析阶段 parseText
chars() 钩子函数分析
当HTML
解析器解析到文本内容时会调用4个钩子函数中的chars
函数来创建文本型的AST
节点,并且也说了在chars
函数中会根据文本内容是否包含变量再细分为创建含有变量的AST
节点和不包含变量的AST
节点,如下:
// 当解析到标签的文本时,触发charschars (text) {
if(res = parseText(text)){
let element = {
type: 2,
expression: res.expression,
tokens: res.tokens,
text
}
} else {
let element = {
type: 3,
text
}
}
}
从上面代码中可以看到,创建含有变量的AST
节点时节点的type属性为2
,并且相较于不包含变量的AST节点多了两个属性:expression
和tokens
。那么如何来判断文本里面是否包含变量以及多的那两个属性是什么呢?这就涉及到文本解析器了,当Vue用HTML解析器解析出文本时,再将解析出来的文本内容传给文本解析器,最后由文本解析器解析该段文本里面是否包含变量以及如果包含变量时再解析expression
和tokens
结果分析
从上面chars
函数的代码中可以看到,把HTML
解析器解析得到的文本内容text
传给文本解析器parseText
函数,根据parseText
函数是否有返回值判断该文本是否包含变量,以及从返回值中取到需要的expression
和tokens
。那么我们就先来看一下parseText
函数如果有返回值,那么它的返回值是什么样子的。
假设现有由HTML解析器解析得到的文本内容如下:
let text = "我叫{{name}},我今年{{age}}岁了"
经过文本解析器解析后得到:
let res = parseText(text)res = {
expression:"我叫"+_s(name)+",我今年"+_s(age)+"岁了",
tokens:[
"我叫",
{'@binding': name },
",我今年"
{'@binding': age },
"岁了"
]
}
从上面的结果中我们可以看到,expression
属性就是把文本中的变量和非变量提取出来,然后把变量用_s()
包裹,最后按照文本里的顺序把它们用+
连接起来。而tokens
是个数组,数组内容也是文本中的变量和非变量,不一样的是把变量构造成{'@binding': xxx}
。这主要是为了给后面代码生成阶段的生成render
函数时用
源码分析
文本解析器的源码位于src/compiler/parser/text-parsre.js
中,代码如下:
const defaultTagRE = /{{((?:.|n)+?)}}/g
const buildRegex = cached(delimiters => {
const open = delimiters[0].replace(regexEscapeRE, '\
const defaultTagRE = /{{((?:.|n)+?)}}/g
const buildRegex = cached(delimiters => {
const open = delimiters[0].replace(regexEscapeRE, '\$&')
const close = delimiters[1].replace(regexEscapeRE, '\$&')
return new RegExp(open + '((?:.|\n)+?)' + close, 'g')
})
export function parseText (text,delimiters) {
const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
//接下来用tagRE去匹配传入的文本内容,判断是否包含变量,若不包含,则直接返回,如下:
if (!tagRE.test(text)) {
return
}
//接下来会开启一个while循环,循环结束条件是tagRE.exec(text)的结果match是否为null,exec( )方法是在一个字符串中执行匹配检索,如果它没有找到任何匹配就返回null,但如果它找到了一个匹配就返回一个数组。
const tokens = []
const rawTokens = []
/**
* let lastIndex = tagRE.lastIndex = 0
* 上面这行代码等同于下面这两行代码:
* tagRE.lastIndex = 0
* let lastIndex = tagRE.lastIndex
*/
let lastIndex = tagRE.lastIndex = 0
let match, index, tokenValue
while ((match = tagRE.exec(text))) {
index = match.index
// push text token
if (index > lastIndex) {
// 先把'{{'前面的文本放入tokens中
rawTokens.push(tokenValue = text.slice(lastIndex, index))
tokens.push(JSON.stringify(tokenValue))
}
// tag token
// 取出'{{ }}'中间的变量exp
const exp = parseFilters(match[1].trim())
// 把变量exp改成_s(exp)形式也放入tokens中
tokens.push(`_s(${exp})`)
rawTokens.push({ '@binding': exp })
// 设置lastIndex 以保证下一轮循环时,只从'}}'后面再开始匹配正则
lastIndex = index + match[0].length
}
// 当剩下的text不再被正则匹配上时,表示所有变量已经处理完毕
// 此时如果lastIndex < text.length,表示在最后一个变量后面还有文本
// 最后将后面的文本再加入到tokens中
if (lastIndex < text.length) {
rawTokens.push(tokenValue = text.slice(lastIndex))
tokens.push(JSON.stringify(tokenValue))
}
// 最后把数组tokens中的所有元素用'+'拼接起来
return {
expression: tokens.join('+'),
tokens: rawTokens
}
}
amp;')
const close = delimiters[1].replace(regexEscapeRE, '\
const defaultTagRE = /{{((?:.|n)+?)}}/g
const buildRegex = cached(delimiters => {
const open = delimiters[0].replace(regexEscapeRE, '\$&')
const close = delimiters[1].replace(regexEscapeRE, '\$&')
return new RegExp(open + '((?:.|\n)+?)' + close, 'g')
})
export function parseText (text,delimiters) {
const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
//接下来用tagRE去匹配传入的文本内容,判断是否包含变量,若不包含,则直接返回,如下:
if (!tagRE.test(text)) {
return
}
//接下来会开启一个while循环,循环结束条件是tagRE.exec(text)的结果match是否为null,exec( )方法是在一个字符串中执行匹配检索,如果它没有找到任何匹配就返回null,但如果它找到了一个匹配就返回一个数组。
const tokens = []
const rawTokens = []
/**
* let lastIndex = tagRE.lastIndex = 0
* 上面这行代码等同于下面这两行代码:
* tagRE.lastIndex = 0
* let lastIndex = tagRE.lastIndex
*/
let lastIndex = tagRE.lastIndex = 0
let match, index, tokenValue
while ((match = tagRE.exec(text))) {
index = match.index
// push text token
if (index > lastIndex) {
// 先把'{{'前面的文本放入tokens中
rawTokens.push(tokenValue = text.slice(lastIndex, index))
tokens.push(JSON.stringify(tokenValue))
}
// tag token
// 取出'{{ }}'中间的变量exp
const exp = parseFilters(match[1].trim())
// 把变量exp改成_s(exp)形式也放入tokens中
tokens.push(`_s(${exp})`)
rawTokens.push({ '@binding': exp })
// 设置lastIndex 以保证下一轮循环时,只从'}}'后面再开始匹配正则
lastIndex = index + match[0].length
}
// 当剩下的text不再被正则匹配上时,表示所有变量已经处理完毕
// 此时如果lastIndex < text.length,表示在最后一个变量后面还有文本
// 最后将后面的文本再加入到tokens中
if (lastIndex < text.length) {
rawTokens.push(tokenValue = text.slice(lastIndex))
tokens.push(JSON.stringify(tokenValue))
}
// 最后把数组tokens中的所有元素用'+'拼接起来
return {
expression: tokens.join('+'),
tokens: rawTokens
}
}
amp;')
return new RegExp(open + '((?:.|\n)+?)' + close, 'g')
})
exportfunction parseText (text,delimiters) {
const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
//接下来用tagRE去匹配传入的文本内容,判断是否包含变量,若不包含,则直接返回,如下:
if (!tagRE.test(text)) {
return
}
//接下来会开启一个while循环,循环结束条件是tagRE.exec(text)的结果match是否为null,exec( )方法是在一个字符串中执行匹配检索,如果它没有找到任何匹配就返回null,但如果它找到了一个匹配就返回一个数组。
const tokens = []
const rawTokens = []
/**
* let lastIndex = tagRE.lastIndex = 0
* 上面这行代码等同于下面这两行代码:
* tagRE.lastIndex = 0
* let lastIndex = tagRE.lastIndex
*/
let lastIndex = tagRE.lastIndex = 0
let match, index, tokenValue
while ((match = tagRE.exec(text))) {
index = match.index
// push text token
if (index > lastIndex) {
// 先把'{{'前面的文本放入tokens中
rawTokens.push(tokenValue = text.slice(lastIndex, index))
tokens.push(JSON.stringify(tokenValue))
}
// tag token
// 取出'{{ }}'中间的变量exp
const exp = parseFilters(match[1].trim())
// 把变量exp改成_s(exp)形式也放入tokens中
tokens.push(`_s(${exp})`)
rawTokens.push({ '@binding': exp })
// 设置lastIndex 以保证下一轮循环时,只从'}}'后面再开始匹配正则
lastIndex = index + match[0].length
}
// 当剩下的text不再被正则匹配上时,表示所有变量已经处理完毕
// 此时如果lastIndex < text.length,表示在最后一个变量后面还有文本
// 最后将后面的文本再加入到tokens中
if (lastIndex < text.length) {
rawTokens.push(tokenValue = text.slice(lastIndex))
tokens.push(JSON.stringify(tokenValue))
}
// 最后把数组tokens中的所有元素用'+'拼接起来
return {
expression: tokens.join('+'),
tokens: rawTokens
}
}
我们看到,除开我们自己加的注释,代码其实不复杂
parseText
函数接收两个参数,一个是传入的待解析的文本内容text
,一个包裹变量的符号delimiters
。
第一个参数好理解,那第二个参数是干什么的呢?别急,我们看函数体内第一行代码:
const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
函数体内首先定义了变量tagRE
,表示一个正则表达式。这个正则表达式是用来检查文本中是否包含变量的。我们知道,通常我们在模板中写变量时是这样写的:hello
。这里用{{}}
包裹的内容就是变量。所以我们就知道,tagRE
是用来检测文本内是否有{{}}
。而tagRE
又是可变的,它是根据是否传入了delimiters
参数从而又不同的值,也就是说如果没有传入delimiters
参数,则是检测文本是否包含{{}}
,如果传入了值,就会检测文本是否包含传入的值。换句话说在开发Vue项目中,用户可以自定义文本内包含变量所使用的符号,例如你可以使用%包裹变量如:hello %name%。
const tokens = []const rawTokens = []
let lastIndex = tagRE.lastIndex = 0
let match, index, tokenValue
while ((match = tagRE.exec(text))) {
}
上面这部分的代码为开启一个while循环,循环结束条件是tagRE.exec(text)
的结果match
是否为null
,exec( )
方法是在一个字符串中执行匹配检索,如果它没有找到任何匹配就返回null
,但如果它找到了一个匹配就返回一个数组。
tagRE.exec("hello {{name}},I am {{age}}")//返回:["{{name}}", "name", index: 6, input: "hello {{name}},I am {{age}}", groups: undefined]
tagRE.exec("hello")
//返回:null
接着往下看
while ((match = tagRE.exec(text))) { index = match.index
if (index > lastIndex) {
// 先把'{{'前面的文本放入tokens中
rawTokens.push(tokenValue = text.slice(lastIndex, index))
tokens.push(JSON.stringify(tokenValue))
}
// tag token
// 取出'{{ }}'中间的变量exp
const exp = match[1].trim()
// 把变量exp改成_s(exp)形式也放入tokens中
tokens.push(`_s(${exp})`)
rawTokens.push({ '@binding': exp })
// 设置lastIndex 以保证下一轮循环时,只从'}}'后面再开始匹配正则
lastIndex = index + match[0].length
}
上面代码中,首先取得字符串中第一个变量在字符串中的起始位置赋给index
,然后比较index
和lastIndex
的大小,此时你可能有疑问了,这个lastIndex
是什么呢?在上面定义变量中,定义了let lastIndex = tagRE.lastIndex = 0
,所以lastIndex
就是tagRE.lastIndex
,而tagRE.lastIndex又
是什么呢?当调用exec( )
的正则表达式对象具有修饰符g时,它将把当前正则表达式对象的lastIndex属性设置为紧挨着匹配子串的字符位置,当同一个正则表达式第二次调用exec( )
,它会将从lastIndex
属性所指示的字符串处开始检索,如果exec( )没
有发现任何匹配结果,它会将lastIndex
重置为0
当index>lastIndex
时,表示变量前面有纯文本,那么就把这段纯文本截取出来,存入rawTokens
中,同时再调用JSON.stringify
给这段文本包裹上双引号,存入tokens
中
如果index
不大于lastIndex
,那说明index
也为0,即该文本一开始就是变量,例如:hello
。那么此时变量前面没有纯文本,那就不用截取,直接取出匹配结果的第一个元素变量名,将其用_s()
包裹存入tokens
中,同时再把变量名构造成{'@binding': exp}
存入rawTokens
中
当while
循环完毕时,表明文本中所有变量已经被解析完毕,如果此时lastIndex < text.length
,那就说明最后一个变量的后面还有纯文本,那就将其再存入tokens
和rawTokens
中
最后,把tokens
数组里的元素用+
连接,和rawTokens
一并返回
以上是 Vue源码之:模板编译三大阶段【上】 的全部内容, 来源链接: utcz.com/a/31639.html