Vue 3 核心源码深度剖析系列(v3.5.44)-第 05 章 编译器内幕(一):编译管线全景与词法/语法分析
本章对应
vuejs/core3.5.44,主讲packages/compiler-core/src下的compile.ts、tokenizer.ts、parser.ts、ast.ts。目标是回答一个问题:一段模板字符串"<div v-if='ok'>{{ msg }}</div>",是怎样一步步变成编译器内部那棵类型化的 AST 的?这一章不涉及
transform(第 06 章)与codegen(第 07 章)的细节,但会完整交代它们在管线中的位置与接口契约,以便读者建立全局观。
导语:为什么理解 parser 比理解 transform 更难
Vue 3 的编译器有一条看似清晰的流水线:parse → transform → generate。多数讲解文章会重点写 transform 和 codegen,因为它们直接决定生成代码的性能——patchFlags、静态提升、Block 树都在那里。但真正"吃掉"了编译器一半复杂度、也最容易在边界输入上出 bug 的,其实是前置的 词法分析(tokenizer)+ 语法分析(parser)。
原因有三:
- 模板是 HTML 的超集,而 HTML 的解析规则本身就不规则。HTML 不是良构语言,浏览器必须容忍畸形输入(未闭合标签、
<出现在属性值里、<script>里的</script>转义……)。Vue 要尽量贴近浏览器行为,于是把htmlparser2的 tokenizer 移植了过来(见packages/compiler-core/src/tokenizer.ts:1-23的 MIT 授权声明)。 - Vue 在此之上叠加了自己的方言:
{{ }}插值、v-指令、:/@/#简写、[动态参数]、修饰符。这些都必须融进同一个字符流状态机里。 - 位置信息是编译期最主要的调试资产:source map、错误定位、SFC 重写都依赖
loc。而位置追踪在"回退重扫"(reconsume)的场景下极易算错,稍有不慎整棵 AST 的column就全体偏移。
本章的组织方式:先给全景(5.1、5.2),再钻进 tokenizer 状态机(5.3、5.4),然后讲位置与上下文(5.5、5.6),接着是 parser 主循环与 node 栈(5.7)、AST 节点体系(5.8)、指令与表达式解析(5.9、5.10)、错误处理(5.11)、HTML 嵌套与 void 标签(5.12)、source map(5.13),最后用一段可运行脚本打印真实 AST(5.14)并回顾(5.15)。
5.1 编译管线全景:baseCompile 的三步走
5.1.1 入口:baseCompile
packages/compiler-core/src/compile.ts:67-122 是整个 compiler-core 的中枢。它接收模板源(字符串或已解析的 RootNode)与编译选项,返回 CodegenResult({ code, ast, preamble?, map? })。
export function baseCompile(
source: string | RootNode,
options: CompilerOptions = {},
): CodegenResult {
const onError = options.onError || defaultOnError
const isModuleMode = options.mode === 'module'
if (__BROWSER__) {
if (options.prefixIdentifiers === true) {
onError(createCompilerError(ErrorCodes.X_PREFIX_ID_NOT_SUPPORTED))
} else if (isModuleMode) {
onError(createCompilerError(ErrorCodes.X_MODULE_MODE_NOT_SUPPORTED))
}
}
const prefixIdentifiers =
!__BROWSER__ && (options.prefixIdentifiers === true || isModuleMode)
if (!prefixIdentifiers && options.cacheHandlers) {
onError(createCompilerError(ErrorCodes.X_CACHE_HANDLER_NOT_SUPPORTED))
}
if (options.scopeId && !isModuleMode) {
onError(createCompilerError(ErrorCodes.X_SCOPE_ID_NOT_SUPPORTED))
}
const resolvedOptions = extend({}, options, { prefixIdentifiers })
const ast = isString(source) ? baseParse(source, resolvedOptions) : source
const [nodeTransforms, directiveTransforms] =
getBaseTransformPreset(prefixIdentifiers)
if (!__BROWSER__ && options.isTS) {
const { expressionPlugins } = options
if (!expressionPlugins || !expressionPlugins.includes('typescript')) {
options.expressionPlugins = [...(expressionPlugins || []), 'typescript']
}
}
transform(ast, extend({}, resolvedOptions, {
nodeTransforms: [...nodeTransforms, ...(options.nodeTransforms || [])],
directiveTransforms: extend({}, directiveTransforms, options.directiveTransforms || {}),
}))
return generate(ast, resolvedOptions)
}
(注:上为便于阅读改写为紧凑行,语义与源码完全一致,源码见 compile.ts:67-122。)
5.1.2 三步契约
| 步骤 | 函数 | 输入 | 输出 | 是否纯函数 |
|---|---|---|---|---|
| 词法+语法分析 | baseParse(source, resolvedOptions) (parser.ts:1028) | 模板字符串 | RootNode | 是(但对 currentOptions/currentRoot 有模块级可变状态) |
| 变换 | transform(ast, options) (transform.ts) | RootNode + 选项 | 原地改写 ast,填充 helpers/hoists/codegenNode | 否(原地变更) |
| 代码生成 | generate(ast, resolvedOptions) (codegen.ts:283) | RootNode + 选项 | CodegenResult | 是 |
关键观察:transform 是原地改写,它把 RootNode.children 里的元素节点替换成 IfNode/ForNode 等结构,并把 codegenNode 挂到节点上;generate 只负责把 ast 序列化成字符串(以及 source map)。所以 baseCompile 返回的 ast 已经是被 transform 污染过的"半成品 AST",而不是纯粹的语法树——这一点在 5.14 的实战里会看到。
5.1.3 getBaseTransformPreset:默认 transform 的顺序契约
compile.ts:32-63 定义了默认的 nodeTransforms 与 directiveTransforms:
export function getBaseTransformPreset(prefixIdentifiers?: boolean): TransformPreset {
return [
[
transformVBindShorthand,
transformOnce,
transformIf,
transformMemo,
transformFor,
...(__COMPAT__ ? [transformFilter] : []),
...(!__BROWSER__ && prefixIdentifiers
? [trackVForSlotScopes, transformExpression]
: __BROWSER__ && __DEV__ ? [transformExpression] : []),
transformSlotOutlet,
transformElement,
trackSlotScopes,
transformText,
],
{ on: transformOn, bind: transformBind, model: transformModel },
]
}
数组顺序即进入顺序(onExit 形成洋葱模型,详见第 06 章)。源码注释直接写了 order is important:transformExpression 必须早于 transformElement,{{ msg }} 的 msg 才被改写成 _ctx.msg;transformIf/transformFor 必须早于 transformElement,<div v-if> 才被包成 IfNode。
5.1.4 registerRuntimeHelpers:符号到运行时名字的映射
transform 阶段往 ast.helpers(Set<symbol>)里塞入的其实是 runtime helper 的 Symbol(见 runtimeHelpers.ts:1-126,如 FRAGMENT = Symbol('Fragment'))。真正把它们变成字符串 _Fragment 的是这张全局映射表 helperNameMap:
export function registerRuntimeHelpers(helpers: Record<symbol, string>): void {
Object.getOwnPropertySymbols(helpers).forEach(s => {
helperNameMap[s] = helpers[s]
})
}
runtimeHelpers.ts:128-132。
设计动机:Symbol 是跨包安全的"身份证"。registerRuntimeHelpers 让 compiler-ssr / compiler-dom 各自补充本平台的 helper 名,codegen 用 helperNameMap[key] 查名字,从而"同一份 AST 可在不同平台 codegen"。它在各平台入口模块被调用(如 compiler-dom/src/index.ts);若单独 import baseCompile 而不 import 平台包,Symbol 未注册,生成的 helper 名可能是空串。
5.1.5 选项的流向:prefixIdentifiers / hoistStatic / cacheHandlers
CompilerOptions = ParserOptions & TransformOptions & CodegenOptions(options.ts:349),三类选项在管线中被分别消费:
| 选项 | 归属接口 | 消费点 | 作用 |
|---|---|---|---|
prefixIdentifiers | Parser 与 Transform 共享 | baseCompile:83-84 归一化;getBaseTransformPreset 决定是否加 transformExpression;codegen 决定用 with(this) 还是 _ctx.x | 表达式前缀化 |
hoistStatic | TransformOptions | transform.ts:337 if (options.hoistStatic) | 把静态节点提升成 _hoisted_n |
cacheHandlers | TransformOptions | vOn.ts 内读取 | 事件处理器缓存到 _cache[n] |
mode | CodegenOptions | codegen.ts:304 useWithBlock = !prefixIdentifiers && mode !== 'module' | ES module vs 函数式 |
sourceMap | CodegenOptions | codegen.ts:272 | 是否构造 SourceMapGenerator |
delimiters | ParserOptions | parser.ts:1067-1071 | 自定义插值定界符 |
prefixIdentifiers 之所以横跨 parser/transform/codegen,是因为它在 parser 阶段就要决定是否用 Babel 预解析表达式(createExp,parser.ts:971-1010),在 transform 阶段决定是否插入 transformExpression,在 codegen 阶段决定外层包裹形式。三者必须一致,否则会生成语义错误的代码。
5.1.6 管线时序图
flowchart TD
A["模板字符串"] --> B["baseParse (parser.ts:1028)"]
B --> B1["reset() 清空模块级状态"]
B1 --> B2["currentOptions = extend({}, defaultParserOptions)"]
B2 --> B3["tokenizer.mode / inXML / delimiters 配置"]
B3 --> B4["tokenizer.parse(input) 状态机跑完"]
B4 --> B5["root.loc = getLoc(0, len)"]
B5 --> B6["root.children = condenseWhitespace(...)"]
B6 --> C["RootNode"]
C --> D["getBaseTransformPreset(prefixIdentifiers)"]
D --> E["transform(ast, options) 原地改写"]
E --> E1["挂 codegenNode / helpers / hoists"]
E1 --> F["generate(ast, resolvedOptions)"]
F --> F1["createCodegenContext"]
F1 --> F2["genFunctionPreamble / genNode"]
F2 --> G["CodegenResult { code, ast, map }"]
5.2 编译发生在哪三种时机
理解"编译何时发生"是理解 Vue 包体积与性能的第一把钥匙。Vue 3 的编译能力分散在三种运行场景中,它们用的是同一套 baseCompile,但打包形态(__BROWSER__ 等编译期开关)截然不同。
5.2.1 场景一:构建期预编译(主流)
vue-loader(webpack)、@vitejs/plugin-vue(Vite/Rollup)、vue-jest、@vue/compiler-sfc 都会在构建时把 .vue 文件的 <template> 交给 compiler-dom(经由 compiler-sfc 的 compileTemplate)编译成 render 函数,再把生成的 JS 一起打进 bundle。
此时:
- 用户代码里根本不含 parser/tokenizer/transform/codegen 的代码(tree-shaken 掉),只有
runtime-core/runtime-dom。 - Source map 在构建期生成(
codegen.ts:272),所以浏览器 devtools 能把断点映射回.vue模板。 - Babel 表达式预解析(
parser.ts:990-1003)在 Node 环境执行,__BROWSER__ === false。
5.2.2 场景二:运行时即时编译(vue/dist/vue.esm-browser.js)
当你用完整版构建(vue.esm-browser.js / vue.global.js,即包含编译器的版本),并且模板写在 DOM 里(<div id="app"><div v-if="ok">...</div></div>,挂载时读取 el.innerHTML 作为模板),编译发生在浏览器运行时。此时 __BROWSER__ === true。
浏览器构建的有趣之处在于 tokenizer 的分支:
// tokenizer.ts:806
if (c === quote || (__BROWSER__ && this.fastForwardTo(quote))) { ... }
// tokenizer.ts:269-273
if (!__BROWSER__) {
this.entityDecoder = new EntityDecoder(htmlDecodeTree, (cp, consumed) => ...)
}
- 实体解码:非浏览器构建用
entities/decode(纯 JS 状态机);浏览器构建改用 DOM 原生decodeHtmlBrowser(compiler-dom/src/parserOptions.ts:12),省掉entities几十 KB 码表。decodeEntities是必填项(parser.ts:1049-1052),浏览器且非 TEST 时不提供会抛错。 fastForwardTo:浏览器构建在引号属性值里用String.indexOf快进到下一个引号(tokenizer.ts:806);非浏览器构建走逐字符状态机,因为它还要处理&entity;,不能让 indexOf 跳过实体。
这是"同一源码、两种宏变量、两种实现策略"的典型,也是 __BROWSER__ 的核心价值。
5.2.3 场景三:运行时 new Function 编译(function 模式)
CodegenOptions.mode = 'function'(options.ts:315)时生成的代码形如:
const { ... } = Vue
return function render(_ctx, _cache) { ... }
配合 new Function(code)() 在运行时得到 render 函数(低代码/在线编辑器场景,或 Vue.compile)。特点:
- 不能生成 ES module import,helper 从全局
Vue取(runtimeGlobalName,默认'Vue')。 - 想不用
with就得开prefixIdentifiers,但浏览器构建对prefixIdentifiers===true直接报X_PREFIX_ID_NOT_SUPPORTED(compile.ts:76-78,因为浏览器构建没打包@babel/parser)。所以浏览器运行时编译只能走with(this)包裹路径——这就是浏览器版生成代码里常见with (this)的原因。
5.2.4 包体积账本
| 构建产物 | 是否含编译器 | 典型大小(min+gzip,量级) | 适用 |
|---|---|---|---|
vue.runtime.esm-bundler.js | 否 | ~34 KB | 配合 SFC 预编译(默认) |
vue.esm-bundler.js | 是(但依赖外部编译器能力) | ~50 KB | 需运行时编译时 |
vue.esm-browser.js | 是(自带浏览器版编译器) | 显著更大(含 parser/tokenizer + DOM 解码) | CDN / 无构建 |
vue.global.js | 是 | 最大 | <script> 直接引入 |
核心结论:预编译之所以是默认推荐,不只是为了性能(运行时不再解析模板),更是为了把 parser/tokenizer/transform/codegen 这整块(几十 KB)从运行时包里彻底剔除。
5.2.5 三种场景对 __BROWSER__ 的分支汇总
| 能力 | __BROWSER__ = false(Node/构建期) | __BROWSER__ = true(运行期) |
|---|---|---|
Babel 表达式解析(createExp) | 支持(parser.ts:979-1008) | 跳过(!__BROWSER__ 前置条件) |
prefixIdentifiers | 支持 | 报错 X_PREFIX_ID_NOT_SUPPORTED(compile.ts:75-77) |
| 实体解码 | entities/decode | decodeHtmlBrowser DOM 方案 |
| 引号属性值快进 | 逐字符 | fastForwardTo(indexOf) |
| source map | 生成 | 不生成(codegen.ts:272 非浏览器分支) |
isTS 自动加 typescript 插件 | 支持(compile.ts:99-104) | 不支持 |
5.3 Tokenizer 状态机总览
5.3.1 为什么要自己写 tokenizer
tokenizer.ts:1-23 坦诚地说明:它改编自 htmlparser2(原作者 Chris Winberry,MIT)。选择"移植 + 魔改"而非直接依赖,原因在于 Vue 需要:
- 零运行时开销:把状态机内联进 compiler,避免额外的函数调用与对象分配(
htmlparser2的 Parser 层对 Vue 是多余的)。 - 插入 Vue 方言:
{{ }}、指令简写、动态参数必须进主状态机。 - 位置追踪的原生支持:
newlines数组 +getPos二分查找(tokenizer.ts:296-328)是 Vue 自己加的。
5.3.2 State 枚举与分组
tokenizer.ts:87-138 列出全部状态。按主题分组便于理解:
| 组 | 状态 | 含义 |
|---|---|---|
| 文本 | Text | 普通文本/内容区 |
| 插值 | InterpolationOpen / Interpolation / InterpolationClose | 匹配 {{ / 内容 / }} |
| 标签 | BeforeTagName / InTagName / InSelfClosingTag / BeforeClosingTagName / InClosingTagName / AfterClosingTagName | < 后到标签名、闭合标签 |
| 属性 | BeforeAttrName / InAttrName / InDirName / InDirArg / InDirDynamicArg / InDirModifier / AfterAttrName / BeforeAttrValue / InAttrValueDq / InAttrValueSq / InAttrValueNq | 属性与指令 |
| 声明 | BeforeDeclaration / InDeclaration | <!...> |
| 处理指令 | InProcessingInstruction | <?...> |
| 注释/CDATA | BeforeComment / CDATASequence / InSpecialComment / InCommentLike | 注释与 CDATA 共用"结束序列"机制 |
| 特殊标签 | BeforeSpecialS / BeforeSpecialT / SpecialStartSequence / InRCDATA | <script>/<style>/<title>/<textarea> |
| 其他 | InEntity / InSFCRootTagName | 实体解码 / SFC 根标签 |
State 的数值从 1 开始(Text = 1),只有 Text 显式赋 1,其余靠 TS 枚举自增。注意注释里 // After < 之类的语义提示。
5.3.3 CharCodes
tokenizer.ts:47-81 把会用到的字符码全部常量化为 Uint8Array 友好的数值。之所以用 charCodeAt 而不是字符串比较,是为了避免每次都构造临时字符串——在逐字符循环里这是数量级的差异。例如 isWhitespace(tokenizer.ts:151-159)直接比较 5 个码值:
export function isWhitespace(c: number): boolean {
return (
c === CharCodes.Space || c === CharCodes.NewLine ||
c === CharCodes.Tab || c === CharCodes.FormFeed ||
c === CharCodes.CarriageReturn
)
}
5.3.4 核心 parse 主循环
真正的状态机心脏是 parse(tokenizer.ts:944-1093):
public parse(input: string): void {
this.buffer = input
while (this.index < this.buffer.length) {
const c = this.buffer.charCodeAt(this.index)
if (c === CharCodes.NewLine && this.state !== State.InEntity) {
this.newlines.push(this.index)
}
switch (this.state) {
case State.Text: { this.stateText(c); break }
case State.InterpolationOpen: { this.stateInterpolationOpen(c); break }
// ... 共 30+ 个 case,按"命中概率"排序以提升性能
case State.InEntity: { this.stateInEntity(); break }
}
this.index++
}
this.cleanup()
this.finish()
}
三个要点:
newlines记录:每遇到\n就把当前位置 push 进newlines,供后续getPos计算行列(5.5 详述)。注意InEntity状态例外——实体解码时 index 会跳变,记录会错乱。switch的 case 顺序被刻意重排:源码注释明确写着 "States that are more likely to be hit are higher up, as a performance improvement."(tokenizer.ts:939-943)。Text、InterpolationOpen、Interpolation、InterpolationClose排在最前,因为一篇模板里文本与插值占绝大多数。this.index++在每条 state 处理之后统一执行:所以 state 函数里看到的this.index是"当前待处理字符",而peek()(tokenizer.ts:330-332)看的是index+1。
5.3.5 状态转移图(主干)
stateDiagram-v2
[*] --> Text
Text --> BeforeTagName: '<'
Text --> InterpolationOpen: delimiterOpen[0] ('{')
BeforeTagName --> InTagName: isTagStartChar
BeforeTagName --> BeforeClosingTagName: '/'
BeforeTagName --> BeforeDeclaration: '!'
BeforeTagName --> InProcessingInstruction: '?'
InTagName --> BeforeAttrName: isEndOfTagSection
BeforeAttrName --> InAttrName: 普通属性名
BeforeAttrName --> InDirName: 'v' + '-'
BeforeAttrName --> InDirArg: '.' / ':' / '@' / '#'
BeforeAttrName --> InSelfClosingTag: '/'
BeforeAttrName --> [*]: '>' (onopentagend)
InDirName --> InDirArg: ':'
InDirName --> InDirModifier: '.'
InDirArg --> InDirDynamicArg: '['
InDirArg --> InDirModifier: '.'
AfterAttrName --> BeforeAttrValue: '='
BeforeAttrValue --> InAttrValueDq: '"'
BeforeAttrValue --> InAttrValueSq: "'"
BeforeAttrValue --> InAttrValueNq: 其它非空白
BeforeClosingTagName --> InClosingTagName: isTagStartChar
InClosingTagName --> AfterClosingTagName: '>' 或空白
AfterClosingTagName --> Text: '>'
5.3.6 回调契约 Callbacks
tokenizer.ts:180-208 定义 Callbacks 接口——tokenizer 自己不构建 AST,它只做词法切分,把"哪里到哪里是什么"通过回调交给 parser。这是经典的生产者/消费者分离设计:
| 回调 | 触发时机 | 参数语义 |
|---|---|---|
ontext(start,end) | 一段文本结束 | [start,end) 切片 |
ontextentity(char,start,end) | 文本中解出一个实体 | 解码后的字符 + 原始区间 |
oninterpolation(start,end) | {{ ... }} 完整识别 | 含 {{/}} 的区间 |
onopentagname(start,end) | 标签名读完 | 标签名区间 |
onopentagend(end) | > 读到(开标签结束) | 结束位置 |
onselfclosingtag(end) | /> | 结束位置 |
onclosetag(start,end) | </tag 的标签名读完 | 标签名区间 |
onattribdata/onattribentity/onattribend | 属性值相关 | 值文本/实体/引号类型 |
onattribname/onattribnameend | 属性名相关 | 名字区间/结束位置 |
ondirname/ondirarg/ondirmodifier | 指令名/参数/修饰符 | 各段区间 |
oncomment/oncdata | 注释/CDATA 内容 | 内容区间 |
onprocessinginstruction(start,end) | <?...> | 区间 |
onend() | 输入结束 | 收尾 |
onerr(code,index) | 出错 | 错误码 + 位置 |
注意 start/end 都是半开区间 [start, end),与 String.prototype.slice 一致;而 onopentagend/onselfclosingtag/onattribnameend/onattribend 传的是单个 endIndex,因为不需要区间。
5.3.7 tokenizer 的模块级/实例级状态
export default class Tokenizer {
public state: State = State.Text // 当前状态
private buffer = '' // 待扫描缓冲
public sectionStart = 0 // 当前 section 起点
private index = 0 // 当前扫描位置
private entityStart = 0 // 实体起点
private baseState = State.Text // 实体解码时暂存的原状态
public inRCDATA = false // 是否在 RCDATA 区(script/style/title/textarea)
public inXML = false // 是否 XML 命名空间(关闭 RCDATA 处理)
public inVPre = false // v-pre:关闭插值解析
private newlines: number[] = [] // 换行位置
private readonly entityDecoder?: EntityDecoder
public mode: ParseMode = ParseMode.BASE
public currentSequence: Uint8Array = undefined!
private sequenceIndex = 0
public delimiterOpen / delimiterClose / delimiterIndex
}
tokenizer.ts:236-287。注意 stack: ElementNode[] 与 cbs: Callbacks 是构造注入(tokenizer.ts:265-274)——stack 是 parser 的 node 栈,tokenizer 需要读它来判断 inSFCRoot(tokenizer.ts:261-263:mode === SFC && stack.length === 0)。
5.3.8 reset():为什么 tokenizer 是单例
parser.ts:100 全局只 new 了一个 tokenizer 实例,baseParse 每次调用时通过 reset()(tokenizer.ts:276-288)清空状态:
public reset(): void {
this.state = State.Text
this.mode = ParseMode.BASE
this.buffer = ''
this.sectionStart = 0
this.index = 0
this.baseState = State.Text
this.inRCDATA = false
this.currentSequence = undefined!
this.newlines.length = 0
this.delimiterOpen = defaultDelimitersOpen
this.delimiterClose = defaultDelimitersClose
}
这里有个易错点:reset() 没有清 inXML 与 inVPre。inXML 由 baseParse 单独赋值(parser.ts:1063-1065),inVPre 由 parser 的 ondirname 管理(parser.ts:235)。如果有人在多线程/重入场景复用 tokenizer(实际不允许),残留状态会污染下一次解析。
这也解释了为什么 baseParse 是非可重入的:currentOptions/currentRoot/stack 全是模块级变量(parser.ts:85-98),reset()(parser.ts:1018-1026)负责把它们归零。Vue 的编译器在设计上就是单线程同步、不可并发调用的。
5.4 Tokenizer 逐段走读
本节把状态机拆成"文本/插值、标签名、属性与指令、引号值、自闭合、注释、CDATA、实体、RCDATA"八个部分,逐分支讲解。
5.4.1 文本与插值的分界 {{ }}
stateText(tokenizer.ts:334-348)只做三件事:
private stateText(c: number): void {
if (c === CharCodes.Lt) {
if (this.index > this.sectionStart) {
this.cbs.ontext(this.sectionStart, this.index)
}
this.state = State.BeforeTagName
this.sectionStart = this.index
} else if (!__BROWSER__ && c === CharCodes.Amp) {
this.startEntity()
} else if (!this.inVPre && c === this.delimiterOpen[0]) {
this.state = State.InterpolationOpen
this.delimiterIndex = 0
this.stateInterpolationOpen(c)
}
}
关键设计:遇到 < 或 {{ 时,先把之前的纯文本 flush 出去(ontext(sectionStart, index)),再把 sectionStart 挪到当前。这样"文本段"与"语法段"天然被切成不重叠的 chunk,parser 侧无需再做二次切分。
注意 inVPre 存在时,{{ 不再触发插值——这正是 v-pre 的语义(原样输出 {{ }})。但注意此时 stateText 并未对 { 做任何特殊处理,所以 {{ msg }} 会被当普通文本整段交给 ontext。
delimiterOpen 默认 new Uint8Array([123,123])(tokenizer.ts:83),即 {{。可通过 delimiters 选项覆盖(parser.ts:1067-1071)。
5.4.2 插值的三段状态
插值的识别被拆成 InterpolationOpen → Interpolation → InterpolationClose 三个状态,目的是支持多字符定界符(虽然默认 {{/}} 只有两位)。
stateInterpolationOpen(tokenizer.ts:354-373)逐字符匹配 delimiterOpen:
private stateInterpolationOpen(c: number): void {
if (c === this.delimiterOpen[this.delimiterIndex]) {
if (this.delimiterIndex === this.delimiterOpen.length - 1) {
const start = this.index + 1 - this.delimiterOpen.length
if (start > this.sectionStart) {
this.cbs.ontext(this.sectionStart, start)
}
this.state = State.Interpolation
this.sectionStart = start
} else {
this.delimiterIndex++
}
} else if (this.inRCDATA) {
this.state = State.InRCDATA
this.stateInRCDATA(c)
} else {
this.state = State.Text
this.stateText(c)
}
}
边界一:不匹配时回退。 文本 { x } 里,stateText 遇 { 进入 InterpolationOpen,第二个字符是空格、不匹配第二个 {,于是回退到 stateText 重扫当前字符——这就是源码里 "Reconsume the token" / stateText(c) 的含义:当前字符没被本状态消费,交给目标状态重新处理。
边界二:start 的计算。 this.index + 1 - this.delimiterOpen.length 是 {{ 起始下标(index 已指向第二个 {);if (start > this.sectionStart) 保证只在前面确实有文本时才 flush。
边界三:为何 sectionStart = start 而非 start + 2? 因为 oninterpolation 要拿到含定界符的区间 [start, index+1],parser 内部再剥掉 {{/}} 并 trim 空白(parser.ts:115-121):
oninterpolation(start, end) {
if (inVPre) return onText(getSlice(start, end), start, end)
let innerStart = start + tokenizer.delimiterOpen.length
let innerEnd = end - tokenizer.delimiterClose.length
while (isWhitespace(currentInput.charCodeAt(innerStart))) innerStart++
while (isWhitespace(currentInput.charCodeAt(innerEnd - 1))) innerEnd--
...
}
stateInterpolation(tokenizer.ts:375-381)只等 delimiterClose[0](});stateInterpolationClose(tokenizer.ts:383-400)匹配完整 }},成功后 oninterpolation(sectionStart, index+1),并把 sectionStart 推到定界符之后。
边界四:inRCDATA 的处理。 在 <title>/<textarea> 里,插值结束后应回到 InRCDATA 而非 Text(tokenizer.ts:387-391),否则 < 的处理会丢。
5.4.3 标签名解析
stateBeforeTagName(tokenizer.ts:559-595)是 < 之后的分派中枢:
private stateBeforeTagName(c: number): void {
if (c === CharCodes.ExclamationMark) { // '<!'
this.state = State.BeforeDeclaration
this.sectionStart = this.index + 1
} else if (c === CharCodes.Questionmark) { // '<?'
this.state = State.InProcessingInstruction
this.sectionStart = this.index + 1
} else if (isTagStartChar(c)) { // 字母
this.sectionStart = this.index
if (this.mode === ParseMode.BASE) {
this.state = State.InTagName
} else if (this.inSFCRoot) {
this.state = State.InSFCRootTagName
} else if (!this.inXML) {
if (c === 116 /* t */) this.state = State.BeforeSpecialT
else this.state = c === 115 /* s */ ? State.BeforeSpecialS : State.InTagName
} else {
this.state = State.InTagName
}
} else if (c === CharCodes.Slash) { // '</'
this.state = State.BeforeClosingTagName
} else {
this.state = State.Text // '<' 不是标签,回退当文本
this.stateText(c)
}
}
分派逻辑随 mode(BASE/HTML/SFC)而变:
| mode | <t... | <s... | 其它字母 | 说明 |
|---|---|---|---|---|
| BASE | InTagName | InTagName | InTagName | 不做特殊标签处理 |
| HTML(非 XML) | BeforeSpecialT | BeforeSpecialS | InTagName | 探测 title/textarea/script/style |
| HTML + XML 命名空间 | InTagName | InTagName | InTagName | XML 不认 RCDATA |
| SFC 根层 | — | — | InSFCRootTagName | 根层非 template 全是 RAWTEXT |
isTagStartChar(tokenizer.ts:144-149)只认 ASCII 字母——这是 HTML 规范的要求("HTML only allows ASCII alpha characters at the beginning of a tag name")。所以 <2> 会被当作文本,< 也回退成文本。
stateInTagName(tokenizer.ts:596-600)等到 isEndOfTagSection(/、> 或空白,tokenizer.ts:161-163)就 handleTagName:
private handleTagName(c: number) {
this.cbs.onopentagname(this.sectionStart, this.index)
this.sectionStart = -1
this.state = State.BeforeAttrName
this.stateBeforeAttrName(c)
}
sectionStart = -1 是"section 已结束"的哨兵值。注意 handleTagName 又把当前字符(/ 或 > 或空白)交给 stateBeforeAttrName 重新处理。
5.4.4 特殊标签判定 BeforeSpecialS/T 与 SpecialStartSequence
stateBeforeSpecialS(tokenizer.ts:887-896)检查第二个字符是否构成 script/style 的关键位:
private stateBeforeSpecialS(c: number): void {
if (c === Sequences.ScriptEnd[3]) { // 'c' in "</script"[3]
this.startSpecial(Sequences.ScriptEnd, 4)
} else if (c === Sequences.StyleEnd[3]) { // 't' in "</style"[3]
this.startSpecial(Sequences.StyleEnd, 4)
} else {
this.state = State.InTagName
this.stateInTagName(c)
}
}
巧妙点:ScriptEnd = "</script"、StyleEnd = "</style"(tokenizer.ts:228-230)。它复用结束序列来匹配开始标签——<s 后看第 3 字符是否为 "</script"[3]('c'),<st 后看 't'。所以 script/style 共用同一套序列、只是 offset 不同(源码注释:re-use the End sequences with an increased offset,tokenizer.ts:210-215)。
startSpecial(sequence, offset)(tokenizer.ts:548-551)→ enterRCDATA(sequence, offset)(tokenizer.ts:553-557)设 inRCDATA=true 并进入 SpecialStartSequence。stateSpecialStartSequence(tokenizer.ts:404-422)逐字符大小写不敏感地匹配序列((c | 0x20) 是 ASCII 小写化技巧),匹配失败则 inRCDATA=false 并回落到 InTagName。
5.4.5 属性名、属性值(引号/单引号/无引号)
stateBeforeAttrName(tokenizer.ts:648-678)是开标签内部分派:
private stateBeforeAttrName(c: number): void {
if (c === CharCodes.Gt) {
this.cbs.onopentagend(this.index)
this.state = this.inRCDATA ? State.InRCDATA : State.Text
this.sectionStart = this.index + 1
} else if (c === CharCodes.Slash) {
this.state = State.InSelfClosingTag
if ((__DEV__ || !__BROWSER__) && this.peek() !== CharCodes.Gt) {
this.cbs.onerr(ErrorCodes.UNEXPECTED_SOLIDUS_IN_TAG, this.index)
}
} else if (c === CharCodes.Lt && this.peek() === CharCodes.Slash) {
// 特例:开标签里出现 "</"
this.cbs.onopentagend(this.index)
this.state = State.BeforeTagName
this.sectionStart = this.index
} else if (!isWhitespace(c)) {
if ((__DEV__ || !__BROWSER__) && c === CharCodes.Eq) {
this.cbs.onerr(ErrorCodes.UNEXPECTED_EQUALS_SIGN_BEFORE_ATTRIBUTE_NAME, this.index)
}
this.handleAttrStart(c)
}
}
分支 < + /(tokenizer.ts:662-668)值得特别注意:HTML 标准不允许开标签里出现 </,但 Vue 特意容忍——注释说 "this is different from standard HTML parsing but makes practical sense especially for parsing intermediate input state in IDEs."。也就是说,你在 IDE 里打字打到一半 <div class="a"</div> 时不会炸掉。这个分支的设计目标就是编辑器场景。
handleAttrStart(tokenizer.ts:679-696)决定是普通属性还是指令:
private handleAttrStart(c: number) {
if (c === CharCodes.LowerV && this.peek() === CharCodes.Dash) { // 'v' + '-'
this.state = State.InDirName
this.sectionStart = this.index
} else if (c === CharCodes.Dot || c === CharCodes.Colon ||
c === CharCodes.At || c === CharCodes.Number) { // '.' ':' '@' '#'
this.cbs.ondirname(this.index, this.index + 1) // 单字符简写即整名
this.state = State.InDirArg
this.sectionStart = this.index + 1
} else {
this.state = State.InAttrName
this.sectionStart = this.index
}
}
CharCodes.Number = 0x23 = '#'(命名有历史包袱,实际是 # 的码值)。所以简写 :、@、#、. 直接作为 dirname 输出并进入 InDirArg——这正是 5.9.2 里 dirname 解析出 bind/on/slot 的来源。
stateInAttrName(tokenizer.ts:708-723)在 =///>/空白处结束;同时校验属性名不能含 "、'、<(UNEXPECTED_CHARACTER_IN_ATTRIBUTE_NAME)。
stateAfterAttrName(tokenizer.ts:779-791):= → BeforeAttrValue;//> → onattribend(QuoteType.NoValue, ...);其它非空白 → 结束当前属性并 handleAttrStart 开新属性(即 a b=c 中 a 无值)。
stateBeforeAttrValue(tokenizer.ts:792-804)三种引号分派:
| 首字符 | 状态 | sectionStart | 结束条件 |
|---|---|---|---|
" | InAttrValueDq | index+1 | 下一个 " |
' | InAttrValueSq | index+1 | 下一个 ' |
| 其它非空白 | InAttrValueNq | index | 空白或 > |
handleInAttrValue(tokenizer.ts:805-817)是双引号/单引号的共同实现:
private handleInAttrValue(c: number, quote: number) {
if (c === quote || (__BROWSER__ && this.fastForwardTo(quote))) {
this.cbs.onattribdata(this.sectionStart, this.index)
this.sectionStart = -1
this.cbs.onattribend(
quote === CharCodes.DoubleQuote ? QuoteType.Double : QuoteType.Single,
this.index + 1,
)
this.state = State.BeforeAttrName
} else if (!__BROWSER__ && c === CharCodes.Amp) {
this.startEntity()
}
}
QuoteType(tokenizer.ts:173-178):NoValue=0、Unquoted=1、Single=2、Double=3。这个枚举在 parser 侧用于决定文本值的 loc 是否要含引号(parser.ts:337-344)。
无引号值的校验(tokenizer.ts:824-845):InAttrValueNq 中若出现 "(仅 DEV/非浏览器)、'、<、=、` 都报 UNEXPECTED_CHARACTER_IN_UNQUOTED_ATTRIBUTE_VALUE。这是因为无引号值在 HTML 里语义最弱,规范明确禁止这些字符。
5.4.6 自闭合
stateInSelfClosingTag(tokenizer.ts:697-707):
private stateInSelfClosingTag(c: number): void {
if (c === CharCodes.Gt) {
this.cbs.onselfclosingtag(this.index)
this.state = State.Text
this.sectionStart = this.index + 1
this.inRCDATA = false // Reset special state, in case of self-closing special tags
} else if (!isWhitespace(c)) {
this.state = State.BeforeAttrName
this.stateBeforeAttrName(c)
}
}
/> 之后必须 inRCDATA = false,否则 <script/> 这种自闭合会错误地让后续内容进入 RCDATA 区。parser 侧 onselfclosingtag(parser.ts:181-188)先 isSelfClosing = true 再 endOpenTag,若栈顶正是同名元素则直接关闭——这就是 void 标签(5.12)与自闭合统一处理的地方。
5.4.7 注释与 CDATA
注释/CDATA 走 BeforeComment → InCommentLike(tokenizer.ts:524-546)。它们共用"start 字符 + 重复 + 其余可跳"的机制:
private stateInCommentLike(c: number): void {
if (c === this.currentSequence[this.sequenceIndex]) {
if (++this.sequenceIndex === this.currentSequence.length) {
if (this.currentSequence === Sequences.CdataEnd) {
this.cbs.oncdata(this.sectionStart, this.index - 2)
} else {
this.cbs.oncomment(this.sectionStart, this.index - 2)
}
this.sequenceIndex = 0
this.sectionStart = this.index + 1
this.state = State.Text
}
} else if (this.sequenceIndex === 0) {
if (this.fastForwardTo(this.currentSequence[0])) {
this.sequenceIndex = 1
}
} else if (c !== this.currentSequence[this.sequenceIndex - 1]) {
this.sequenceIndex = 0 // Allow long sequences, eg. --->, ]]>
}
}
CommentEnd = "-->"(tokenizer.ts:227),CdataEnd = "]]>"。由于结束序列的首字符会重复(- 和 ]),第三个分支允许 ----> 这类超长序列:只要当前字符等于前一序列字符,sequenceIndex 就不归零。
stateBeforeComment(tokenizer.ts:869-879)在第二个 - 时进入 InCommentLike,并把 sequenceIndex 设为 2——注释说 "Allow short comments (eg. <!-->)",即直接把 --> 的前两位 -- 视为已匹配,从而 <!--> 也能闭合。
ncdata 回调(parser.ts:472-478):若当前命名空间是 HTML 则报 CDATA_IN_HTML_CONTENT(CDATA 只在 XML/SVG/MathML 里合法),否则当文本处理。
5.4.8 实体解码 InEntity
默认(非浏览器)构建用 entities/decode 的 EntityDecoder(tokenizer.ts:258-274)。startEntity(tokenizer.ts:908-919)记录 baseState/entityStart,按上下文选 DecodingMode.Legacy(文本/RCDATA)或 DecodingMode.Attribute(属性值)——因为 HTML 规范里属性值对实体的处理与文本不同(属性值里 ∉ 不会被解码等历史怪癖)。
stateInEntity(tokenizer.ts:921-937)把 buffer 喂给 decoder:
private stateInEntity(): void {
if (!__BROWSER__) {
const length = this.entityDecoder!.write(this.buffer, this.index)
if (length >= 0) {
this.state = this.baseState
if (length === 0) {
this.index = this.entityStart // 解码失败,回退
}
} else {
this.index = this.buffer.length - 1 // 缓冲耗尽
}
}
}
解码成功回调 emitCodePoint(tokenizer.ts:1167-1195):按 baseState 分流 ontextentity / onattribentity,并据此重置 sectionStart/index。注意实体区间会跨多个 index,这也是 parse 主循环里 c === NewLine && state !== InEntity 才记 newline 的原因。
浏览器构建里没有 entityDecoder,startEntity 什么都不做——因为浏览器构建改用 decodeEntities(DOM/decodeHtmlBrowser)在整段文本层面解码。
5.4.9 RCDATA:<title> / <textarea> 的特殊内容模型
stateInRCDATA(tokenizer.ts:425-471)是最精妙的状态之一:
private stateInRCDATA(c: number): void {
if (this.sequenceIndex === this.currentSequence.length) {
if (c === CharCodes.Gt || isWhitespace(c)) {
const endOfText = this.index - this.currentSequence.length
if (this.sectionStart < endOfText) {
const actualIndex = this.index
this.index = endOfText // Spoof 位置
this.cbs.ontext(this.sectionStart, endOfText)
this.index = actualIndex
}
this.sectionStart = endOfText + 2 // Skip over the `</`
this.stateInClosingTagName(c)
this.inRCDATA = false
return
}
this.sequenceIndex = 0
}
if ((c | 0x20) === this.currentSequence[this.sequenceIndex]) {
this.sequenceIndex += 1
} else if (this.sequenceIndex === 0) {
if (this.currentSequence === Sequences.TitleEnd ||
(this.currentSequence === Sequences.TextareaEnd && !this.inSFCRoot)) {
if (!__BROWSER__ && c === CharCodes.Amp) this.startEntity()
else if (!this.inVPre && c === this.delimiterOpen[0]) {
this.state = State.InterpolationOpen
this.delimiterIndex = 0
this.stateInterpolationOpen(c)
}
} else if (this.fastForwardTo(CharCodes.Lt)) {
this.sequenceIndex = 1
}
} else {
this.sequenceIndex = Number(c === CharCodes.Lt) // 注意 `<</script` 的容错
}
}
要点:
InRCDATA的文本结束用"伪造位置"处理:endOfText = index - sequence.length(</title长 8),临时把this.index改成endOfText再调ontext,让上报位置与真实文本边界一致——典型的"为位置正确而妥协可读性"。<title>/<textarea>允许插值与实体,故要跳InterpolationOpen/startEntity;<script>/<style>则用fastForwardTo(Lt)直接跳到下一个<。sequenceIndex = Number(c === Lt):看到<时把序列索引置 1(认为已匹配</的<),正确处理<</script>这种"像结束其实是文本"的情况。
5.4.10 fastForwardTo:性能技巧
fastForwardTo(c)(tokenizer.ts:494-514)从当前位置向后扫描直到找到字符 c:
private fastForwardTo(c: number): boolean {
while (++this.index < this.buffer.length) {
const cc = this.buffer.charCodeAt(this.index)
if (cc === CharCodes.NewLine) this.newlines.push(this.index)
if (cc === c) return true
}
this.index = this.buffer.length - 1 // 因为 parse 循环末尾会 index++
return false
}
它在 InCommentLike、InDeclaration、InProcessingInstruction、InRCDATA、浏览器版属性值等处使用,把"逐字符 switch"降级成"线性扫描 + 找目标字符",在大段注释/CDATA 上收益明显。注意它必须自己维护 newlines,否则位置计算会错。
5.4.11 cleanup / finish / handleTrailingData
输入耗尽后,parse 依次调 cleanup()(tokenizer.ts:1098-1116)、finish()(tokenizer.ts:1118-1126)。
cleanup:若还有未 flush 的 section,按当前状态补发ontext/onattribdata。finish:若还在InEntity则entityDecoder.end(),然后handleTrailingData(),最后cbs.onend()。handleTrailingData(tokenizer.ts:1129-1165):InCommentLike补发注释/CDATA;标签内部状态(InTagName/InAttr*/InClosingTagName)则故意不回调(注释:"not calling the respective callback signals that the tag should be ignored");否则补发ontext。
"故意不回调"的契约:未闭合的标签,parser 根本不知道它存在;错误由 parser 的 onend(parser.ts:422-470)用 tokenizer.state 判断(EOF_IN_TAG 等)。这体现职责边界——tokenizer 只报"字符级"错误(onerr),"结构级"错误(未闭合标签)由 parser 报。
5.5 位置追踪:index / line / column 与 getPos
位置信息是编译器的"第二公民"——没有它,source map、错误定位、HMR 全都无从谈起。Vue 的实现思路是:tokenizer 只记录换行位置,行列由 getPos 反算。
5.5.1 Position 与 SourceLocation 数据结构
ast.ts:75-87:
// The node's range. The `start` is inclusive and `end` is exclusive.
// [start, end)
export interface SourceLocation {
start: Position
end: Position
source: string
}
export interface Position {
offset: number // from start of file
line: number
column: number
}
offset:从文件头起算的 0-based 字符偏移。line:1-based 行号。column:1-based 列号。注意getPos返回的column = index + 1或index - newlines[j],而 codegen 的addMapping会loc.column - 1转成 source-map 的 0-based(codegen.ts:264)。source:该位置的原始文本切片(getSlice),用于报错时展示与 SFC 重写。
locStub(ast.ts:601-605)是"无位置"哨兵:
export const locStub: SourceLocation = {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 1, offset: 0 },
source: '',
}
codegen 用 node.loc !== locStub 判断是否要跳过生成 mapping(codegen.ts:232)。
5.5.2 newlines 数组与 getPos 二分查找
tokenizer 每遇到 \n 就 newlines.push(index)(tokenizer.ts:948-950,fastForwardTo 里也维护)。getPos(tokenizer.ts:296-328):
public getPos(index: number): Position {
let line = 1
let column = index + 1
const length = this.newlines.length
let j = -1
if (length > 100) {
// 二分查找:找到最后一个 newlines[m] < index 的 m
let l = -1, r = length
while (l + 1 < r) {
const m = (l + r) >>> 1
this.newlines[m] < index ? (l = m) : (r = m)
}
j = l
} else {
// 线性倒序查找
for (let i = length - 1; i >= 0; i--) {
if (index > this.newlines[i]) { j = i; break }
}
}
if (j >= 0) {
line = j + 2
column = index - this.newlines[j]
}
return { column, line, offset: index }
}
算法解读:
- 默认
line = 1, column = index + 1(没有任何换行时,列号 = offset+1)。 j是"index 之前最近的换行位置"在newlines中的下标。找到后,line = j + 2(因为j为 0-based,行号 1-based 再加 1),column = index - newlines[j](相对该换行的偏移)。length > 100走二分:O(log n);否则线性。为什么是 100?因为"倒序线性找"在数组末尾命中的概率高(位置通常由后往前推进),短数组时更快;长数组(超大模板)才值得二分。这是一个经验阈值。
易错点:getPos 假设 index 是"已经处理过的位置",即 newlines 里所有小于 index 的换行都已被记录。若你拿一个未来的 index 来调 getPos,得到的行列会错。
5.5.3 getLoc / setLocEnd / cloneLoc
parser 侧(parser.ts:916-933):
function getLoc(start: number, end?: number): SourceLocation {
return {
start: tokenizer.getPos(start),
end: end == null ? end : tokenizer.getPos(end), // 允许延迟附着
source: end == null ? end : getSlice(start, end),
}
}
export function cloneLoc(loc: SourceLocation): SourceLocation {
return getLoc(loc.start.offset, loc.end.offset)
}
function setLocEnd(loc: SourceLocation, end: number) {
loc.end = tokenizer.getPos(end)
loc.source = getSlice(loc.start.offset, end)
}
end 可为 null/undefined 而暂时留空——这是延迟附着(late attachment)模式:onopentagname 先建节点只给 start,等到 onclosetag 才 setLocEnd 补 end。源码里用了 @ts-expect-error allow late attachment(parser.ts:919-921)来绕过类型检查。
cloneLoc 存在的原因是:某些 AST 节点需要独立的位置副本,避免后续 setLocEnd 修改污染共享的 loc 对象。
5.5.4 advancePositionWithClone / advancePositionWithMutation
utils.ts:234-274 提供两个把位置向前推进的函数:
export function advancePositionWithClone(
pos: Position, source: string, numberOfCharacters: number = source.length,
): Position {
return advancePositionWithMutation(
{ offset: pos.offset, line: pos.line, column: pos.column },
source, numberOfCharacters,
)
}
export function advancePositionWithMutation(
pos: Position, source: string, numberOfCharacters: number = source.length,
): Position {
let linesCount = 0
let lastNewLinePos = -1
for (let i = 0; i < numberOfCharacters; i++) {
if (source.charCodeAt(i) === 10) { linesCount++; lastNewLinePos = i }
}
pos.offset += numberOfCharacters
pos.line += linesCount
pos.column = lastNewLinePos === -1
? pos.column + numberOfCharacters
: numberOfCharacters - lastNewLinePos
return pos
}
二者语义相同,区别在是否克隆:
advancePositionWithClone:先复制{offset,line,column}再变异,不改原对象。用于需要保留旧位置的场景。advancePositionWithMutation:原地变异,源码注释直言 "without cloning (for performance reasons), since this gets called a lot in the parser"(utils.ts:250-251)。
column 的算法:若跨行,numberOfCharacters - lastNewLinePos(即最后换行后到终点还有几个字符 + 1,等价于列号);否则 column + numberOfCharacters。
注意:这两个函数在 parser 主流程里并不被频繁使用——因为 parser 走的是 tokenizer 的 getPos 反算路子。它们主要服务于别的消费者,例如 transformExpression 在改写表达式后需要平移内部位置,以及 codegen 的 context(codegen.ts:199 里 advancePositionWithMutation(context, code) 维护生成代码的当前位置)。
5.5.5 source 字段与 getSlice
SourceLocation.source 就是 currentInput.slice(start, end)(parser.ts:569-571)。它有三个用途:
- 报错信息:
createCompilerError把loc挂在 error 上,上层(如 playground)可高亮原始片段。 - SFC 重写:
compiler-sfc用模板里表达式的loc.source精确回写。 dirToAttr(parser.ts:935-962):v-pre内把指令还原成属性时,name取dir.rawName,值取dir.exp.loc的 content,并修正引号(loc.end.offset < dir.loc.end.offset时把 start--、end++,因为要包含引号)。
5.5.6 位置追踪的边界陷阱清单
| 陷阱 | 位置 | 后果 |
|---|---|---|
InEntity 状态不记 newline | tokenizer.ts:948 | 实体跨行时行号漏算 |
fastForwardTo 需自行记 newline | tokenizer.ts:497-499 | 遗漏则后续行号全错 |
InRCDATA 伪造 index | tokenizer.ts:431-436 | 文本上报位置需与真实边界对齐 |
late attachment(end 为空) | parser.ts:919-921 | 若忘记 setLocEnd 则 end 为 undefined |
column 1-based vs source-map 0-based | codegen.ts:264 | 转换时需 -1 |
cloneLoc 未用导致共享 loc 被改 | parser.ts:926-928 | 多个节点 loc 串味 |
5.6 Parser 上下文:ParserContext 字段逐个解释
Vue 的 parser 没有显式的 ParserContext 类——它用一组模块级变量(parser.ts:85-98)来充当上下文。这是一个刻意的性能/简洁取舍:parser 是单线程同步的,模块级状态天然等价于一个上下文对象,但省去了解引用开销。
let currentOptions: MergedParserOptions = defaultParserOptions
let currentRoot: RootNode | null = null
let currentInput = ''
let currentOpenTag: ElementNode | null = null
let currentProp: AttributeNode | DirectiveNode | null = null
let currentAttrValue = ''
let currentAttrStartIndex = -1
let currentAttrEndIndex = -1
let inPre = 0
let inVPre = false
let currentVPreBoundary: ElementNode | null = null
const stack: ElementNode[] = []
5.6.1 currentOptions
合并后的选项(默认值 defaultParserOptions,parser.ts:70-83 与用户选项 extend)。注意 baseParse 里逐 key 覆盖(parser.ts:1033-1041),且跳过 null:
if (options) {
let key: keyof ParserOptions
for (key in options) {
if (options[key] != null) {
currentOptions[key] = options[key]
}
}
}
!= null 同时排除 null 与 undefined,让"未指定"与"显式关闭"可区分。defaultParserOptions(parser.ts:70-83)里 comments: __DEV__:开发构建保留注释,生产构建默认丢弃。
5.6.2 currentRoot
当前的 RootNode,由 createRoot([], input) 建立(parser.ts:1073)。addNode(parser.ts:912-914)的落点是 (stack[0] || currentRoot).children——栈空时挂到根。
5.6.3 currentInput
当前正在解析的源字符串。getSlice(start, end)(parser.ts:569-571)就是 currentInput.slice(...)。它被设为模块级而非参数传递,是为了让所有回调(tokenizer 调用)不必带 input 参数。
5.6.4 currentOpenTag
正在构建的开标签元素节点。onopentagname(parser.ts:139-151)创建它并填 tag/ns/tagType=ELEMENT/空 props/空 children/loc/codegenNode: undefined:
onopentagname(start, end) {
const name = getSlice(start, end)
currentOpenTag = {
type: NodeTypes.ELEMENT,
tag: name,
ns: currentOptions.getNamespace(name, stack[0], currentOptions.ns),
tagType: ElementTypes.ELEMENT, // will be refined on tag close
props: [],
children: [],
loc: getLoc(start - 1, end), // start-1 包含 '<'
codegenNode: undefined,
}
}
关键:tagType 先假定 ELEMENT,真正的类型(COMPONENT/SLOT/TEMPLATE)在 onCloseTag 才 refine(parser.ts:640-648)。这是因为判断"是不是组件"需要看 props(如 is="..."),而 props 要到标签读完才知道。loc 的 start-1 是为了把 < 也纳入位置。
5.6.5 currentProp / currentAttrValue / currentAttrStartIndex / currentAttrEndIndex
currentProp:正在构建的属性或指令节点,由onattribname/ondirname建立,onattribend时压入currentOpenTag.props。currentAttrValue:属性值的累积字符串。因为 tokenizer 可能分段回调(文本、实体),所以用累加(parser.ts:284-288)。currentAttrStartIndex/EndIndex:记录属性值区间的起止,供onattribend构造value.loc(parser.ts:337-344)。
onattribdata(parser.ts:284-288):
onattribdata(start, end) {
currentAttrValue += getSlice(start, end)
if (currentAttrStartIndex < 0) currentAttrStartIndex = start
currentAttrEndIndex = end
}
易错点:currentAttrStartIndex 只在首次(< 0)时被设,后续分段(如实体打断)不重置——保证 start 是最初的值起点。
5.6.6 inPre
<pre> 内的空白不被压缩。计数器(不是布尔)因为 isPreTag 是 HTML 命名空间专属(parser.ts:580-582, 664-666):
if (ns === Namespaces.HTML && currentOptions.isPreTag(tag)) inPre++
// onCloseTag:
if (ns === Namespaces.HTML && currentOptions.isPreTag(tag)) inPre--
condenseWhitespace(parser.ts:837-883)读 if (!inPre) 判断是否压缩。用计数器而非布尔,是为了支持嵌套的 <pre> 与不同命名空间的边界。
5.6.7 inVPre / currentVPreBoundary
v-pre 的开关。ondirname(parser.ts:234-244)里遇到 v-pre 时:
if (name === 'pre') {
inVPre = tokenizer.inVPre = true
currentVPreBoundary = currentOpenTag
// convert dirs before this one to attributes
const props = currentOpenTag!.props
for (let i = 0; i < props.length; i++) {
if (props[i].type === NodeTypes.DIRECTIVE) {
props[i] = dirToAttr(props[i] as DirectiveNode)
}
}
}
三个动作:
inVPre = tokenizer.inVPre = true:同时置 parser 与 tokenizer 的标志——tokenizer 用它关闭插值(stateText:343),parser 用它把{{ }}当文本(oninterpolation:112-114)。currentVPreBoundary = currentOpenTag:记录v-pre作用域到哪个元素为止,onCloseTag匹配(parser.ts:667-670)才复位。- 把已有指令还原为属性:
v-pre之后出现的{{ }}/指令应原样保留;而v-pre之前已解析成DIRECTIVE的属性(如:foo="x")要经dirToAttr变回ATTRIBUTE,name用rawName(保留原始写法:foo)。
5.6.8 stack
元素栈(ElementNode[]),用 unshift/shift 从头部操作:stack[0] 是当前最内层元素。之所以用头操作,是因为 stack[0] 在 onCloseTag/endOpenTag/addNode/condenseWhitespace 处被高频读取,比 stack[stack.length-1] 直观;代价是 unshift/shift 为 O(n),但嵌套深度通常很小。
stack 同时被传给 tokenizer(parser.ts:100),用于判断 inSFCRoot(tokenizer.ts:261-263)。
5.6.9 其它隐含状态
parser 没有独立的 sections/helpers/transforms 字段——那些属于 transform 阶段 的 TransformContext(第 06 章)。parser 阶段只产生"纯语法树",helpers/directives/components 等容器字段在 createRoot(ast.ts:607-625)里被初始化为空集合,等 transform 填充。
3.5 也没有 isIgnoringNewlineNS 之类的独立字段,其效果由 isIgnoreNewlineTag(parser.ts:655-662)+ Namespaces.HTML 判断实现:HTML 命名空间下、属于忽略首换行的标签(pre/textarea)时,删掉第一个文本节点的首个换行。
5.6.10 上下文生命周期
sequenceDiagram
participant U as 调用方
participant BP as baseParse
participant P as parser 模块状态
U->>BP: baseParse(input, options)
BP->>P: reset() 清空 stack/currentOpenTag/currentProp
BP->>P: currentInput = input
BP->>P: currentOptions = extend({}, defaultParserOptions) + options
BP->>P: currentRoot = createRoot([], input)
BP->>P: tokenizer.parse(input) 期间回调写状态
BP->>P: root.loc = getLoc(0, len)
BP->>P: root.children = condenseWhitespace(...)
BP->>P: currentRoot = null
BP-->>U: RootNode
reset()(parser.ts:1018-1026)并不重置 inPre/inVPre!它只重置 currentOpenTag/currentProp/currentAttrValue/stack/tokenizer。若上一次解析因异常中途退出(未正常闭合),残留的 inPre/inVPre 会污染下一次——defaultOnError 直接 throw,一旦出错就中断,调用方捕获后重新 baseParse 时 inPre 可能非 0。这是已知的实现瑕疵。
5.7 解析主循环与 node 栈
5.7.1 没有显式主循环:回调驱动
与 tokenizer 的 while 主循环不同,parser 没有自己的循环。它是纯回调的:tokenizer.parse 跑完,回调把 AST 拼出来。parser 的"主循环"实际上就是 tokenizer 的 parse(tokenizer.ts:944-1093)。这种设计让 parser 侧代码变成"事件处理器集合",每个回调只管自己那一小段。
5.7.2 addNode:落点决策
function addNode(node: TemplateChildNode) {
;(stack[0] || currentRoot).children.push(node)
}
parser.ts:912-914。栈空 → 挂到 root;否则挂到当前最内层元素。这是唯一的"插入 AST 节点"入口(除了直接 push)。
5.7.3 endOpenTag:开标签真正入栈
function endOpenTag(end: number) {
if (tokenizer.inSFCRoot) {
currentOpenTag!.innerLoc = getLoc(end + 1, end + 1)
}
addNode(currentOpenTag!)
const { tag, ns } = currentOpenTag!
if (ns === Namespaces.HTML && currentOptions.isPreTag(tag)) inPre++
if (currentOptions.isVoidTag(tag)) {
onCloseTag(currentOpenTag!, end)
} else {
stack.unshift(currentOpenTag!)
if (ns === Namespaces.SVG || ns === Namespaces.MATH_ML) {
tokenizer.inXML = true
}
}
currentOpenTag = null
}
parser.ts:573-592。要点:
inSFCRoot时给innerLoc:SFC 根层元素需要有"内部内容"的位置(<template>的内容区),供 SFC 重写用。初始 end 与 start 相同,等onCloseTag时再补(parser.ts:625-636)。- 节点先
addNode再决定入栈:因为节点归属父节点(当前 stack[0]/root),而自己成为新的 stack[0]。 - void 标签直接
onCloseTag:<img>等不需要闭合标签。注意此时它没被 push 进 stack——因为已经关闭。这是 void 标签与普通元素最大的区别。 inXML:进入 SVG/MathML 后,tokenizer 关闭 RCDATA 处理(tokenizer.ts:576-588),因为 XML 里<script>不是 RCDATA。
5.7.4 onCloseTag:元素闭合(含类型 refine 与空白压缩)
parser.ts:616-753 是 element 生命周期的收尾,做了一长串事:
| 步骤 | 代码 | 作用 |
|---|---|---|
| 附着 end | setLocEnd(el.loc, ...) | implied 用 backTrack(end, '<'),否则 lookAhead(end, '>')+1 |
| SFC innerLoc | parser.ts:625-636 | 补 innerLoc.end = 最后一个 child 的 end |
| tagType refine | parser.ts:638-648 | slot / template(有 v-if/for/slot) / component |
| 空白压缩 | parser.ts:651-653 | 非 RCDATA 才 condenseWhitespace(children) |
| 首换行忽略 | parser.ts:655-662 | pre/textarea |
| inPre 递减 | parser.ts:664-666 | — |
| v-pre 复位 | parser.ts:667-670 | currentVPreBoundary === el 时 |
| inXML 复位 | parser.ts:671-676 | 回到 HTML 命名空间时 |
| 2.x 兼容 | parser.ts:678-752 | 仅 __COMPAT__ |
lookAhead/backTrack(parser.ts:755-765)负责在原文里找 >/<,用于精确 end:
function lookAhead(index: number, c: number) {
let i = index
while (currentInput.charCodeAt(i) !== c && i < currentInput.length - 1) i++
return i
}
function backTrack(index: number, c: number) {
let i = index
while (currentInput.charCodeAt(i) !== c && i >= 0) i--
return i
}
isImplied 的含义:当闭合是"隐式"(如 A 未闭合而 B 开始,或 void 标签)时,元素 end 要回退到 <(backTrack);当有显式 </tag> 时,end 应前进到 > 之后(lookAhead)。
5.7.5 onCloseTag 的调用来源
onclosetag 回调(parser.ts:157-179)处理 </tag>:
onclosetag(start, end) {
const name = getSlice(start, end)
if (!currentOptions.isVoidTag(name)) {
let found = false
for (let i = 0; i < stack.length; i++) {
const e = stack[i]
if (e.tag.toLowerCase() === name.toLowerCase()) {
found = true
if (i > 0) emitError(ErrorCodes.X_MISSING_END_TAG, stack[0].loc.start.offset)
for (let j = 0; j <= i; j++) {
const el = stack.shift()!
onCloseTag(el, end, j < i) // j < i 表示"隐式关闭"
}
break
}
}
if (!found) emitError(ErrorCodes.X_INVALID_END_TAG, backTrack(start, CharCodes.Lt))
}
}
关键算法:从栈底(stack[0] 是最内层,所以实际是"从最内层往外")搜索匹配标签名。找到后,把它上面的所有元素(j < i)一并隐式关闭——这正是 HTML 的"自动补全"语义:<div><span></div> 中 </div> 会先隐式关闭 span(报 X_MISSING_END_TAG),再关闭 div。
若没找到匹配(!found),报 X_INVALID_END_TAG,位置回退到 <。
易错点:标签名比较用 toLowerCase(),所以 <DIV> 与 </div> 能配对;但 Vue 的组件名是大小写敏感的(<MyComp> ≠ </mycomp>?——其实这里也 toLowerCase 了,所以理论上会配对,但 tagType 判定组件看首字母大写,见 5.8 表)。这是 HTML 大小写不敏感与 Vue 组件大小写敏感之间的张力点。
5.7.6 元素栈的完整时序(示例)
模板:<div><span>a</span></div>
sequenceDiagram
participant T as tokenizer
participant P as parser
Note over T: 触发 onopentagname("div")
P->>P: currentOpenTag = {div}
T->>P: onopentagend
P->>P: endOpenTag: addNode(div); stack.unshift(div)
Note over T: 触发 onopentagname("span")
P->>P: currentOpenTag = {span}
T->>P: onopentagend
P->>P: endOpenTag: addNode(span→div.children); stack.unshift(span)
T->>P: ontext("a")
P->>P: addNode(text→span.children)
T->>P: onclosetag("span")
P->>P: stack[0]=span 匹配 → shift → onCloseTag(span)
T->>P: onclosetag("div")
P->>P: stack[0]=div 匹配 → shift → onCloseTag(div)
T->>P: onend
P->>P: (栈空)无错误
5.7.7 onend:EOF 处理
parser.ts:422-470。两件事:
- 按
tokenizer.state报 EOF 错误:不同状态对应不同错误码(BeforeTagName→EOF_BEFORE_TAG_NAME、Interpolation*→X_MISSING_INTERPOLATION_END、InCommentLike→EOF_IN_CDATA/EOF_IN_COMMENT、各种 tag 内状态→EOF_IN_TAG)。这跟前文"tokenizer 的 handleTrailingData 故意不回调"呼应——结构错误由 parser 在这里兜底。 - 关闭所有未闭合元素:
for (index...) { onCloseTag(stack[index], end-1); emitError(X_MISSING_END_TAG, ...) }。注意用end-1(因为输入末尾无>)。
5.7.8 隐式关闭与 void 的统一
onselfclosingtag(parser.ts:181-188)与 void 标签在 endOpenTag 里都走"不入栈、直接 onCloseTag"或"入栈后立刻关闭"的路径。统一点是它们都不把自己的子元素文本当作子节点——<img> 之后的内容属于 img 的父节点。
onselfclosingtag(end) {
const name = currentOpenTag!.tag
currentOpenTag!.isSelfClosing = true
endOpenTag(end)
if (stack[0] && stack[0].tag === name) {
onCloseTag(stack.shift()!, end)
}
}
注意 endOpenTag 里对非 void 的自闭合标签会把它 push 进栈(因为 isVoidTag 不一定为真),所以 onselfclosingtag 要立刻 shift 关闭它。顺序:先 endOpenTag(push),再判断 stack[0].tag === name 则 shift。这正是自闭合 <FooBar/>(组件)能被正确关闭的原因。
5.8 AST 节点体系
5.8.1 NodeTypes 全枚举
ast.ts:29-61:
export enum NodeTypes {
ROOT, // 0 根
ELEMENT, // 1 元素
TEXT, // 2 文本
COMMENT, // 3 注释
SIMPLE_EXPRESSION, // 4 简单表达式
INTERPOLATION, // 5 插值 {{ }}
ATTRIBUTE, // 6 普通属性
DIRECTIVE, // 7 指令
// containers
COMPOUND_EXPRESSION, // 8 复合表达式
IF, // 9 v-if 容器
IF_BRANCH, // 10 单个 if/else-if/else 分支
FOR, // 11 v-for 容器
TEXT_CALL, // 12 文本/插值调用(createTextVNode)
// codegen
VNODE_CALL, // 13 createElementVNode/createBlock 调用
JS_CALL_EXPRESSION, // 14
JS_OBJECT_EXPRESSION, // 15
JS_PROPERTY, // 16
JS_ARRAY_EXPRESSION, // 17
JS_FUNCTION_EXPRESSION, // 18
JS_CONDITIONAL_EXPRESSION,// 19
JS_CACHE_EXPRESSION, // 20
// ssr codegen
JS_BLOCK_STATEMENT, // 21
JS_TEMPLATE_LITERAL, // 22
JS_IF_STATEMENT, // 23
JS_ASSIGNMENT_EXPRESSION,// 24
JS_SEQUENCE_EXPRESSION, // 25
JS_RETURN_STATEMENT, // 26
}
可以按来源分三层:
| 层 | 节点 | 产生阶段 |
|---|---|---|
| 模板语法层 | ROOT/ELEMENT/TEXT/COMMENT/SIMPLE_EXPRESSION/INTERPOLATION/ATTRIBUTE/DIRECTIVE | parser 产生 |
| 容器层 | COMPOUND_EXPRESSION/IF/IF_BRANCH/FOR/TEXT_CALL | parser 或 transform 产生 |
| 代码生成层 | VNODE_CALL 及全部 JS_* | transform 产生,codegen 消费 |
易错点:NodeTypes 是纯数字枚举,ROOT = 0。做 switch 时若不写 default 且节点类型越界,编译器不报错——所以源码里常见 if (__DEV__) { const exhaustiveCheck: never = node }(如 utils.ts:587-590)来强制穷尽。
5.8.2 ElementTypes 与 ConstantTypes
ast.ts:63-68:
export enum ElementTypes { ELEMENT, COMPONENT, SLOT, TEMPLATE }
ELEMENT:普通原生元素(<div>),codegen 成createElementVNode。COMPONENT:组件(首字母大写 / 内置组件 /is="vue:"/ 非 nativeTag),codegen 成createVNode+resolveComponent。SLOT:<slot>出口,codegen 成renderSlot。TEMPLATE:可被编译掉的<template>(带了v-if/v-for/v-slot),codegen 时被"穿透"(children 提升到父级)。
判定逻辑在 isComponent(parser.ts:782-830):
function isComponent({ tag, props }: ElementNode): boolean {
if (currentOptions.isCustomElement(tag)) return false
if (tag === 'component' || isUpperCase(tag.charCodeAt(0)) ||
isCoreComponent(tag) ||
(currentOptions.isBuiltInComponent && currentOptions.isBuiltInComponent(tag)) ||
(currentOptions.isNativeTag && !currentOptions.isNativeTag(tag))) {
return true
}
// ... 'is' 属性(is="vue:..." / 兼容模式 :is)
return false
}
isUpperCase(parser.ts:832-834)只判 ASCII 大写(c > 64 && c < 91)。isCoreComponent(utils.ts:50-65)识别 Teleport/Suspense/KeepAlive/BaseTransition。
ConstantTypes(ast.ts:215-220):
export enum ConstantTypes {
NOT_CONSTANT = 0,
CAN_SKIP_PATCH, // 可跳过 patch
CAN_CACHE, // 可缓存
CAN_STRINGIFY, // 可字符串化(最高级)
}
注释点明:"Higher levels implies lower levels. e.g. a node that can be stringified can always be hoisted and skipped for patch."(ast.ts:210-214)。createSimpleExpression 里静态表达式自动获得 CAN_STRINGIFY(ast.ts:701-714)。
5.8.3 模板语法层节点字段逐个说明
RootNode(ast.ts:104-121):
| 字段 | 类型 | 含义 |
|---|---|---|
type | NodeTypes.ROOT | 固定 0 |
source | string | 整个模板源码 |
children | TemplateChildNode[] | 根子节点 |
helpers | Set<symbol> | 需要 import 的运行时 helper(transform 填) |
components | string[] | 需 resolveComponent 的组件名 |
directives | string[] | 需 resolveDirective 的指令名 |
hoists | (JSChildNode|null)[] | 静态提升出的 _hoisted_n 值 |
imports | ImportItem[] | import 项(name/source) |
cached | (CacheExpression|null)[] | _cache[n] 槽位 |
temps | number | 临时变量计数 |
ssrHelpers | symbol[]? | SSR 专用 helper |
codegenNode | TemplateChildNode|JSChildNode|BlockStatement? | 根的函数返回体 |
transformed | boolean? | 是否已 transform |
filters | string[]? | 仅 2.x 兼容 |
ElementNode(ast.ts:123-171)是四种子类型的联合(PlainElementNode/ComponentNode/SlotOutletNode/TemplateNode)。BaseElementNode(ast.ts:126-135)公共字段:
| 字段 | 类型 | 含义 |
|---|---|---|
ns | Namespace | 命名空间(HTML=0/SVG=1/MATH_ML=2) |
tag | string | 标签名原文 |
tagType | ElementTypes | 元素类别(onCloseTag refine) |
props | (AttributeNode|DirectiveNode)[] | 属性与指令 |
children | TemplateChildNode[] | 子节点 |
isSelfClosing | boolean? | 是否 /> 自闭合 |
innerLoc | SourceLocation? | 仅 SFC 根层元素 |
codegenNode | 各子类型不同 | transform 后填充 |
四子类型只差在 codegenNode 的类型(ast.ts:137-171):PlainElementNode 为 VNodeCall | SimpleExpressionNode | CacheExpression | MemoExpression | undefined(静态提升→SimpleExpression,v-once→CacheExpression,v-memo→MemoExpression);ComponentNode 无 SimpleExpressionNode 分支(组件不能简单提升);SlotOutletNode 为 RenderSlotCall | CacheExpression;TemplateNode 恒为 undefined。
TextNode(ast.ts:173-176)只有 content: string。CommentNode(ast.ts:178-181)只有 content。
AttributeNode(ast.ts:183-188):
| 字段 | 含义 |
|---|---|
name | 属性名(如 class) |
nameLoc | 名字的位置(不含值) |
value | TextNode | undefined(class="a b" 中的 a b) |
DirectiveNode(ast.ts:190-208):
| 字段 | 含义 |
|---|---|
name | 归一化名:bind/on/model/if/for/slot…(无前缀) |
rawName | 原始名(保留 :/@/#/v- 与 arg/modifier),仅 parse 期用 |
exp | 表达式(ExpressionNode | undefined) |
arg | 参数(:foo 的 foo,v-on:click 的 click) |
modifiers | SimpleExpressionNode[](.stop/.prevent…) |
forParseResult | 仅 v-for:缓存 parseForExpression 结果 |
SimpleExpressionNode(ast.ts:222-244):
| 字段 | 含义 |
|---|---|
content | 表达式文本 |
isStatic | 是否静态(如 :foo="'bar'" 里 arg 静态、v-bind 的字符串字面量) |
constType | ConstantTypes |
ast | Babel 解析结果:null=简单标识符免解析,false=解析报错,否则 AST 节点 |
hoisted | 指向被提升的节点 |
identifiers | 作为函数参数解析时的标识符列表 |
isHandlerKey | 是否事件处理 key |
InterpolationNode(ast.ts:246-249):content: ExpressionNode(通常是 SIMPLE_EXPRESSION,也可能是 COMPOUND)。
CompoundExpressionNode(ast.ts:251-274):children: (SimpleExpressionNode | CompoundExpressionNode | InterpolationNode | TextNode | string | symbol)[]——字符串与节点混排(详见 5.10)。
5.8.4 容器层节点字段
IfNode(ast.ts:276-280):branches: IfBranchNode[],codegenNode?: IfConditionalExpression | CacheExpression(v-if + v-once 时是 Cache)。
IfBranchNode(ast.ts:282-288):condition: ExpressionNode | undefined(else 分支为 undefined)、children、userKey(用户写的 key 属性)、isTemplateIf。
ForNode(ast.ts:290-299):
| 字段 | 含义 |
|---|---|
source | 被遍历的源(list) |
valueAlias | 值别名(item) |
keyAlias | key 别名((item, key) in list) |
objectIndexAlias | 索引别名((item, key, index)) |
parseResult | ForParseResult(与 forParseResult 同构) |
children | 子节点 |
codegenNode | ForCodegenNode(FRAGMENT + renderList) |
ForParseResult(ast.ts:301-307):source/value/key/index/finalized。parser.ts:496-567 的 parseForExpression 用正则 forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+(\S[\s\S]*)/(utils.ts:605)拆分 LHS/RHS,再用 forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/(parser.ts:493)拆出 key/index,并用 stripParensRE = /^\(|\)$/g(parser.ts:494)去掉外层括号。finalized 标记"是否已 finalize"(transform 的 processFor 会二次处理)。
TextCallNode(ast.ts:309-313):content: TextNode | InterpolationNode | CompoundExpressionNode,codegenNode: CallExpression | SimpleExpressionNode。由 transformText 产生——当元素只有一个文本子节点时,把它包成 createTextVNode 调用,避免创建元素 VNode 的数组 children。
5.8.5 代码生成层节点
VNodeCall(ast.ts:318-340)是最重要的 codegen 节点:
| 字段 | 含义 |
|---|---|
tag | string | symbol | CallExpression('div' 或 _Fragment 或组件解析调用) |
props | PropsExpression | undefined |
children | 多种:子节点数组 / 单个文本 / slots / renderList / hoisted / cached |
patchFlag | PatchFlags |
dynamicProps | 动态 prop 名(字符串或表达式) |
directives | withDirectives 参数 |
needsPatch / isBlockRequired | 后续 transform 调整 block 属性的回调标志 |
isBlock | 是否 block(需要 diff 追踪) |
disableTracking | 是否禁用 block 追踪 |
isComponent | 是否组件 vnode |
createVNodeCall(ast.ts:627-665)在 context 存在时会自动注册 helper:isBlock 时注册 OPEN_BLOCK + getVNodeBlockHelper(inSSR, isComponent),否则 getVNodeHelper(inSSR, isComponent);directives 存在时注册 WITH_DIRECTIVES。
getVNodeHelper/getVNodeBlockHelper(ast.ts:873-885)在 CREATE_VNODE/CREATE_ELEMENT_VNODE 与 CREATE_BLOCK/CREATE_ELEMENT_BLOCK 之间选择:组件或 SSR 用 createVNode,原生元素用 createElementVNode。
其余 JS_* 节点(ast.ts:360-474)是"最小 JS AST 子集",字段都很直白:CallExpression{callee,arguments}、ObjectExpression{properties}、Property{key,value}、ArrayExpression{elements}、FunctionExpression{params,returns,body,newline,isSlot}、ConditionalExpression{test,consequent,alternate,newline}、CacheExpression{index,value,needPauseTracking,inVOnce,needArraySpread}。
5.8.6 工厂函数与 locStub
ast.ts:596-871 全是形如 createXxx(...) 的工厂:
loc默认locStub——很多 codegen 节点不对应源码位置。createRoot(ast.ts:607-625)初始化所有容器(helpers/components/hoists…),是 parser 与 transform 的契约。createSimpleExpression(ast.ts:701-714)在isStatic时强制constType = CAN_STRINGIFY;createInterpolation(ast.ts:716-727)把字符串content自动包成createSimpleExpression。createVNodeCall(ast.ts:627-665)是唯一的"副作用工厂":它接收context并可能注册 helper。
5.8.7 TemplateChildNode 联合与类型守卫
ast.ts:93-102:
export type TemplateChildNode =
| ElementNode | InterpolationNode | CompoundExpressionNode
| TextNode | CommentNode | IfNode | IfBranchNode | ForNode | TextCallNode
utils.ts 提供一组守卫:isText(TEXT|INTERPOLATION,utils.ts:341-345)、isVPre、isVSlot、isTemplateNode(utils.ts:355-361)、isSlotOutlet、isStaticExp(utils.ts:47-48)。这些守卫是 transform 阶段的"类型收窄"工具,但 isText 等定义在 parser 之后就能用,所以 parser/transform 共享。
5.8.8 三层 AST 与 transform 的关系
flowchart LR
subgraph 语法层["语法层(parser 产出)"]
R[ROOT] --> E[ELEMENT]
E --> A[ATTRIBUTE]
E --> D[DIRECTIVE]
E --> T[TEXT]
E --> I[INTERPOLATION]
end
subgraph 容器层["容器层(parser/transform 产出)"]
IF[IF] --> IFS[IF_BRANCH]
FOR[FOR]
CE[COMPOUND_EXPRESSION]
end
subgraph codegen层["codegen 层(transform 产出)"]
VC[VNODE_CALL] --> CE2[JS_CALL_EXPRESSION]
VC --> OE[JS_OBJECT_EXPRESSION]
VC --> AE[JS_ARRAY_EXPRESSION]
end
语法层 --> 容器层 --> codegen层
parser 产出语法层;容器层的 IF/FOR 也已由 parser 产出(v-if/v-for 在 parse 时即识别为指令,transform 再"脱糖"成容器节点——详见第 06 章 transformIf/transformFor 的 createStructuralDirectiveTransform)。codegenNode 是两层之间的"接口":transform 把语法节点包裹/替换后挂上去。
5.9 指令解析
5.9.1 v- 前缀与简写识别路径
tokenizer 层的识别(5.4.5):v- → InDirName;:/@/#/. → 直接 ondirname 单字符 + InDirArg。
parser 层的归一化在 ondirname(parser.ts:201-246):
ondirname(start, end) {
const raw = getSlice(start, end)
const name = raw === '.' || raw === ':'
? 'bind'
: raw === '@' ? 'on'
: raw === '#' ? 'slot'
: raw.slice(2) // 'v-xxx' → 'xxx'
if (!inVPre && name === '') {
emitError(ErrorCodes.X_MISSING_DIRECTIVE_NAME, start)
}
if (inVPre || name === '') {
// 退化为普通属性
currentProp = { type: NodeTypes.ATTRIBUTE, name: raw, nameLoc: getLoc(start, end), value: undefined, loc: getLoc(start) }
} else {
currentProp = {
type: NodeTypes.DIRECTIVE, name, rawName: raw,
exp: undefined, arg: undefined,
modifiers: raw === '.' ? [createSimpleExpression('prop')] : [],
loc: getLoc(start),
}
if (name === 'pre') { /* v-pre 处理,见 5.6.7 */ }
}
}
归一化映射表:
原始 raw | 归一化 name | 说明 |
|---|---|---|
v-if | if | raw.slice(2) |
v-bind / : | bind | 冒号简写 |
v-on / @ | on | at 简写 |
v-slot / # | slot | 井号简写 |
. | bind + modifier prop | 点简写 = .prop 修饰符 |
其它 v-xxx | xxx | 直接切片 |
点简写 .foo 是历史语法糖,等价于 v-bind:foo.prop(把绑定作为 DOM property 而非 attribute),故归一化为 bind 且预置 [createSimpleExpression('prop')] 修饰符(parser.ts:231)。
name === '':即"光杆 v-"(如 v-=)。不在 v-pre 内则报 X_MISSING_DIRECTIVE_NAME;在 v-pre 内或空名时退化为普通 ATTRIBUTE。
5.9.2 参数 ondirarg 与动态参数 [ ]
ondirarg(parser.ts:248-263):
ondirarg(start, end) {
if (start === end) return
const arg = getSlice(start, end)
if (inVPre && !isVPre(currentProp!)) {
;(currentProp as AttributeNode).name += arg
setLocEnd((currentProp as AttributeNode).nameLoc, end)
} else {
const isStatic = arg[0] !== `[`
;(currentProp as DirectiveNode).arg = createExp(
isStatic ? arg : arg.slice(1, -1), // 动态参数剥掉 [ ]
isStatic,
getLoc(start, end),
isStatic ? ConstantTypes.CAN_STRINGIFY : ConstantTypes.NOT_CONSTANT,
)
}
}
要点:start === end 直接返回(容忍 v-bind: 无参数);arg[0] !== '[' 判静态,动态参数剥掉方括号(:[foo] → foo),且 constType 分别取 CAN_STRINGIFY/NOT_CONSTANT;v-pre 内则把参数拼回属性名(name += arg)以原样保留。
动态参数的 tokenizer 侧在 stateInDirDynamicArg(tokenizer.ts:750-763):遇 ] 回到 InDirArg;若遇 =/分隔符仍未闭合,补报 X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END(错误信息:"dynamic directive argument cannot contain spaces",errors.ts:151-153),即 v-bind:[a b] 非法。
5.9.3 修饰符 ondirmodifier
ondirmodifier(parser.ts:265-282):
ondirmodifier(start, end) {
const mod = getSlice(start, end)
if (inVPre && !isVPre(currentProp!)) {
;(currentProp as AttributeNode).name += '.' + mod
setLocEnd((currentProp as AttributeNode).nameLoc, end)
} else if ((currentProp as DirectiveNode).name === 'slot') {
// slot 无修饰符,特例
const arg = (currentProp as DirectiveNode).arg
if (arg) {
;(arg as SimpleExpressionNode).content += '.' + mod
setLocEnd(arg.loc, end)
}
} else {
const exp = createSimpleExpression(mod, true, getLoc(start, end))
;(currentProp as DirectiveNode).modifiers.push(exp)
}
}
三个分支:v-pre 内拼回属性名;v-slot(#)特殊处理——#foo.bar 的 .bar 不是修饰符而是槽名的一部分(foo.bar,为兼容 vuejs/language-tools#2710);常规则 createSimpleExpression(mod, true, ...)(静态)压入 modifiers。
指令简写对应关系速查:
| 写法 | name | arg | modifiers |
|---|---|---|---|
v-on:click.stop.prevent | on | click | [stop, prevent] |
@keyup.enter | on | keyup | [enter] |
v-bind:foo.sync | bind | foo | [sync](兼容模式转 model) |
:list | bind | list | [] |
#[name] | slot | 动态 name | [] |
.foo | bind | foo | [prop] |
5.9.4 onattribend:值的最终化与表达式解析
onattribend(parser.ts:312-410)是属性/指令的收口,逻辑分支较多:
onattribend(quote, end) {
if (currentOpenTag && currentProp) {
setLocEnd(currentProp.loc, end)
if (quote !== QuoteType.NoValue) {
if (__BROWSER__ && currentAttrValue.includes('&')) {
currentAttrValue = currentOptions.decodeEntities!(currentAttrValue, true)
}
if (currentProp.type === NodeTypes.ATTRIBUTE) {
if (currentProp!.name === 'class') currentAttrValue = condense(currentAttrValue).trim()
if (quote === QuoteType.Unquoted && !currentAttrValue) {
emitError(ErrorCodes.MISSING_ATTRIBUTE_VALUE, end)
}
currentProp!.value = {
type: NodeTypes.TEXT, content: currentAttrValue,
loc: quote === QuoteType.Unquoted
? getLoc(currentAttrStartIndex, currentAttrEndIndex)
: getLoc(currentAttrStartIndex - 1, currentAttrEndIndex + 1),
}
if (tokenizer.inSFCRoot && currentOpenTag.tag === 'template' &&
currentProp.name === 'lang' && currentAttrValue && currentAttrValue !== 'html') {
tokenizer.enterRCDATA(toCharCodes(`</template`), 0)
}
} else {
// 指令
let expParseMode = ExpParseMode.Normal
if (!__BROWSER__) {
if (currentProp.name === 'for') expParseMode = ExpParseMode.Skip
else if (currentProp.name === 'slot') expParseMode = ExpParseMode.Params
else if (currentProp.name === 'on' && currentAttrValue.includes(';')) {
expParseMode = ExpParseMode.Statements
}
}
currentProp.exp = createExp(currentAttrValue, false,
getLoc(currentAttrStartIndex, currentAttrEndIndex),
ConstantTypes.NOT_CONSTANT, expParseMode)
if (currentProp.name === 'for') {
currentProp.forParseResult = parseForExpression(currentProp.exp)
}
// 兼容:v-bind:foo.sync → v-model:foo
}
}
if (currentProp.type !== NodeTypes.DIRECTIVE || currentProp.name !== 'pre') {
currentOpenTag.props.push(currentProp)
}
}
currentAttrValue = ''
currentAttrStartIndex = currentAttrEndIndex = -1
}
细节:
class特殊化:普通class的多个空格会被condense成单空格再 trim(parser.ts:329-331)。只对class做,是因为 HTML 里class以任意空白分隔、语义等价于单空格,而其它属性值空格可能有意义。- 引号 vs 无引号的位置:无引号值
loc用[start,end];引号值loc要-1/+1把引号包进来(parser.ts:340-343),与dirToAttr的修正同源。 v-pre不入 props:currentProp.name !== 'pre'时跳过——v-pre是"解析开关",不参与渲染。- SFC
lang触发 RCDATA:<template lang="pug">强制 tokenizer 进入 RCDATA,把非 HTML 内容当文本。
5.9.5 ExpParseMode:表达式解析模式
parser.ts:964-969:
enum ExpParseMode { Normal, Params, Statements, Skip }
| 模式 | 适用 | Babel 调用 |
|---|---|---|
Normal | 大多数表达式 | parseExpression('(' + content + ')') |
Params | v-slot 参数、v-for 别名 | parseExpression('(' + content + ')=>{}') |
Statements | v-on 含 ;(多语句) | parse(' ' + content + ' ').program |
Skip | v-for 的原始表达式 | 不解析(留给 parseForExpression) |
之所以要区分,是因为同一个模板表达式在不同位置语法角色不同:v-for="(a,b) of x" 的 (a,b) 在 Normal 模式下会被 Babel 当成"逗号序列表达式"(合法但无意义),而 Params 模式下包成箭头函数参数才是正确语义。
5.10 表达式处理
5.10.1 SIMPLE_EXPRESSION 与 isStatic
SimpleExpressionNode 是表达式的最小单元。它有两种角色:
- 指令参数/修饰符:
isStatic = true(除非是[动态]参数),content是纯文本(如click、stop)。 - 指令值/插值:
isStatic = false(因为值可能是任意 JS 表达式),content是表达式源码。
createExp(parser.ts:971-1010):
function createExp(
content: SimpleExpressionNode['content'],
isStatic: SimpleExpressionNode['isStatic'] = false,
loc: SourceLocation,
constType: ConstantTypes = ConstantTypes.NOT_CONSTANT,
parseMode = ExpParseMode.Normal,
) {
const exp = createSimpleExpression(content, isStatic, loc, constType)
if (!__BROWSER__ && !isStatic && currentOptions.prefixIdentifiers &&
parseMode !== ExpParseMode.Skip && content.trim()) {
if (isSimpleIdentifier(content)) {
exp.ast = null // fast path
return exp
}
try {
const plugins = currentOptions.expressionPlugins
const options: BabelOptions = { plugins: plugins ? [...plugins, 'typescript'] : ['typescript'] }
if (parseMode === ExpParseMode.Statements) {
exp.ast = parse(` ${content} `, options).program // 两侧 padding
} else if (parseMode === ExpParseMode.Params) {
exp.ast = parseExpression(`(${content})=>{}`, options)
} else {
exp.ast = parseExpression(`(${content})`, options) // 包括号
}
} catch (e: any) {
exp.ast = false // indicate an error
emitError(ErrorCodes.X_INVALID_EXPRESSION, loc.start.offset, e.message)
}
}
return exp
}
四个设计点:
- 快速路径
ast = null:isSimpleIdentifier(content)(utils.ts:67-69,正则nonIdentifierRE = /^$|^\d|[^\$\w\xA0-\uFFFF]/)为真时,说明是简单标识符,无需 Babel 解析,直接置ast = null。这是一个高频优化——模板里大量{{ msg }}、@click="foo"都命中。 - 包括号
(+ content +):防止{a:1}被解析成 block statement 而非对象。这是模板表达式的经典陷阱。 Params模式包=>:(a, b) => {}让a, b成为函数参数,从而 Babel 能真正把a/b识别为"声明",供transformExpression做作用域分析。Statements模式两侧加空格:v-on="a; b"多语句用parse而非parseExpression,两侧空格是为了让 Babel 的start/end位置与实际对齐(否则第一个 token 的位置会错一位)。
ast = false 的语义:解析失败。transformExpression(第 08 章)看到 ast === false 会跳过前缀化,避免二次报错。注意 appendError 时用 loc.start.offset 作为错误位置。
5.10.2 INTERPOLATION
{{ expr }} 由 tokenizer 的 oninterpolation 回调产出(parser.ts:111-137):
oninterpolation(start, end) {
if (inVPre) return onText(getSlice(start, end), start, end)
let innerStart = start + tokenizer.delimiterOpen.length
let innerEnd = end - tokenizer.delimiterClose.length
while (isWhitespace(currentInput.charCodeAt(innerStart))) innerStart++
while (isWhitespace(currentInput.charCodeAt(innerEnd - 1))) innerEnd--
let exp = getSlice(innerStart, innerEnd)
if (exp.includes('&')) {
// decode entities for backwards compat
exp = __BROWSER__ ? currentOptions.decodeEntities!(exp, false) : decodeHTML(exp)
}
addNode({
type: NodeTypes.INTERPOLATION,
content: createExp(exp, false, getLoc(innerStart, innerEnd)),
loc: getLoc(start, end),
})
}
要点:
{{ }}内的表达式要 trim 空白(两个 while 循环)。所以{{ msg }}的content是msg,但loc仍是完整的{{ ... }}区间。- 实体解码兼容:
{{ a & b }}里的&要解码成&,否则表达式语法错误。这是"向后兼容"(旧模板里确实有人这么写)。 inVPre直接当文本:v-pre内插值不解析。loc与content.loc的区别:节点自身的loc覆盖含定界符的区间;content.loc只覆盖表达式。
InterpolationNode 在 transform 阶段会被 transformExpression 处理成 _ctx.msg(content 还是 SimpleExpressionNode,只是 prefix 了),再在 codegen 里生成 _toDisplayString(_ctx.msg)。
5.10.3 COMPOUND_EXPRESSION:字符串与节点混排
CompoundExpressionNode(ast.ts:251-274)的 children 数组是混合类型:
children: (SimpleExpressionNode | CompoundExpressionNode | InterpolationNode | TextNode | string | symbol)[]
它解决一个核心问题:表达式被"拆开"后如何重新拼接。典型场景是 {{ a }}{{ b }} 相邻,或多个文本/插值连续。举个更实际的例子:
模板 <div>hello {{ name }}!</div>。transformText(transformText.ts)会把这三个 child(TEXT "hello "、INTERPOLATION {{ name }}、TEXT "!")合并成一个 COMPOUND_EXPRESSION:
children: [
"hello ",
{ type: INTERPOLATION, content: { content: "_ctx.name" } },
"!"
]
注意第一个 child 是字符串 "hello "(纯静态部分),第二个是节点(需要求值),第三个是字符串。codegen 遇到 COMPOUND_EXPRESSION 时会把 children 逐个生成并用 + 连接(utils.ts 或 codegen 的 genCompoundExpression)。
为什么用字符串混排而不是统一成 TEXT 节点? 因为字符串是 JS 字面量的直接形式,序列化时无需再判断类型;同时 symbol 可以出现在 children 里(如 transformText 插入的 helper symbol),代表"一个运行时 helper 调用",codegen 时直接映射成 _helperName。这让 children 成为一个"可执行片段序列",而非严格的语法树。
createCompoundExpression(ast.ts:729-738):
export function createCompoundExpression(
children: CompoundExpressionNode['children'],
loc: SourceLocation = locStub,
): CompoundExpressionNode {
return { type: NodeTypes.COMPOUND_EXPRESSION, loc, children }
}
易错点:CompoundExpressionNode.children 里的 string 与节点混排,导致遍历时必须先判类型(isObject(c) 才能递归)。hasScopeRef(utils.ts:577-578)就是这么处理的:return node.children.some(c => isObject(c) && hasScopeRef(c, ids))。
5.10.4 transformExpression 消费表达式 AST 的方式(预告)
第 08 章会展开,这里先点出 parser 提供的接口:
SimpleExpressionNode.ast是 Babel 节点(或null/false)。isMemberExpression(utils.ts:187-190)/isFnExpression(utils.ts:229-232)按构建选择浏览器版(正则启发式)或 Node 版(Babel AST 精确判断):
export const isMemberExpressionBrowser = (exp: ExpressionNode): boolean => { /* 手写词法状态机 */ }
export const isMemberExpressionNode: (exp, context) => boolean = __BROWSER__ ? (NOOP as any) : (exp, context) => {
try {
let ret: Node = exp.ast || parseExpression(getExpSource(exp), {
plugins: context.expressionPlugins ? [...context.expressionPlugins, 'typescript'] : ['typescript'],
})
ret = unwrapTSNode(ret) as Expression
return ret.type === 'MemberExpression' || ret.type === 'OptionalMemberExpression' ||
(ret.type === 'Identifier' && ret.name !== 'undefined')
} catch (e) { return false }
}
浏览器版 isMemberExpressionBrowser(utils.ts:91-160)是一个手写词法状态机,用 MemberExpLexState(inMemberExp/inBrackets/inParens/inString)扫描 a.b[c].d 这类路径,判断是否"根级合法"。它不能依赖 Babel,因为浏览器构建没打包 @babel/parser。这正是 5.2.2 里"浏览器构建 vs 非浏览器构建"差异在表达式层面的又一体现。
5.11 错误处理
5.11.1 onError 回调与 defaultOnError
ErrorHandlingOptions(options.ts:17-20)定义两个钩子:onWarn?、onError?。
默认值(errors.ts:12-18):
export function defaultOnError(error: CompilerError): never {
throw error // fail-fast
}
export function defaultOnWarn(msg: CompilerError): void {
__DEV__ && console.warn(`[Vue warn] ${msg.message}`)
}
注意 defaultOnError 的返回类型是 never——它要么 throw,要么(被自定义实现)永不返回。这是编译器的 fail-fast 契约:默认情况下,第一个错误直接抛出,编译立即中断。这与"收集所有错误再报告"的策略不同——Vue 选择了 fail-fast,因为编译器出错后继续跑会产生更多连锁错误(schema 已损坏)。
5.11.2 CompilerError 与 createCompilerError
export interface CompilerError extends SyntaxError {
code: number | string
loc?: SourceLocation
}
export interface CoreCompilerError extends CompilerError {
code: ErrorCodes
}
errors.ts:3-10。它继承 SyntaxError,所以 instanceof SyntaxError 成立,方便上层统一捕获。
createCompilerError(errors.ts:24-38):
export function createCompilerError<T extends number>(
code: T, loc?: SourceLocation,
messages?: { [code: number]: string },
additionalMessage?: string,
): InferCompilerError<T> {
const msg =
__DEV__ || !__BROWSER__
? (messages || errorMessages)[code] + (additionalMessage || ``)
: `https://vuejs.org/error-reference/#compiler-${code}`
const error = new SyntaxError(String(msg)) as InferCompilerError<T>
error.code = code
error.loc = loc
return error
}
生产构建的信息压缩策略:非浏览器构建或 DEV 构建给出人类可读消息;浏览器生产构建只给一个文档链接 https://vuejs.org/error-reference/#compiler-<code>。这把错误文案从运行时包里省掉(几十 KB 的错误消息表)。这又是一个 __DEV__/__BROWSER__ 开关的收益点。
messages 参数允许更高层编译器(compiler-dom/compiler-ssr)传入自己的消息表。additionalMessage 用于拼接动态信息(如 X_INVALID_EXPRESSION 会拼上 Babel 的 e.message,见 parser.ts:1006)。
5.11.3 错误码分段
ErrorCodes(errors.ts:40-110)是一个自增枚举,但被刻意分成几段:
| 段 | 范围 | 内容 |
|---|---|---|
| 解析-通用 | 0 ~ 20(ABRUPT_CLOSING_OF_EMPTY_COMMENT … UNEXPECTED_SOLIDUS_IN_TAG) | HTML 解析错误 |
| Vue 特有解析 | 21 ~ 25(X_INVALID_END_TAG … X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END) | Vue 方言解析错误 |
| transform | 26 ~ 47(X_V_IF_NO_EXPRESSION … X_KEEP_ALIVE_INVALID_CHILDREN) | 结构指令/绑定错误 |
| 通用 | 48 ~ 51(X_PREFIX_ID_NOT_SUPPORTED … X_VNODE_HOOKS) | 编译选项错误 |
| 保留 | X_V_BIND_INVALID_SAME_NAME_ARGUMENT(52) | 注释说是为当前 minor 保持顺序 |
| 扩展点 | __EXTEND_POINT__(53) | 哨兵:供更高层编译器续接 |
关键机制是 __EXTEND_POINT__:
// Special value for higher-order compilers to pick up the last code
// to avoid collision of error codes. This should always be kept as the last
// item.
__EXTEND_POINT__,
errors.ts:106-109。它的值等于"枚举项数"(即 53)。compiler-dom 的 DOMErrorCodes(packages/compiler-dom/src/errors.ts:23-36)直接从它续接:
export enum DOMErrorCodes {
X_V_HTML_NO_EXPRESSION = 54 /* ErrorCodes.__EXTEND_POINT__ */,
X_V_HTML_WITH_CHILDREN,
...
__EXTEND_POINT__,
}
而且有一个测试期断言(errors.ts:38-49)确保续接值不冲突:
if (__TEST__) {
if (DOMErrorCodes.X_V_HTML_NO_EXPRESSION < ErrorCodes.__EXTEND_POINT__) {
throw new Error(`DOMErrorCodes need to be updated to ${ErrorCodes.__EXTEND_POINT__} to match extension point from core ErrorCodes.`)
}
}
这就是"分段"的完整闭环:核心枚举改动 → __EXTEND_POINT__ 自动前移 → 测试期断言发现下游未同步 → 编译报错,强制开发者更新 DOM/SSR 的起始值。
5.11.4 错误码分段可视化
flowchart TD
A["ErrorCodes (errors.ts:40)"] --> B["0-20 HTML 解析"]
A --> C["21-25 Vue 解析"]
A --> D["26-47 transform"]
A --> E["48-51 通用/选项"]
A --> F["52 保留"]
A --> G["53 __EXTEND_POINT__ 哨兵"]
G --> H["DOMErrorCodes (compiler-dom) 54-64"]
H --> I["SSRErrorCodes (compiler-ssr) 接续"]
5.11.5 emitError:parser 内部的统一出口
parser.ts:1012-1016:
function emitError(code: ErrorCodes, index: number, message?: string) {
currentOptions.onError(
createCompilerError(code, getLoc(index, index), undefined, message),
)
}
getLoc(index, index) 生成一个"零长度"位置(start == end),只用于报位置。所有 parser 侧错误都走这里。
5.11.6 compile 出错时如何 fail-fast
回到 baseCompile(compile.ts:71-90)。前四个检查(X_PREFIX_ID_NOT_SUPPORTED、X_MODULE_MODE_NOT_SUPPORTED、X_CACHE_HANDLER_NOT_SUPPORTED、X_SCOPE_ID_NOT_SUPPORTED)是在编译入口做的选项合法性检查:
const onError = options.onError || defaultOnError
...
if (__BROWSER__) {
if (options.prefixIdentifiers === true) onError(createCompilerError(ErrorCodes.X_PREFIX_ID_NOT_SUPPORTED))
else if (isModuleMode) onError(createCompilerError(ErrorCodes.X_MODULE_MODE_NOT_SUPPORTED))
}
const prefixIdentifiers = !__BROWSER__ && (options.prefixIdentifiers === true || isModuleMode)
if (!prefixIdentifiers && options.cacheHandlers) onError(createCompilerError(ErrorCodes.X_CACHE_HANDLER_NOT_SUPPORTED))
if (options.scopeId && !isModuleMode) onError(createCompilerError(ErrorCodes.X_SCOPE_ID_NOT_SUPPORTED))
这些检查的动机:选项之间存在互斥/依赖关系,越早报越好。
prefixIdentifiers与cacheHandlers的依赖:cacheHandlers需要作用域分析来决定 handler 是否可缓存(见options.ts:262-274注释),而作用域分析又依赖前缀化,所以"没有前缀化却要缓存 handler"是矛盾配置。scopeId只在 module 模式有意义(因为需要生成的代码被 SFC 包装)。- 浏览器构建根本不支持
prefixIdentifiers(无 Babel)与 module 模式(无 import)。
fail-fast 的实际表现:用户在 playground 输入一个畸形模板,第一次 onError 被触发(默认 throw),整个 baseCompile 中断,用户看到第一个错误。若自定义 onError 只收集不抛,编译会继续跑完——但这会引发位置错乱等问题。Vue 官方 playground 用的正是"收集 + 展示"策略,代价是可能产生级联错误。
5.11.7 错误码与消息的对照(节选)
| 错误码 | 消息 | 触发点 |
|---|---|---|
UNEXPECTED_CHARACTER_IN_ATTRIBUTE_NAME | Attribute name cannot contain U+0022 ("), U+0027 ('), and U+003C (<). | tokenizer.ts:712-722 |
X_MISSING_INTERPOLATION_END | Interpolation end sign was not found. | parser.ts:431-437 |
X_INVALID_END_TAG | Invalid end tag. | parser.ts:176 |
X_MISSING_END_TAG | Element is missing end tag. | parser.ts:166, 468 |
DUPLICATE_ATTRIBUTE | Duplicate attribute. | parser.ts:303-309 |
X_INVALID_EXPRESSION | Error parsing JavaScript expression: | parser.ts:1006 |
其中 DUPLICATE_ATTRIBUTE 的检测在 onattribnameend(parser.ts:296-310):
onattribnameend(end) {
const start = currentProp!.loc.start.offset
const name = getSlice(start, end)
if (currentProp!.type === NodeTypes.DIRECTIVE) currentProp!.rawName = name
if (currentOpenTag!.props.some(
p => (p.type === NodeTypes.DIRECTIVE ? p.rawName : p.name) === name,
)) {
emitError(ErrorCodes.DUPLICATE_ATTRIBUTE, start)
}
}
注意比较时对指令用 rawName(v-bind:foo 原文),对普通属性用 name。所以 :foo 和 v-bind:foo 会被视为不同名(不报重复),而两个 class 会报重复。这是刻意的宽松策略——指令的等价性判断留给 transform。
5.12 HTML 嵌套与 void 标签
5.12.1 isVoidTag
packages/shared/src/domTagConfig.ts:38-39:
const VOID_TAGS = 'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'
经 makeMap 转成 isVoidTag(domTagConfig.ts:63-64)。为什么放在 shared?源码注释解释:compiler-dom 与 runtime-dom 都要用,放 shared 避免二者互相依赖(domTagConfig.ts:1-2)。
Void 标签的语义:不能有子节点、不需要闭合标签。parser 里由 endOpenTag(parser.ts:583-584)处理:
if (currentOptions.isVoidTag(tag)) {
onCloseTag(currentOpenTag!, end)
} else {
stack.unshift(currentOpenTag!)
...
}
isVoidTag 的默认实现是 NO(parser.ts:76)——即 base 模式不认识 void 标签。只有 compiler-dom 的 parserOptions(packages/compiler-dom/src/parserOptions.ts:8)传入了真正的 isVoidTag。所以 baseParse(base 模式)会把 <br> 当作普通开标签,期待一个 </br>——这正是"平台无关的 base 模式"的边界。
5.12.2 自闭合与 void 的统一 vs 差异
| 场景 | tokenizer 触发 | parser 处理 | 是否入栈 |
|---|---|---|---|
<br>(void) | onopentagend | endOpenTag 里 isVoidTag 为真 → onCloseTag | 否 |
<br/>(void + 自闭合) | onselfclosingtag | endOpenTag(void → 不入栈)→ stack[0]?.tag === name 为假 → 不再 shift | 否 |
<div/>(非 void 自闭合) | onselfclosingtag | endOpenTag(入栈)→ stack[0].tag === name 为真 → shift + onCloseTag | 入栈后立即出栈 |
<img> 后跟内容 | onopentagend | void 不入栈,内容归父节点 | 否 |
差异点:void 标签"根本不认识闭合标签"——</br> 会走 onclosetag,但 isVoidTag('br') 为真时直接忽略(parser.ts:159:if (!currentOptions.isVoidTag(name)))。而非 void 的自闭合标签是"先入栈再立刻出栈"。两者最终都不产生子节点,但内部路径不同。
5.12.3 isValidHTMLNesting 与嵌套规则
packages/compiler-dom/src/htmlNesting.ts 移植自 validate-html-nesting(ISC 许可)。isValidHTMLNesting(parent, child)(htmlNesting.ts:13-44)的判定顺序:
parent === 'template'→ 任意 child 合法。parent in onlyValidChildren→ 查白名单(onlyValidChildren[parent].has(child))。child in onlyValidParents→ 查"唯一合法父"白名单(onlyValidParents[child].has(parent))。parent in knownInvalidChildren→ 命中则非法。child in knownInvalidParents→ 命中则非法。- 兜底 true。
四张表(htmlNesting.ts:51-201):
onlyValidChildren(父 → 只允许哪些子):
| 父 | 允许的子 |
|---|---|
head | base,basefront,bgsound,link,meta,title,noscript,noframes,style,script,template |
optgroup | option |
select | optgroup,option,hr |
table | caption,colgroup,tbody,tfoot,thead |
tr | td,th |
colgroup | col |
tbody/thead/tfoot | tr |
script/iframe/option/textarea/style/title | emptySet(不能有子元素) |
onlyValidParents(子 → 只允许哪些父):
| 子 | 允许的父 |
|---|---|
html | emptySet(无父) |
body/head | html |
td/th | tr |
colgroup/caption/tbody/tfoot/thead | table |
col | colgroup |
tr | tbody,thead,tfoot |
dd/dt | dl,div |
figcaption | figure |
summary | details |
area | map |
knownInvalidChildren(父 → 明确非法的子)与 knownInvalidParents(子 → 明确非法的父):
| 表 | 例子 |
|---|---|
knownInvalidChildren | p 不能含 div/h1..h6/ul/ol/li/table/pre/...;svg 不能含 div/span/br/... |
knownInvalidParents | a 不能在 a 内;button 不能在 button 内;li 不能在 li 内;h1~h6 互相不能嵌套(用 headings Set);dd/dt 互相不能嵌套;form 不能在 form 内 |
注意 headings = new Set(['h1','h2','h3','h4','h5','h6'])(htmlNesting.ts:46),所以 h2 在 h1 内非法(表格里 h1..h6 都映射到 headings)。
emptySet = new Set([])(htmlNesting.ts:47)用于表达"不能有子/父"。
这个校验只在 __DEV__ 时启用(packages/compiler-dom/src/index.ts:27-31):
export const DOMNodeTransforms: NodeTransform[] = [
transformStyle,
transformTransition,
...(__DEV__ ? [validateHtmlNesting] : []),
]
即生产构建里 validateHtmlNesting 被 tree-shake 掉,htmlNesting.ts 的整张表也不进包。这是一个"开发期帮你抓错、生产期零开销"的典型设计。
5.12.4 RCDATA 与嵌套的关系
RCDATA(<title>/<textarea>)里的内容不做 HTML 嵌套校验——因为内容被当纯文本。同理 <script>/<style>(RAWTEXT)也是。这通过 tokenizer.inRCDATA 与 onCloseTag 里的 if (!tokenizer.inRCDATA) { el.children = condenseWhitespace(children) }(parser.ts:651-653)体现——RCDATA 内不做空白压缩(因为空白有意义)也不做嵌套校验。
5.12.5 空白压缩策略 condenseWhitespace
虽然不属于"嵌套",但它是 parser 对子节点数组的最后一道加工,且与元素相关,故放这里。parser.ts:837-883:
function condenseWhitespace(nodes: TemplateChildNode[]): TemplateChildNode[] {
const shouldCondense = currentOptions.whitespace !== 'preserve'
let removedWhitespace = false
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i]
if (node.type === NodeTypes.TEXT) {
if (!inPre) {
if (isAllWhitespace(node.content)) {
const prev = nodes[i - 1] && nodes[i - 1].type
const next = nodes[i + 1] && nodes[i + 1].type
// Remove if: 首/尾节点;condense 模式下位于两条 comments 之间、
// comment-elem 之间、两元素之间且含换行
if (!prev || !next ||
(shouldCondense && (
(prev === NodeTypes.COMMENT && (next === NodeTypes.COMMENT || next === NodeTypes.ELEMENT)) ||
(prev === NodeTypes.ELEMENT && (next === NodeTypes.COMMENT ||
(next === NodeTypes.ELEMENT && hasNewlineChar(node.content))))))) {
removedWhitespace = true
nodes[i] = null as any
} else {
node.content = ' '
}
} else if (shouldCondense) {
node.content = condense(node.content)
}
} else {
node.content = node.content.replace(windowsNewlineRE, '\n')
}
}
}
return removedWhitespace ? nodes.filter(Boolean) : nodes
}
规则总结:
whitespace: 'preserve':完全不压缩。inPre:不压缩,但把\r\n归一成\n(#6410:SSR 时浏览器把服务端的\r\n归一,客户端必须一致,否则 hydration mismatch)。- 纯空白文本节点:若是首/尾节点 → 删除;若在 condense 模式下位于 "comment↔comment"、"comment↔element"、"element↔element 且含换行" 之间 → 删除;否则压成单个空格。
- 非纯空白文本:condense 模式下把连续空白压成单空格(
condense,parser.ts:895-910)。
isAllWhitespace(utils.ts:607-614)与 condense(parser.ts:895-910)是配套工具。易错点:nodes.filter(Boolean) 会在压缩后重建数组(因为用了 nodes[i] = null as any 打洞)。这会改变数组身份——所以 condenseWhitespace 的返回值必须被使用(el.children = condenseWhitespace(children),parser.ts:652)。
5.13 位置信息与 source map 的关系
5.13.1 loc 是链接"模板源码"与"生成代码"的唯一纽带
source map 的本质是生成代码位置 → 原始位置的映射。Vue 的编译器在 codegen 时才生成 map,而 map 的三要素(原始行列、生成行列、源码文件名)中,"原始行列"完全来自 parser 产出的 node.loc。因此 parser 的 getPos 精度 = source map 的精度。
5.13.2 transform 消费 loc
transform 阶段不直接生成 map,但它在两处使用 loc:
- 报错定位:transform 的每个错误(如
X_V_IF_NO_EXPRESSION)都传入node.loc,最终createCompilerError(code, loc)。 - 重写表达式后平移:
transformExpression在把msg改成_ctx.msg后,需要把内部 Babel 节点的位置按_ctx.前缀长度平移,用的正是advancePositionWithClone/advancePositionWithMutation(5.5.4)。
transform 也可能合成新节点(如 IfNode、ForNode、createCompoundExpression),这些新节点的 loc 通常来自被包裹的原节点或 locStub。
5.13.3 codegen 消费 loc:addMapping
CodegenContext.push(codegen.ts:182-236)在拼接代码的同时维护 context.line/column/offset(生成端位置),并在有 node 时调用 addMapping:
push(code, newlineIndex = NewlineType.None, node) {
context.code += code
if (!__BROWSER__ && context.map) {
if (node) {
let name
if (node.type === NodeTypes.SIMPLE_EXPRESSION && !node.isStatic) {
const content = node.content.replace(/^_ctx\./, '')
if (content !== node.content && isSimpleIdentifier(content)) name = content
}
if (node.loc.source) addMapping(node.loc.start, name)
}
// ... 推进 context 位置(含 newlineIndex 快路径)
if (node && node.loc !== locStub && node.loc.source) addMapping(node.loc.end)
}
}
addMapping(codegen.ts:256-270)直接写 _mappings:
function addMapping(loc: Position, name: string | null = null) {
const { _names, _mappings } = context.map!
if (name !== null && !_names.has(name)) _names.add(name)
_mappings.add({
originalLine: loc.line,
originalColumn: loc.column - 1, // source-map column is 0 based
generatedLine: context.line,
generatedColumn: context.column - 1,
source: filename,
name,
})
}
三个要点:
column - 1:把 Vue 的 1-based 列转成 source-map 的 0-based。这是最容易被忽略的转换。name提取:只对"非静态的简单表达式"尝试提取_ctx.前缀后的标识符,作为 mapping 的name。这样 devtools 里能显示变量名,便于调试。- 绕开 source-map-js 的公开 API:注释说明直接写私有字段
_names/_mappings是为了跳过addMapping()的一堆校验——热路径优化(codegen 会调用上千次)。 - 只在
!__BROWSER__ && sourceMap时建立context.map(codegen.ts:272-278):
if (!__BROWSER__ && sourceMap) {
context.map = new SourceMapGenerator() as unknown as CodegenSourceMapGenerator
context.map.setSourceContent(filename, context.source)
context.map._sources.add(filename)
}
source-map-js 是惰性引入(只有开启 sourceMap 才实例化),且浏览器构建根本不生成 map。
5.13.4 newlineIndex 快路径
push 的第二参数 newlineIndex(codegen.ts:182, 197-230)是一个性能优化:拼接的代码片段通常不含换行(NewlineType.None)或只有一个已知位置的换行(NewlineType.Start/End),只有在不确定时(NewlineType.Unknown)才走全量 advancePositionWithMutation 扫描。__TEST__ 下还有断言校验调用方传的 newlineIndex 是否正确(codegen.ts:205-226)。
5.13.5 整条链路的 map 生成示意
flowchart LR
T["模板源码<br/>line/col"] -->|getPos| L["node.loc (Position)"]
L -->|addMapping(originalLine/Column)| M["SourceMapGenerator._mappings"]
C["codegen 生成代码<br/>context.line/column"] -->|addMapping(generatedLine/Column)| M
M -->|context.map.toString()| S["CodegenResult.map"]
因果链:getPos 的 newlines 记录若在某个状态(如 InEntity)漏记一个换行,则 node.loc.line 偏小,进而 source map 里该 node 的 originalLine 偏小,最终 devtools 里断点会落到源码的上一行。位置追踪的一个 bug = 一整套 source map 的错乱。这就是为什么 5.5.6 的陷阱清单值得反复对照源码。
5.14 实战:用 baseParse 打印完整 AST
5.14.1 准备脚本
Vue 的 @vue/compiler-core 暴露了 baseParse。可以在仓库根目录下写一个临时脚本(不改动仓库文件)运行:
// demo-ast.mjs —— 在 core-3.5.44 根目录执行:node demo-ast.mjs
import { baseParse } from './packages/compiler-core/src/index.ts' // 若有 tsx/ts-node
// 或用已构建产物:
import { baseParse } from './packages/compiler-core/dist/compiler-core.cjs.js'
const template = `<div v-if="ok" class="box">
hello {{ msg }}
<span :title="tip">world</span>
<!-- note -->
</div>`
const ast = baseParse(template)
console.log(
JSON.stringify(ast, (key, value) =>
key === 'loc' ? undefined : value, 2)
)
用 .mjs + 已构建的 compiler-core.cjs.js 最省事(packages/compiler-core/dist/ 由 pnpm build 产出)。若只在源码层验证,用 npx tsx 直接跑 .ts 亦可。
5.14.2 输出片段(裁剪)
对上面模板,baseParse 产出的 AST(去掉 loc 后)大致如下:
{
"type": 0,
"source": "<div v-if=\"ok\" class=\"box\">...",
"children": [
{ "type": 1, "tag": "div", "ns": 0, "tagType": 0,
"props": [
{ "type": 7, "name": "if", "rawName": "v-if",
"exp": { "type": 4, "content": "ok", "isStatic": false, "constType": 0 },
"arg": null, "modifiers": [] },
{ "type": 6, "name": "class", "value": { "type": 2, "content": "box" } }
],
"children": [
{ "type": 2, "content": "\n hello " },
{ "type": 5, "content": { "type": 4, "content": "msg", "isStatic": false, "constType": 0 } },
{ "type": 1, "tag": "span", "tagType": 0,
"props": [
{ "type": 7, "name": "bind", "rawName": ":title",
"exp": { "type": 4, "content": "tip", "isStatic": false, "constType": 0 },
"arg": { "type": 4, "content": "title", "isStatic": true, "constType": 3 },
"modifiers": [] }
],
"children": [{ "type": 2, "content": "world" }] }
] }
],
"helpers": [], "components": [], "directives": [],
"hoists": [], "imports": [], "cached": [], "temps": 0
}
注:
arg: null与某些字段在实际输出中可能是undefined(JSON 会省略),部分空白已按condenseWhitespace规则压缩;<!-- note -->因comments在 DEV 默认为 true 会保留为type: 3的节点。此处的目标是展示字段结构,具体值因构建/选项而有差异。
5.14.3 逐字段解读
根节点 type: 0 (ROOT):source 是整个模板;children 只有一个 div 元素(因为外层还有换行文本,被 condenseWhitespace 删掉了首尾纯空白);helpers/components/directives 均为空——证明 parser 阶段不做任何 helper 收集,这些是 transform 的产物;temps: 0。
div 元素(type: 1, tagType: 0 = ELEMENT):ns: 0(HTML);props 有两个——一个 DIRECTIVE(name: "if",rawName: "v-if",exp.content: "ok")和一个 ATTRIBUTE(name: "class",value.content: "box")。注意 v-if 在 parser 阶段只是普通指令节点,尚未变成 IfNode(那是 transform 的事)。
children:第一个 TEXT 的 content 是 "\n hello "——换行与缩进被保留(<div> 非 pre,condense 只把连续空白压成单空格)。第二个是 INTERPOLATION,其 content 是 SIMPLE_EXPRESSION(content: "msg"、isStatic: false)。
span 元素:props[0] 是 DIRECTIVE,name: "bind"(而非 ":")、rawName: ":title"、arg 是 SIMPLE_EXPRESSION(isStatic: true、constType: 3 (CAN_STRINGIFY)),exp 是 tip。完整印证了 5.9.2 的"静态参数 → CAN_STRINGIFY"规则。
constType 对照(ast.ts:215-220):0=NOT_CONSTANT、1=CAN_SKIP_PATCH、2=CAN_CACHE、3=CAN_STRINGIFY;arg 的 3 表示可字符串化,而 exp 的 0 表示非常量。
loc 被过滤掉了(JSON.stringify 的 replacer 里 key === 'loc' 返回 undefined)。若要查看位置,改成打印 ast.children[0].loc:
{
"start": { "line": 1, "column": 1, "offset": 0 },
"end": { "line": 4, "column": 6, "offset": 106 },
"source": "<div v-if=\"ok\" class=\"box\">\n hello {{ msg }}\n <span :title=\"tip\">world</span>\n</div>"
}
5.14.4 边界输入实验(畸形 HTML)
把 template 换成下列输入,观察 baseParse 的报错与产出的 AST:
| 输入 | 现象 | 相关错误码 |
|---|---|---|
<div><span></div> | span 被隐式关闭,报错 | X_MISSING_END_TAG |
<div>(EOF) | 栈内元素全部关闭并报错 | X_MISSING_END_TAG |
</div>(无开标签) | 报错 | X_INVALID_END_TAG |
{{ msg(未闭合插值) | 报错 | X_MISSING_INTERPOLATION_END |
<div class=> | 无引号空值 | MISSING_ATTRIBUTE_VALUE |
<div foo="a" foo="b"> | 重复属性 | DUPLICATE_ATTRIBUTE |
<div v-bind:[a b]="x"> | 动态参数含空格 | X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END |
<div v-="x"> | 空指令名 | X_MISSING_DIRECTIVE_NAME |
<[CDATA[x]]> | HTML 命名空间下 CDATA | CDATA_IN_HTML_CONTENT |
<div class=a=b> | 无引号值含 = | UNEXPECTED_CHARACTER_IN_UNQUOTED_ATTRIBUTE_VALUE |
<a><div class="x"</div> | 开标签内出现 </(IDE 场景) | 无错误(容忍) |
<!-- a ---> | 超长注释结束序列 | 无错误(sequenceIndex 机制) |
<title>a</title>(base 模式) | base 不认 RCDATA | 无错误,但 </title> 正常配对 |
把 onError 换成收集器(onError: (e) => errors.push(e))就能一次性看到多个错误与它们的 loc——这也是理解错误码分段(5.11.3)的实操方式。
5.15 本章要点回顾
-
管线三步:
baseParse(tokenizer + parser)→transform(原地改写)→generate(序列化)。baseCompile(compile.ts:67)负责选项归一化(prefixIdentifiers)与 transform preset 装配。 -
三种编译时机:构建期预编译(默认,运行时无编译器)、运行时即时编译(
__BROWSER__=true,浏览器版自带编译器 + DOM 解码)、function模式 +new Function。__BROWSER__在 tokenizer 层就分出两套实现(实体解码、属性值快进、Babel 预解析、source map)。 -
Tokenizer 是单例状态机:
State枚举 30+ 状态,parse主循环按"命中概率"排序 case。它不建 AST,通过Callbacks把 token 交给 parser。 -
回退 + reconsume:遇到不匹配就把当前字符交给目标状态重扫。
{{ }}、RCDATA、注释/CDATA(sequenceIndex)、实体(entityDecoder)各有专门处理。 -
位置追踪:tokenizer 只记
newlines,getPos(tokenizer.ts:296)用"短数组倒序线性 / 长数组二分"反算line/column。column1-based,转 source map 时-1。fastForwardTo必须自维护newlines,InEntity刻意不记 newline。 -
parser 无显式
ParserContext类:用模块级变量(currentOptions/currentOpenTag/currentProp/inPre/inVPre/stack)充当上下文,单线程非可重入。reset()有清理不彻底的瑕疵(不重置inPre/inVPre/inXML)。 -
node 栈:
stack[0]最内层,addNode挂到stack[0] || currentRoot。endOpenTag决定"入栈 or 直接关闭"(void),onCloseTag负责类型 refine、空白压缩与状态复位。隐式关闭用backTrack,显式用lookAhead。 -
AST 三层:语法层、容器层(
COMPOUND_EXPRESSION/IF/IF_BRANCH/FOR/TEXT_CALL)、codegen 层(VNODE_CALL+JS_*)。codegenNode是接口,NodeTypes/ElementTypes/ConstantTypes是骨架。 -
指令解析:
v-/:/@/#/.在 tokenizer 识别,ondirname归一化(:/.→bind,@→on,#→slot,.预置prop)。ondirarg处理静态/动态参数(剥[ ]),ondirmodifier处理修饰符(v-slot特例),onattribend是值的最终化点。 -
表达式处理:
createExp(parser.ts:971)="简单标识符快速路径 + 四种ExpParseMode"。ast=null免解析、ast=false解析失败。浏览器版用isMemberExpressionBrowser替代 Babel。COMPOUND_EXPRESSION.children字符串与节点混排。 -
错误处理:
CompilerError extends SyntaxError,createCompilerError在浏览器生产降级为文档链接。ErrorCodes分段 +__EXTEND_POINT__哨兵 + 测试期断言,保证DOMErrorCodes续接。默认defaultOnError是never(fail-fast)。 -
HTML 嵌套与 void:
isVoidTag在shared/domTagConfig.ts(与 runtime-dom 共享),base 模式默认NO。isValidHTMLNesting(compiler-dom/src/htmlNesting.ts)用四表判嵌套,仅__DEV__启用。condenseWhitespace是 parser 对子节点的最后加工。 -
位置与 source map:
codegen.push有node时用node.loc.start/end调addMapping,column - 1转 0-based;只对非静态简单表达式提取_ctx.后的name。source-map-js惰性引入、仅非浏览器构建。 -
实战结论:
baseParse输出朴素语法树(helpers空、v-if仍是DIRECTIVE),compile之后才是 codegen 树(IF+codegenNode)。调 bug 时依次跑 tokenizer 事件流 → parser AST →compile代码,三段定位。
本章所有代码引用均可在
packages/compiler-core/src/{compile.ts,tokenizer.ts,parser.ts,ast.ts,errors.ts,options.ts,utils.ts}、packages/shared/src/domTagConfig.ts、packages/compiler-dom/src/{parserOptions.ts,htmlNesting.ts,errors.ts,index.ts}中核对。行号对应3.5.44。下一章(第 06 章)将从transform.ts出发,讲清NodeTransform/DirectiveTransform的洋葱模型、TransformContext、以及v-if/v-for/v-slot如何把本章产出的朴素 AST 脱糖成 codegen 树。
本章完。