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
}注意第二步中的 ancestors 参数——它是一个栈,记录了从根节点到当前节点的路径。当 parseChildren 遇到一个结束标签时,它会向上查找 ancestors 栈,确认这个结束标签确实与某个祖先的开始标签匹配。如果不匹配,就报错。这就是 Vue 能给出精确的"标签未闭合"错误提示的原因。
parseTag:解析标签名和属性
typescript
function parseTag(
context: ParserContext,
type: TagType
): ElementNode {
// 匹配标签名: <div 或 </div
const match = /^<\/?([a-z][^\t\r\n\f />]*)/i.exec(context.source)!
const tag = match[1]
advanceBy(context, match[0].length) // 消费 "<div" 或 "</div"
advanceSpaces(context) // 跳过空白
// 解析属性列表
const props = parseAttributes(context, type)
// 检查自闭合
let isSelfClosing = false
if (context.source.length === 0) {
emitError(context, ErrorCodes.EOF_IN_TAG)
} else {
isSelfClosing = startsWith(context.source, '/>')
advanceBy(context, isSelfClosing ? 2 : 1) // 消费 "/>" 或 ">"
}
// 确定标签类型
let tagType = ElementTypes.ELEMENT
if (tag === 'slot') {
tagType = ElementTypes.SLOT
} else if (tag === 'template') {
if (props.some(p => p.type === NodeTypes.DIRECTIVE &&
isSpecialTemplateDirective(p.name))) {
tagType = ElementTypes.TEMPLATE
}
} else if (isComponent(tag, props, context)) {
tagType = ElementTypes.COMPONENT
}
return {
type: NodeTypes.ELEMENT,
tag,
tagType,
props,
isSelfClosing,
children: [],
loc: getSelection(context, start),
codegenNode: undefined
}
}parseAttribute:属性与指令的分流
属性解析是模板解析中最复杂的部分之一,因为 Vue 的属性有多种形态:
| 语法 | 类型 | 示例 |
|---|---|---|
| 普通属性 | ATTRIBUTE | class="foo" |
v- 指令 | DIRECTIVE | v-if="show" |
: 缩写 | DIRECTIVE (bind) | :title="msg" |
@ 缩写 | DIRECTIVE (on) | @click="handler" |
# 缩写 | DIRECTIVE (slot) | #default="{ item }" |
. 修饰符 | DIRECTIVE (bind + prop) | .textContent="text" |
typescript
function parseAttribute(
context: ParserContext,
nameSet: Set<string>
): AttributeNode | DirectiveNode {
// 解析属性名
const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exec(context.source)!
const name = match[0]
// 重复属性检测
if (nameSet.has(name)) {
emitError(context, ErrorCodes.DUPLICATE_ATTRIBUTE)
}
nameSet.add(name)
// 解析 = 和属性值
let value: AttributeValue = undefined
if (/^[\t\r\n\f ]*=/.test(context.source)) {
advanceSpaces(context)
advanceBy(context, 1) // 消费 "="
advanceSpaces(context)
value = parseAttributeValue(context)
}
// 判断是否为指令
if (/^(v-[A-Za-z0-9-]|:|\.|@|#)/.test(name)) {
// 这是一个指令
const dirMatch = /^(?:v-([a-z0-9-]+))?(?:(?::|^\.|^@|^#)(\[[^\]]+\]|[^\.]+))?(.+)?$/i
.exec(name)!
let dirName = dirMatch[1] ||
(startsWith(name, ':') || startsWith(name, '.') ? 'bind'
: startsWith(name, '@') ? 'on'
: 'slot')
let arg: ExpressionNode | undefined
if (dirMatch[2]) {
const isSlot = dirName === 'slot'
const startOffset = name.lastIndexOf(dirMatch[2])
arg = {
type: NodeTypes.SIMPLE_EXPRESSION,
content: dirMatch[2],
isStatic: !(dirMatch[2].startsWith('[')) // [expr] 为动态参数
}
}
// 解析修饰符: .stop.prevent
const modifiers = dirMatch[3]
? dirMatch[3].slice(1).split('.').filter(Boolean)
: []
return {
type: NodeTypes.DIRECTIVE,
name: dirName,
exp: value && { type: NodeTypes.SIMPLE_EXPRESSION, content: value.content, isStatic: false },
arg,
modifiers,
loc: getSelection(context, start)
}
}
// 普通属性
return {
type: NodeTypes.ATTRIBUTE,
name,
value: value && { type: NodeTypes.TEXT, content: value.content },
loc: getSelection(context, start)
}
}🔥 深度洞察
Vue 模板中
v-bind的三种写法(v-bind:title、:title、.title)在 Parse 阶段就被统一为DirectiveNode,name字段都是'bind'。但.title会自动添加prop修饰符。这种"语法糖在 Parse 阶段脱糖"的策略让后续的 Transform 和 Codegen 只需处理规范化后的 AST,极大简化了下游逻辑。
parseInterpolation:解析插值表达式
typescript
function parseInterpolation(
context: ParserContext,
mode: TextModes
): InterpolationNode | undefined {
const [open, close] = context.options.delimiters // 默认 ['{ {', '} }']
const closeIndex = context.source.indexOf(close, open.length)
if (closeIndex === -1) {
emitError(context, ErrorCodes.X_MISSING_INTERPOLATION_END)
return undefined
}
advanceBy(context, open.length) // 消费 "{{"
const rawContentLength = closeIndex - open.length
const rawContent = context.source.slice(0, rawContentLength)
const preTrimContent = parseTextData(context, rawContentLength, mode)
const content = preTrimContent.trim()
advanceBy(context, close.length) // 消费 "}}"
return {
type: NodeTypes.INTERPOLATION,
content: {
type: NodeTypes.SIMPLE_EXPRESSION,
isStatic: false,
content,
loc: getSelection(context, innerStart, innerEnd)
},
loc: getSelection(context, start)
}
}插值解析相对简单:找到 {{ 和 }} 的位置,提取中间的表达式字符串。注意表达式内容会被 trim(),所以 {{ msg }} 和 {{msg}} 的解析结果完全一致。
TextModes:解析模式的影响
不是所有的 HTML 元素内部都按同样的规则解析。Vue 定义了四种文本模式:
typescript
enum TextModes {
DATA, // 普通元素内部:解析标签、插值、实体
RCDATA, // <textarea>/<title> 内部:解析插值和实体,但不解析标签
RAWTEXT, // <style>/<script> 内部:一切都是纯文本
CDATA, // <![CDATA[...]]> 内部:纯文本
ATTRIBUTE_VALUE // 属性值内部:解析实体
}当解析进入一个 <textarea> 时,模式切换为 RCDATA。此时即使遇到 <div>,也不会被当作标签解析——因为 <textarea> 的内容规范不允许嵌套标签。这与浏览器的行为保持一致。
8.3 Transform 阶段:从模板 AST 到增强 AST
如果说 Parse 是"忠实的翻译官"——它只负责把模板结构如实转换为树形数据,不做任何判断。那么 Transform 就是"战略分析师"——它分析 AST 的语义,做出优化决策,为每个节点生成最优的代码生成方案。
Transform 的核心架构
Transform 阶段的入口是 transform() 函数:
typescript
// packages/compiler-core/src/transform.ts
export function transform(root: RootNode, options: TransformOptions) {
const context = createTransformContext(root, options)
// 遍历并转换整棵 AST
traverseNode(root, context)
// 静态提升
if (options.hoistStatic) {
hoistStatic(root, context)
}
// 创建根级 Block
if (!options.ssr) {
createRootCodegen(root, context)
}
// 收集元信息
root.helpers = new Set([...context.helpers.keys()])
root.components = [...context.components]
root.directives = [...context.directives]
root.imports = context.imports
root.hoists = context.hoists
root.temps = context.temps
root.cached = context.cached
}