Appearance
第 8 章 模板编译深度剖析
本章要点
- Parse 阶段的完整实现:手写递归下降解析器如何将模板字符串逐字符消费为 AST
- 词法分析的状态机模型:标签开启、属性读取、插值解析的状态流转
- Transform 阶段的插件化架构:nodeTransforms 与 directiveTransforms 的协作机制
- 核心转换插件剖析:v-if、v-for、v-model 等指令的编译期展开
- PatchFlags 与 Block 的注入时机:从 AST 节点到 codegenNode 的关键跃迁
- Codegen 阶段的代码拼接策略:如何生成可读且高效的渲染函数
- Source Map 生成:从模板行列号到生成代码行列号的映射链路
第 7 章中,我们以"导游图"的视角鸟瞰了编译器的三阶段流水线。你知道了 Parse、Transform、Codegen 各自的职责边界,也知道了 PatchFlags、Block Tree、静态提升是在哪个阶段被注入的。但一份导游图再精美,也无法替代你亲自走进每一条街巷。
本章就是那次步行之旅。我们将从编译器的入口函数 baseCompile() 出发,沿着模板字符串被"消化"的路径,逐阶段、逐函数地追踪它的变形过程。每到一个关键节点,我们都会停下来,看看源码中的实际实现,理解设计者的意图。
一个忠告:本章代码量较大。建议你打开 Vue 3 源码仓库(packages/compiler-core/src/),与本章文字对照阅读。当你能在脑海中完整复现一个模板从字符串到渲染函数的旅程时,你就真正"拥有"了 Vue 编译器。
8.1 编译器入口:baseCompile 与 compile
两个 compile 函数
Vue 3 的编译器暴露了两层入口:
typescript
// packages/compiler-dom/src/index.ts
export function compile(
template: string,
options?: CompilerOptions
): CodegenResult {
return baseCompile(
template,
extend({}, parserOptions, options, {
nodeTransforms: [
...DOMNodeTransforms,
...(options?.nodeTransforms || [])
],
directiveTransforms: extend(
{},
DOMDirectiveTransforms,
options?.directiveTransforms || {}
)
})
)
}compiler-dom 的 compile() 是面向用户的入口,它在 compiler-core 的 baseCompile() 基础上注入了 DOM 平台特定的解析选项和转换插件。这种"核心+平台"的分层模式在 Vue 3 中反复出现——响应式系统、渲染器、编译器都遵循同样的哲学。
baseCompile 的三步曲
typescript
// packages/compiler-core/src/compile.ts
export function baseCompile(
source: string | RootNode,
options: CompilerOptions = {}
): CodegenResult {
// 第一步:Parse
const ast = isString(source) ? baseParse(source, options) : source
// 准备转换插件
const [nodeTransforms, directiveTransforms] =
getBaseTransformPreset(options.prefixIdentifiers)
// 第二步:Transform
transform(
ast,
extend({}, options, {
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || [])
],
directiveTransforms: extend(
{},
directiveTransforms,
options.directiveTransforms || {}
)
})
)
// 第三步:Codegen
return generate(ast, extend({}, options, { prefixIdentifiers }))
}三行核心调用,三个阶段。简洁到令人愉悦。但每一行背后都藏着数千行实现代码。让我们逐一展开。
8.2 Parse 阶段:从字符串到 AST
解析器的整体结构
Vue 的模板解析器是一个手写的递归下降解析器(Recursive Descent Parser)。它没有使用 lex/yacc 之类的解析器生成工具,也没有使用正则表达式来做词法分析——所有的解析逻辑都是通过逐字符扫描和条件分支实现的。
为什么手写?三个理由:
- 性能:手写解析器可以避免正则引擎的回溯开销,在大型模板上比基于正则的方案快 2-3 倍
- 错误恢复:手写解析器可以在遇到语法错误时精确定位错误位置,给出有意义的提示("缺少闭合标签
</div>,对应第 12 行的<div>"),而不是抛出一个含糊的"Unexpected token" - 控制力:Vue 的模板语法有很多"非标准 HTML"的扩展(
v-if、@click、{{ }}),手写解析器可以在任何位置插入自定义逻辑
解析上下文:ParserContext
解析器的所有状态都封装在一个 ParserContext 对象中:
typescript
// packages/compiler-core/src/parse.ts(简化)
interface ParserContext {
source: string // 剩余未解析的模板字符串
originalSource: string // 原始完整模板
offset: number // 当前偏移量(字符数)
line: number // 当前行号
column: number // 当前列号
options: ParserOptions // 解析选项
inPre: boolean // 是否在 <pre> 标签内
inVPre: boolean // 是否在 v-pre 指令范围内
}解析过程的核心思想是消费(consume):每解析出一个 token(标签、属性、文本、插值),就从 source 的头部"吃掉"对应的字符,同时更新行号和列号。当 source 被完全消费时,解析完成。
parseChildren:递归下降的核心
parseChildren() 是整个解析器的心脏。它在一个 while 循环中不断检查 source 的当前字符,根据字符类型决定调用哪个子解析函数:
typescript
// packages/compiler-core/src/parse.ts(简化)
function parseChildren(
context: ParserContext,
mode: TextModes,
ancestors: ElementNode[]
): TemplateChildNode[] {
const nodes: TemplateChildNode[] = []
while (!isEnd(context, mode, ancestors)) {
const s = context.source
let node: TemplateChildNode | undefined
if (mode === TextModes.DATA || mode === TextModes.RCDATA) {
if (mode === TextModes.DATA && s[0] === '<') {
if (s[1] === '!') {
// 注释: <!-- ... -->
if (startsWith(s, '<!--')) {
node = parseComment(context)
} else if (startsWith(s, '<!DOCTYPE')) {
// DOCTYPE: 作为注释处理
node = parseBogusComment(context)
}
} else if (s[1] === '/') {
// 结束标签: </div>
// 不生成节点,由父级处理
break
} else if (/[a-z]/i.test(s[1])) {
// 开始标签: <div>
node = parseElement(context, ancestors)
}
} else if (startsWith(s, context.options.delimiters[0])) {
// 插值: { { msg } }
node = parseInterpolation(context, mode)
}
}
// 兜底:纯文本
if (!node) {
node = parseText(context, mode)
}
if (isArray(node)) {
for (let i = 0; i < node.length; i++) {
pushNode(nodes, node[i])
}
} else {
pushNode(nodes, node)
}
}
return nodes
}🔥 深度洞察
parseChildren的分支逻辑揭示了 Vue 模板的语法优先级:<开头的先尝试解析为标签或注释;{{开头的解析为插值;其他一律视为纯文本。这个优先级决定了模板中的歧义如何被解析——比如文本中出现的<字符,如果后面跟的不是字母或/,就会被当作普通文本处理。
parseElement:解析元素的三步走
一个 HTML 元素的解析分为三步:
typescript
function parseElement(
context: ParserContext,
ancestors: ElementNode[]
): ElementNode {
// 第一步:解析开始标签 <div class="foo">
const element = parseTag(context, TagType.Start)
// 自闭合标签(如 <br/>)不需要后续步骤
if (element.isSelfClosing || context.options.isVoidTag?.(element.tag)) {
return element
}
// 第二步:递归解析子节点
ancestors.push(element)
const mode = context.options.getTextMode?.(element, parent) ?? TextModes.DATA
const children = parseChildren(context, mode, ancestors)
ancestors.pop()
element.children = children
// 第三步:解析结束标签 </div>
if (startsWithEndTagOpen(context.source, element.tag)) {
parseTag(context, TagType.End)
} else {
emitError(context, ErrorCodes.X_MISSING_END_TAG, 0, element.loc.start)
}
// 更新位置信息
element.loc = getSelection(context, element.loc.start)
return element
}