Vue3源码——模板template如何编译为AST
这一节我们来探究一下 Vue3 关于模板编译这一块的内容。这里,关于 Vue3 从模板template到最后呈现页面数据的整个过程做一个简单的剧透:
- 将编写好的template模板,转换为AST
- 将 AST 转换为JS AST
- 将JS AST转换为render函数
- 执行render函数,将DOM结构呈现在页面上
compile
接下来,我们正式进入源码阶段:
function compile(
template: string,
options: CompilerOptions = {}
) {
return baseCompile(
template,
extend({}, parserOptions, options, {
nodeTransforms: [
ignoreSideEffectTags,
...DOMNodeTransforms,
...(options.nodeTransforms || [])
],
directiveTransforms: extend(
{},
DOMDirectiveTransforms,
options.directiveTransforms || {}
),
transformHoist: __BROWSER__ ? null : stringifyStatic
})
)
}
我们可以看到compile函数主要就是负责将options整合,而主体逻辑都在baseCompile函数中:
baseCompile
function baseCompile(template, options = {}) {
...
const isModuleMode = options.mode === "module";
const prefixIdentifiers = false;
...
// 将template转换为AST
const ast = isString(template) ? baseParse(template, options) : template;
// 获取用于操作转换ast的方法
const [nodeTransforms, directiveTransforms] = getBaseTransformPreset(prefixIdentifiers);
// 将AST转换为JS AST
transform(
ast,
extend({}, options, {
prefixIdentifiers,
nodeTransforms: [
...nodeTransforms,
...options.nodeTransforms || []
],
directiveTransforms: extend(
{},
directiveTransforms,
options.directiveTransforms || {}
)
})
);
// 将JS AST生成render函数
return generate(
ast,
extend({}, options, {
prefixIdentifiers
})
);
}
baseCompile函数的整体流程也比较清晰,主要做的事情正是我们在文章开头提到的关于模板编译流程中的前三步:template => AST => JS AST => render。
流程比较简单,但每一步其实都包含了不小的逻辑处理,我们这一节先看第一步——template转换AST。
template转换为AST
进入 baseCompile函数,首先会判断 template 是否为字符串模板,如果是字符串模板的话,则对字符串模板template进行解析。
这里我们在看源码之前,先看一下将 template 转换为 AST,到底这个 AST 是长什么样子的:
<div id="demo">
<span id="text">{{ state.a }}</span>
</div>
上面代码转换为 AST 之后为:

AST 的具体属性字段,先暂时不深究,我们在下面源码再具体分析:
baseParse
function baseParse(content, options = {}) {
// 创建字符串解析上下文
const context = createParserContext(content, options);
// 获取起始位置
const start = getCursor(context);
// 创建AST
return createRoot(
parseChildren(context, 0 /* DATA */, []),
getSelection(context, start)
);
}
function createParserContext(content, rawOptions) {
const options = extend({}, defaultParserOptions);
let key;
for (key in rawOptions) {
options[key] = rawOptions[key] === void 0 ? defaultParserOptions[key] : rawOptions[key];
}
return {
options,
column: 1,
line: 1,
offset: 0,
// 存储我们的模板字符串template
originalSource: content,
source: content,
inPre: false,
inVPre: false,
onWarn: options.onWarn
};
}
function getCursor(context) {
const { column, line, offset } = context
return { column, line, offset }
}
在baseParse函数中:
- 首先,通过createParserContext函数初始化一个字符串解析上下文
- 然后,通过getCursor函数获取起始位置
这两步说到底都还是一些初始化的工作,我们生成AST的核心逻辑还是在最后createRoot(parseChildren(context, 0 /* DATA */, []), getSelection(context, start));
createRoot
function createRoot(children, loc = locStub) {
return {
type: 0 /* ROOT */,
children,
helpers: /* @__PURE__ */ new Set(),
components: [],
directives: [],
hoists: [],
imports: [],
cached: 0,
temps: 0,
codegenNode: void 0,
loc
};
}
createRoot函数的作用就是将接收到的参数(children
和loc
)和其他一些初始化参数进行整合,最后返回出去,即为我们想要得到的AST。
我们先看一下生成children的parseChildren函数:
parseChildren
parseChildren函数比较长,如果一行行看可能会头皮发麻,我们先调整下站位,从远处观望一下,看看parseChildren到底都做了什么:

parseChildren的整体思路还是比较清晰的,主要逻辑就是通过遍历模板字符串,并对字符串进行分情况讨论:
- 以 {{ 开头
- 以 < 开头
- 以 <! 开头
- 以 </ 开头
- 以 < + 字母 开头
- 以 <? 开头
- 文本节点
以 {{ 开头
当我们遍历模板字符串遇到以 {{ 开头时,进入下面逻辑:
// context.options.delimiters 为 [`{{`, `}}`]
if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
// 如果s是以{{开头
node = parseInterpolation(context, mode);
}
parseInterpolation函数主要是负责处理插值,比如{{ msg }}
。将字符串解析上下文context
传入parseInterpolation函数拿到node节点。
function parseInterpolation(context, mode) {
// 这里open为 {{ close为 }}
const [open, close] = context.options.delimiters;
// 确定 }} 位置
const closeIndex = context.source.indexOf(close, open.length);
// 如果 不存在 }} 说明这里的模板字符串写法不规范,直接报错
if (closeIndex === -1) {
emitError(context, 25 /* X_MISSING_INTERPOLATION_END */);
return void 0;
}
// 获取起始位置
const start = getCursor(context);
// 将指针向后移动,同是会将context.source的内容也更新为 跳过了{{ 之后的内容
advanceBy(context, open.length);
// 插值起点指针
const innerStart = getCursor(context);
// 插值终点指针
const innerEnd = getCursor(context);
// 插值内容长度
const rawContentLength = closeIndex - open.length;
// 获取插值内容,也就是{{和}}之间的内容
const rawContent = context.source.slice(0, rawContentLength);
// 内部会调用advanceBy方法,将指针向后移动,并获取插值内容
const preTrimContent = parseTextData(context, rawContentLength, mode);
// 插值内容去空格
const content = preTrimContent.trim();
// 如果存在空格,计算偏移值
const startOffset = preTrimContent.indexOf(content);
if (startOffset > 0) {
advancePositionWithMutation(innerStart, rawContent, startOffset);
}
// 如果尾部存在空格,计算偏移值
const endOffset = rawContentLength - (preTrimContent.length - content.length - startOffset);
advancePositionWithMutation(innerEnd, rawContent, endOffset);
// 指针向后移动至}}之后
advanceBy(context, close.length);
return {
type: 5 /* INTERPOLATION */,
content: {
type: 4 /* SIMPLE_EXPRESSION */,
isStatic: false,
constType: 0 /* NOT_CONSTANT */,
// 插值的内容
content,
loc: getSelection(context, innerStart, innerEnd)
},
loc: getSelection(context, start)
};
}
parseInterpolation函数在做的工作就是通过不断地移动指针,然后判断对应位置的内容,去维护相应的信息,每一步的作用在上面注释中已写的比较详细了。
其中,移动指针的方法是advanceBy。
function advanceBy(context, numberOfCharacters) {
const { source } = context;
// 维护context的位置变量
advancePositionWithMutation(context, source, numberOfCharacters);
context.source = source.slice(numberOfCharacters);
}
advanceBy函数接收两个参数:上下文context
和要跳过字符的长度length
。
在函数内部,会从context中取出剩余的模板字符串source,根据要跳过的字符长度维护上下文context中的位置变量,最后将上下文context中的source赋值为指针移动后的剩余模板字符串。
以 < 开头
当剩余的字符串以<开头时,如果不符合规范,则直接报错;否则,则会继续进行分情况讨论:
以 <! 开头时:
if (s[1] === "!") {
// s 的前两个字符为<!
if (startsWith(s, "<!--")) {
// 当做注释处理
node = parseComment(context);
} else if (startsWith(s, "<!DOCTYPE")) {
// 处理<!DOCTYPE 情况
node = parseBogusComment(context);
} else if (startsWith(s, "<![CDATA[")) {
// 处理 <![CDATA[ 情况
if (ns !== 0 /* HTML */) {
node = parseCDATA(context, ancestors);
} else {
emitError(context, 1 /* CDATA_IN_HTML_CONTENT */);
node = parseBogusComment(context);
}
} else {
// 都不是则报错
emitError(context, 11 /* INCORRECTLY_OPENED_COMMENT */);
node = parseBogusComment(context);
}
}
- 当以
<!--
开头,则调用parseComment方法,进入注释处理逻辑 - 当以
<!DOCTYPE
开头,则调用parseBogusComment方法 - 当以
<![CDATA[
开头,则调用parseCDATA方法 - 其他情况,进行报错处理
上面几种情况对应的处理函数parseComment,parseBogusComment,parseCDATA这里暂不展开,有兴趣的可以仿照上面读源码的方法来探究对应的函数。
以 </ 开头时:
if (s[1] === "/") {
// 如果s是 </ 开头
if (s.length === 2) {
// 写法不规范直接报错
emitError(context, 5 /* EOF_BEFORE_TAG_NAME */, 2);
} else if (s[2] === ">") {
// </> 缺少结束标签报错
emitError(context, 14 /* MISSING_END_TAG_NAME */, 2);
advanceBy(context, 3);
continue;
} else if (/[a-z]/i.test(s[2])) {
// 文本中存在多余结束标签报错
emitError(context, 23 /* X_INVALID_END_TAG */);
parseTag(context, TagType.End, parent);
continue;
} else {
emitError(
context,
12 /* INVALID_FIRST_CHARACTER_OF_TAG_NAME */,
2
);
node = parseBogusComment(context);
}
}
当剩余字符串遍历到以 </ 开头的时候,如果进入到这一层判断,那多半是模板字符串代码写的不规范,会进行报错处理。
这里可能就会有些疑问了,为什么到这一层判定时,会对 </ 的所有情况都判定为错误呢?如果是一个正常的闭合标签又当如何呢?我们带着疑问先往下看。
以 (< + 字母) 开头
if (/[a-z]/i.test(s[1])) {
// 解析标签元素节点
node = parseElement(context, ancestors);
}
当字符串是以 < + 字母 开头的话,则会进入解析元素标签的逻辑中。将上下文context和标签栈ancestors作为参数传入parseElement方法中,去获取解析后的node。
这里先说明一下标签栈ancestors的作用:
Vue在解析标签的时候,使用了一个栈的结构来维护解析的进度,当解析一个开始标签时,则会将其入栈,当解析到对应的结束标签时,则会让标签出栈。
接下来,我们进入parseElement函数的源码中来继续探索:
function parseElement(context, ancestors) {
...
// 获取当前标签的父标签节点
const parent = last(ancestors);
// 解析标签
const element = parseTag(context, TagType.Start, parent);
...
// 如果是自闭合标签
if (element.isSelfClosing || context.options.isVoidTag(element.tag)) {
return element;
}
// 不是自闭合标签,将标签入栈
ancestors.push(element);
const mode = context.options.getTextMode(element, parent);
// 递归解析子节点,同时将标签栈ancestors传入
const children = parseChildren(context, mode, ancestors);
// 子节点解析完毕,标签出栈
ancestors.pop();
element.children = children;
// 遇到结束标签
if (startsWithEndTagOpen(context.source, element.tag)) {
// 解析结束标签,并前进代码到结束标签后
parseTag(context, TagType.End, parent);
} else {
...
}
element.loc = getSelection(context, element.loc.start);
...
return element;
}
我们可以看到在parseElement函数中做的事情就是:
- 解析当前标签,判断标签是否为自闭合,是自闭合则直接返回,不是则将当前标签入栈
ancestors
。 - 递归解析子节点,最终得到子节点的AST
- 子节点解析完毕,当前标签出栈,并将子节点AST作为
children
赋值给当前element
的children
属性 - 判定结束标签,维护指针至结束标签之后
- 最后,返回维护好的节点AST
这里我们注意到有一个解析标签的函数parseTag,我们来看一下它又是如何实现的:
function parseTag(context, type, parent) {
// 获取开始指针位置
const start = getCursor(context);
// 匹配标签中的字符
const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source);
// 获取标签名称tag
const tag = match[1];
const ns = context.options.getNamespace(tag, parent);
// 指针后移
advanceBy(context, match[0].length);
// 指针后移,跳过空格
advanceSpaces(context);
// 获取当前指针
const cursor = getCursor(context);
// 获取剩余未解析模板字符串
const currentSource = context.source;
// 解析标签上的属性
let props = parseAttributes(context, type);
...
// 是否自闭合
let isSelfClosing = false;
if (context.source.length === 0) {
// 如果到这一步时,模板字符串已经遍历完了,说明写法不规范,报错
emitError(context, 9 /* EOF_IN_TAG */);
} else {
// 判定是否有自闭合标签的标志
isSelfClosing = startsWith(context.source, "/>");
if (type === 1 /* End */ && isSelfClosing) {
emitError(context, 4 /* END_TAG_WITH_TRAILING_SOLIDUS */);
}
// 根据是否为自闭合标签,决定指针向后移动几个字符
advanceBy(context, isSelfClosing ? 2 : 1);
}
if (type === 1 /* End */) {
// 如果是闭合标签
return;
}
let tagType = 0 /* ELEMENT */;
if (!context.inVPre) {
// 根据标签是组件,插槽还是模板,为tagType标记不同的值
if (tag === "slot") {
tagType = 2 /* SLOT */;
} else if (tag === "template") {
if (props.some(
(p2) => p2.type === 7 /* DIRECTIVE */ && isSpecialTemplateDirective(p2.name)
)) {
tagType = 3 /* TEMPLATE */;
}
} else if (isComponent(tag, props, context)) {
tagType = 1 /* COMPONENT */;
}
}
return {
type: 1 /* ELEMENT */,
ns,
tag,
tagType,
props,
isSelfClosing,
children: [],
loc: getSelection(context, start),
codegenNode: void 0
};
}
parseTag函数做的事情就是解析标签:
- 首先,通过正则匹配的方式取出标签名tag
- 然后通过parseAttributes方法获取标签上的属性
- 根据 /> 标志,判断标签是否自闭合
- 根据标签是组件,插槽还是模板,为tagType标记不同的值
- 最后,将上述信息包装成对象返回
而以<开头的其他情况,Vue都是只做了报错处理,这里就不再细说这些情况了。
至此,我们基本上就已经了解了Vue是如何解析template的标签内容的。
最后我们再看看一下,当解析遇到文本类型时,Vue又是如何解析的。
解析普通文本节点
if (!node) {
node = parseText(context, mode);
}
我们可以发现,解析文本节点用到的函数是 parseText:
function parseText(context, mode) {
// 根据mode确定文本解析的结束标志
const endTokens = mode === 3 /* CDATA */ ? ["]]>"] : ["<", context.options.delimiters[0]];
let endIndex = context.source.length;
// 确定文本解析的结束位置
for (let i = 0; i < endTokens.length; i++) {
const index = context.source.indexOf(endTokens[i], 1);
if (index !== -1 && endIndex > index) {
endIndex = index;
}
}
// 获取指针位置
const start = getCursor(context);
// 解析文本,并将指针后移
const content = parseTextData(context, endIndex, mode);
return {
type: 2 /* TEXT */,
content,
loc: getSelection(context, start)
};
}
function parseTextData(context, length, mode) {
// 获取文本内容
const rawText = context.source.slice(0, length);
// 移动指针
advanceBy(context, length);
if (mode === 2 /* RAWTEXT */ || mode === 3 /* CDATA */ || !rawText.includes("&")) {
return rawText;
} else {
return context.options.decodeEntities(
rawText,
mode === 4 /* ATTRIBUTE_VALUE */
);
}
}
parseText函数的逻辑相比前面几个解析函数还算是比较简单了:
- 首先,根据mode确定了结束的标志,一般来说都是
[">", "{{"]
- 然后,在剩余的模板字符串中去搜索结束标志,找到最近的结束位置。而从开头到结束位置,这中间的字符串就是我们需要的文本内容。
- 移动指针,返回解析后的文本AST。
总结
至此,我们对模板template转换为AST的具体操作有了一定的了解。说到底,就是通过指针的不断移动,维护剩余的模板字符串,再针对不同的情况具体讨论,调用对应封装好的处理函数,这样一点一点的蚕食模板字符串template,最终将得到的内容拼接为我们最终想要的AST。
转载自:https://juejin.cn/post/7249204284179906618