解析器的作用就是将模板解析成AST。
那么什么是AST呢?下面我们先来了解一下AST到底是什么东西:
AST(Abstract Syntax Tree,抽象语法树),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
例子:
<div>
<p>{{value}}p>
div>
将上面的模板转换成AST后:
{
type: 1,
tag: "div",
attrsList: [],
attrsMap: {},
rawAttrsMap: {},
parent: undefined,
plain: true,
static: false,
staticRoot: false,
children: [{
type: 1,
tag: "p",
attrsList: [],
attrsMap: {},
rawAttrsMap: {},
parent: {type: 1, tag: "div", …},
plain: true,
static: false,
staticRoot: false,
children: [{
type: 2,
expression: "_s(value)",
text: "{{value}}",
static: false
}]
}]
}
说白了,其实就是用js对象来描述一个节点,一个对象表示一个节点,对象中的属性保存了节点所需的各种数据。如type表示节点的类型,type值为1表示这是一个元素节点;tag是标签的名字;children是一个数组,里面保存了子节点的描述对象。
对AST有一定概念之后,下面来看一下解析器是如何将模板解析成AST的。
export function parse (
template: string,
options: CompilerOptions
): ASTElement | void {
warn = options.warn || baseWarn
platformIsPreTag = options.isPreTag || no
platformMustUseProp = options.mustUseProp || no
platformGetTagNamespace = options.getTagNamespace || no
const isReservedTag = options.isReservedTag || no
maybeComponent = (el: ASTElement) => !!el.component || !isReservedTag(el.tag)
transforms = pluckModuleFunction(options.modules, 'transformNode')
preTransforms = pluckModuleFunction(options.modules, 'preTransformNode')
postTransforms = pluckModuleFunction(options.modules, 'postTransformNode')
delimiters = options.delimiters
const stack = []
const preserveWhitespace = options.preserveWhitespace !== false
const whitespaceOption = options.whitespace
let root
let currentParent
let inVPre = false
let inPre = false
let warned = false
function warnOnce (msg, range) {...}
function closeElement (element) {...}
function trimEndingWhitespace (el) {...}
function checkRootConstraints (el) {...}
parseHTML(template, {
warn,
expectHTML: options.expectHTML,
isUnaryTag: options.isUnaryTag,
canBeLeftOpenTag: options.canBeLeftOpenTag,
shouldDecodeNewlines: options.shouldDecodeNewlines,
shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
shouldKeepComment: options.comments,
outputSourceRange: options.outputSourceRange,
//解析到标签的开始位置时触发start函数
start (tag, attrs, unary, start, end) {...},
//解析到标签的结束位置时触发end函数
end (tag, start, end) {...},
//解析到文本时触发chars函数
chars (text: string, start: number, end: number) {...},
//解析到注释时触发comment函数
comment (text: string, start, end) {...}
})
return root
}
在parse函数中,先是定义一些变量,如何调用parseHTML函数对模板进行解析。
调用parse HTML函数时传递的options参数中的start函数是构建一个元素类型的AST节点并将它压入栈中,chars函数是构建一个文本类型的AST节点,comment函数是构建一个注释类型的AST节点,end是从栈中取出一个节点以便完成AST树状结构的构建。
export function parseHTML (html, options) {
const stack = [] //一个栈,用来记录DOM的层级关系
const expectHTML = options.expectHTML
const isUnaryTag = options.isUnaryTag || no
const canBeLeftOpenTag = options.canBeLeftOpenTag || no
let index = 0
let last, lastTag
while (html) {
last = html
// Make sure we're not in a plaintext content element like script/style
//判断是不是script,style,textarea这三种标签(纯文本内容元素)
if (!lastTag || !isPlainTextElement(lastTag)) {
//正常元素处理
} else {
//纯文本内容元素处理
}
}
}
在parseHTML函数中,会维护一个栈,这个栈用来记录DOM的层级关系。解析HTML模板时是一个循环的过程,模板解析时又分为纯文本内容元素与非纯文本内容元素来进行处理。
if (!lastTag || !isPlainTextElement(lastTag)) {
//正常元素处理
//找到标签的开始位置"<"
let textEnd = html.indexOf('<')
//判断是否是以"<"开头
if (textEnd === 0) {
// Comment:
//如果是注释标签
//其中const comment = /^
if (comment.test(html)) {
//注释标签的结束标签
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
if (options.shouldKeepComment) {
//触发comment回调
options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
}
//截掉已解析的注释标签,然后开始下一轮解析
advance(commentEnd + 3)
continue
}
}
// http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
//如果是条件注释标签
//其中const conditionalComment = /^
if (conditionalComment.test(html)) {
//找到注释标签的结束标签
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
//截掉条件注释标签部分,开始下一轮循环
advance(conditionalEnd + 2)
continue
}
}
// Doctype:
//如果是DOCTYPE
//其中const doctype = /^]+>/i;
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
//截取掉DOCTYPE,然后开始下一轮循环
advance(doctypeMatch[0].length)
continue
}
// End tag:
//如果是结束标签
const endTagMatch = html.match(endTag)
if (endTagMatch) {
const curIndex = index
//截取掉结束标签
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// Start tag:
//如果是开始标签
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
advance(1)
}
continue
}
}
let text, rest, next
//如果不是以"<"开头,则当成文本
if (textEnd >= 0) {
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
//如果有<但是又不符合开始标签、结束标签、注释、条件注释的话,就当成普通文本处理
next = rest.indexOf('<', 1)
if (next < 0) break
textEnd += next
rest = html.slice(textEnd)
}
text = html.substring(0, textEnd)
}
//如果模板中找不到<,则说明整个模板都是文本
if (textEnd < 0) {
text = html
}
//截取文本
if (text) {
advance(text.length)
}
//触发chars回调函数
if (options.chars && text) {
options.chars(text, index - text.length, index)
}
}
先判断剩余HTML模板第一个字符是不是"<",如果是则进一步进行处理判断到底是哪种类型,如果不是“<”则将它当做文本。
1)注释标签:
使用正则表达式判断剩余模板字符串是不是“”,若找到了“-->”就将注释文本截取出来。其中,如果配置options的shouldKeepComment为真的话,就会触发comment回调函数。
2)条件注释标签:
使用正则表达式判断模板字符串是不是符合条件注释标签的规则,如果符合则将条件注释部分的文本截取掉。它不需要触发options的回调函数,截取之后也没有对条件注释标签的内容进行其它的处理,只是简单的将条件注释内容给截掉而已。所以在vue.js中写条件注释其实是没有用的。
3)DOCTYPE:
DOCTYPE的截取是通过正则表达式来匹配DOCTYPE标签,然后根据匹配结果的length属性来决定截取的字符串长度。
4)结束标签:
判断剩余模板字符串是不是符合结束标签的规则,如果是结束标签,则将结束标签进行截取,然后调用parseEndTag函数。parseEndTag函数中会将结束标签名转换为小写,然后在stack中找到最近的相同标签名,然后触发end回调函数,并删除stack中已解析完成的标签。如果在stack中并没有找到相同的标签名的话,就会判断如果是"br"则触发start回调函数,如果是"p"则一次调用start和end回调函数。
5)开始标签:
开始标签的解析就要复杂一点了,因为开始标签中还有属性需要解析。开始标签的解析被分为了3部分:标签名、属性、结尾。
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
}
}
}
parseStartTag 函数中先是匹配开始标签的标签名部分,如果匹配到了就创建一个match对象,在match对象中,tagName保存着标签的名字,attrs是一个数组保存着开始标签中的属性。将标签名截取掉之后就开始解析属性,将解析到的属性信息一项项添加到attrs中。属性解析完之后就到了结尾部分,如果结尾部分是"/>"的话则是自闭合标签,如果是自闭合标签则match.unarySlash保存着“/”,否则match.unarySlash是空字符串。
通过parseStartTag解析得到了开始标签的解析结果之后,就调用handleStartTag函数进一步进行处理。handleStartTag函数主要是讲tagName、attrs、unary等的数据取出来然后出发start回调函数。
6)文本截取:
如果模板不是以“<”开头,则当成文本进行处理。
1、不是以“<”开头但是模板中存在“<”,则截掉开头到“<”之间的字符串,然后接着有个循环进行判断剩下的字符串是否符合开始标签、结束标签、注释、条件注释的规则,如果不符合则当成普通文本处理,然后继续查找下一个"<“的位置重复上述循环的判断过程,直到符合标签的规则或者剩余字符串模板中不包含“<”。通过上述过程得到字符串模板中属于文本的部分。
2、如果模板字符串中不存在”<",则说明整个模板都是文本。
3、通过上述1、2得到本次的文本,然后将文本进行截取,触发chars回调函数。
在chars回调函数中,会对文本进行进一步解析,因为文本中还可能带有变量。如果文本中没有带变量的话,就会构建一个type是3的普通文本类型的AST并添加到currentParent.children中,也就是添加到当前节点的父节点的children属性中;如果文本中带了变量,则构建一个type为2的带变量的文本类型的AST并添加到父节点的children属性中。而currentParent = stack[stack.length - 1],也就是用来记录DOM的层级关系的栈stack中的栈顶元素节点。
对文本进一步处理主要是通过parseText函数来进行:
export function parseText (
text: string,
delimiters?: [string, string]
): TextParseResult | void {
//其中const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
const tagRE = delimiters ? buildRegex(delimiters) : defaultTagRE
//判断文本中是否带变量
if (!tagRE.test(text)) {
return
}
const tokens = []
const rawTokens = []
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
//将变量转换成_s(name)的形式
const exp = parseFilters(match[1].trim())
tokens.push(`_s(${exp})`)
rawTokens.push({ '@binding': exp })
//将lastIndex的位置指向剩下还没解析的文本的开头,然后开始下一轮循环
lastIndex = index + match[0].length
}
//变量解析完之后如果后面还有文本也将它添加到tokens中
if (lastIndex < text.length) {
rawTokens.push(tokenValue = text.slice(lastIndex))
tokens.push(JSON.stringify(tokenValue))
}
//返回解析结果
return {
expression: tokens.join('+'),
tokens: rawTokens
}
}
在parseText函数中,先通过正则表达式来判断要解析的文本中有没有变量,如果没有则直接return;如果匹配到了变量,则将{{左边的文本添加到tokens中,然后将变量转化为_s(name)的形式,对后续的文本循环上述的变量解析过程,直到没有再匹配到变量,然后再将变量后边的文本也添加到tokens中。最后将解析结果返回。
可以看到,文本“tctest{{name}}123”解析后的expression变量值为"tctest"+_s(name)+“123”。
_s是函数toString的别名:
/**
* Convert a value to a string that is actually rendered.
*/
export function toString (val: any): string {
return val == null
? ''
: Array.isArray(val) || (isPlainObject(val) && val.toString === _toString)
? JSON.stringify(val, null, 2)
: String(val)
}
script,style,textarea这三种元素是纯文本内容元素。
if (!lastTag || !isPlainTextElement(lastTag)) {
//正常元素处理
} else {
//纯文本内容元素处理
let endTagLength = 0
const stackedTag = lastTag.toLowerCase()
const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(' + stackedTag + '[^>]*>)', 'i'))
//对文本进行处理
const rest = html.replace(reStackedTag, function (all, text, endTag) {
endTagLength = endTag.length
if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
text = text
.replace(//g, '$1') // #7298
.replace(//g, '$1')
}
if (shouldIgnoreFirstNewline(stackedTag, text)) {
//去掉开头的/n
text = text.slice(1)
}
//触发chars回调
if (options.chars) {
options.chars(text)
}
//返回空字符串会将匹配到的内容截掉
return ''
})
index += html.length - rest.length
html = rest
//解析结束标签
parseEndTag(stackedTag, index - endTagLength, index)
}
纯文本内容元素处理内容元素主要是将元素的内容截取出来,然后触发chars回调函数,接着解析结束标签并触发end回调。
解析器就是讲HTML模板解析成AST,解析时通过一个栈来记录DOM的层级关系。解析模板时,分为纯文本元素和正常元素两大类来解析,正常元素又分为开始标签、结束标签、HTML注释、条件注释、DOCTYPE以及文本着几类,文本中如果有变量也需要进一步对变量进行解析。