准备
vue版本号2.6.12,为方便分析,选择了runtime+compiler版本。
回顾
如果有感兴趣的同学可以看看我之前的源码分析文章,这里呈上链接:《Vue源码分析系列:目录》
模板编译的入口
在runtime+compiler
版本中,Vue再执行挂载操作时,会调用为runtime+compiler
版本特别定制的$mount
方法,这个方法主要是将模板template
编译为可执行的render
函数。所以,整个Vue的模板编译入口就在这个$mount
中。
$mount
const { render, staticRenderFns } = compileToFunctions(template, {
outputSourceRange: process.env.NODE_ENV !== 'production',
shouldDecodeNewlines,
shouldDecodeNewlinesForHref,
delimiters: options.delimiters,
comments: options.comments
}, this)
这里调用了compileToFunctions
获取了render
函数。
Vue在定义compiler
函数时使用大量的函数柯里化技术,保存了不同平台的编译选项。最终这个compileToFunctions
执行的是src/compiler/index.js
中的createCompilerCreator
函数的参数(有兴趣的童鞋可以自己一层一层往里面找),这个函数的参数也是一个函数baseCompile
,这里我们来看下这个函数。
baseCompile
function baseCompile(
template: string,
options: CompilerOptions
): CompiledResult {
//生成AST
const ast = parse(template.trim(), options);
//优化AST
if (options.optimize !== false) {
optimize(ast, options);
}
//用AST生成一些代码
const code = generate(ast, options);
return {
ast,
render: code.render,b
staticRenderFns: code.staticRenderFns,
};
}
这里的逻辑相对而言清晰很多,首先调用parse
方法生成了一个AST抽象语法树。然后调用optimize
对这个AST进行了优化,添加了一些属性。最后使用generate
将AST转化为可执行的一个函数体,用于放入new Function()
中执行(这个函数就是render
函数)。
这一篇,我们主要分析的是parse
方法,看看Vue是如何将一个HTML字符串解析为AST的。
parse
parse
的函数逻辑较为复杂,我们可以宏观的看一看大概它干了什么:
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) {
if (!warned) {
warned = true;
warn(msg, range);
}
}
function closeElement(element) {
trimEndingWhitespace(element);
if (!inVPre && !element.processed) {
element = processElement(element, options);
}
// tree management
if (!stack.length && element !== root) {
// allow root elements with v-if, v-else-if and v-else
if (root.if && (element.elseif || element.else)) {
if (process.env.NODE_ENV !== "production") {
checkRootConstraints(element);
}
addIfCondition(root, {
exp: element.elseif,
block: element,
});
} else if (process.env.NODE_ENV !== "production") {
warnOnce(
`Component template should contain exactly one root element. ` +
`If you are using v-if on multiple elements, ` +
`use v-else-if to chain them instead.`,
{ start: element.start }
);
}
}
if (currentParent && !element.forbidden) {
if (element.elseif || element.else) {
processIfConditions(element, currentParent);
} else {
if (element.slotScope) {
// scoped slot
// keep it in the children list so that v-else(-if) conditions can
// find it as the prev node.
const name = element.slotTarget || '"default"';
(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[
name
] = element;
}
currentParent.children.push(element);
element.parent = currentParent;
}
}
// final children cleanup
// filter out scoped slots
element.children = element.children.filter((c) => !(c: any).slotScope);
// remove trailing whitespace node again
trimEndingWhitespace(element);
// check pre state
if (element.pre) {
inVPre = false;
}
if (platformIsPreTag(element.tag)) {
inPre = false;
}
// apply post-transforms
for (let i = 0; i < postTransforms.length; i++) {
postTransforms[i](element, options);
}
}
function trimEndingWhitespace(el) {
// remove trailing whitespace node
if (!inPre) {
let lastNode;
while (
(lastNode = el.children[el.children.length - 1]) &&
lastNode.type === 3 &&
lastNode.text === " "
) {
el.children.pop();
}
}
}
function checkRootConstraints(el) {
if (el.tag === "slot" || el.tag === "template") {
warnOnce(
`Cannot use <${el.tag}> as component root element because it may ` +
"contain multiple nodes.",
{ start: el.start }
);
}
if (el.attrsMap.hasOwnProperty("v-for")) {
warnOnce(
"Cannot use v-for on stateful component root element because " +
"it renders multiple elements.",
el.rawAttrsMap["v-for"]
);
}
}
一些辅助函数。
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(tag, attrs, unary, start, end) { ... },
end(tag, start, end) { ... },
chars(text: string, start: number, end: number) { ... },
comment(text: string, start, end) { ... },
}
)
return root;
这里调用了一个方法parseHTML
,用于解析HTML
字符串,之后又传入了一些类似hooks的方法。
进入parseHTML
。
parseHTML
const stack = []
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
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// Comment:
if (comment.test(html)) {
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
if (options.shouldKeepComment) {
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
if (conditionalComment.test(html)) {
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
advance(conditionalEnd + 2)
continue
}
}
// Doctype:
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
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)
}
if (options.chars && text) {
options.chars(text, index - text.length, index)
}
} 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(/<!\--([\s\S]*?)-->/g, '$1') // #7298
.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1')
}
if (shouldIgnoreFirstNewline(stackedTag, text)) {
text = text.slice(1)
}
if (options.chars) {
options.chars(text)
}
return ''
})
index += html.length - rest.length
html = rest
parseEndTag(stackedTag, index - endTagLength, index)
}
if (html === last) {
options.chars && options.chars(html)
if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
options.warn(`Mal-formatted tag at end of template: "${html}"`, { start: index + html.length })
}
break
}
}
一个while
循环,不停地去寻找html字符串中出现<
的位置。
如果<
出现的位置是第一个,先会判断是不是style
标签、script
标签、textArea
标签等,如果是这些标签会有特殊的处理,如果不是就会去解析html
字符串中的注释节点、Doctype
标签、闭合标签、开始标签,在解析这些html结构中最终都会调用advance
方法。advance
可以改变html
字符串的长度。
如果<
出现的位置不是第一个,就说明其中有文本节点,之后就是解析文本节点的逻辑。我们先来看看advance
的内容。
advance
的定义可以在下面找到。
function advance (n) {
index += n
html = html.substring(n)
}
将当前的解析索引前进n步,并且截断html
字符串前面已解析的部分。也就是说在while
循环内,只要命中一次规则,就会解析一部分,解析完毕后又会将索引向后移到未解析的位置,并且截断前面所有解析过的部分。所以html
字符串会越来越短,直至解析完毕,完全消失。
分析完advance
方法后,接下来我们来分析一下几个比较重要的编译过程。
开始标签的编译
开始标签的编译在parse
函数中的while
内:
// Start tag:
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
advance(1)
}
continue
}
先看看命中规则parseStartTag
方法:
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
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
}
}
}
首先使用正则匹配html字符串中的开始标签的开始符号——<
,如果有匹配到,就定义一个返回结果对象,tagName
为标签名,attrs
为属性,start
为开始的索引值。
然后调用advance
前进start[0].length
步。之后就是使用循环来完成这个返回结果对象。
回到parse
函数继续查看开始标签的编译过程。
如果命中了开始标签的匹配规则,就调用handleStartTag
。我们来看一下handleStartTag
的实现:
function handleStartTag (match) {
const tagName = match.tagName
const unarySlash = match.unarySlash
if (expectHTML) {
if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
parseEndTag(lastTag)
}
if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
parseEndTag(tagName)
}
}
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 (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
attrs[i].start = args.start + args[0].match(/^\s*/).length
attrs[i].end = args.end
}
}
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)
}
}
拿到刚刚从parseStartTag
从的返回结果tagName
(标签名称)、unarySlash
(一元标签符)。
使用变量unary
存储这个标签是否为一元标签。
接下来是解析html属性,具体就不用看了。
然后有个判断,如果当前标签不是一元标签的话就在stack
中入栈当前的标签信息。这个stack
标签栈是用于检查当前模板的标签闭合匹配情况的,之后在编译闭合标签的方法中可以得到体现。
最后调用了一次options
传入的hookstart
,这部分我们放在下一篇中去分析hook。
闭合标签的编译
闭合标签的编译在parse
函数的while
循环内:
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
//End tag:
const endTagMatch = html.match(endTag)
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
先使用正则判断是不是闭合标签的格式。如果匹配到了就先使用advance
函数前进endTagMatch[0].length
步,然后调动用parseEndTag
编译闭合标签:
function parseEndTag (tagName, start, end) {
let pos, lowerCasedTagName
if (start == null) start = index
if (end == null) end = index
// 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)
}
}
}
在这个方法中可以看出,编译器会遍历stack
标签栈,找到最近一个的开始标签,如果当前这个闭合标签不匹配最近一个开始标签,就会报错:tag <${stack[i].tag}> has no matching end tag.
,相信这个报错有很多童鞋都遇到过。
文本节点的编译
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)
}
if (options.chars && text) {
options.chars(text, index - text.length, index)
}
如果命中testEnd >= 0
就说明现在的html字符串不是以<
为开头的,就表示其中有文本。文本然后编译器就会截取<
后面的部分。为了防止在文本内也会有<
内容,这里编译器写了一个循环去跳过这些文本中的<
部分,保证最后截取到的rest
必须是闭合标签开始后剩余的部分。
之后截取文本部分,赋值给text
变量,用于之后AST的构建。
之后还有一个判断if (textEnd < 0)
,这个判断如果命中了,就说明这个html字符串之后全部都是文本了,已经没有任何标签了,于是直接就把html
赋值给了text
文本内容。再调用advance
将游标前进到文本节点之后。最后再使用调用chars
hook将文本内容添加到AST上(hook的内容我们放在下一篇中去分析)。
总结
总体来说,parse
的代码量非常的多,主要集中在parseHTML
方法。如果不忽略非主线代码的话,看起来非常的吃力,同时这部分我感觉我个人可能分析的不是很到位。