Vue 3 核心源码深度剖析系列(v3.5.44)-第 05 章 编译器内幕(一):编译管线全景与词法/语法分析

1 阅读52分钟

Vue 3 核心源码深度剖析系列(v3.5.44)-第 05 章 编译器内幕(一):编译管线全景与词法/语法分析

本章对应 vuejs/core 3.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)。

原因有三:

  1. 模板是 HTML 的超集,而 HTML 的解析规则本身就不规则。HTML 不是良构语言,浏览器必须容忍畸形输入(未闭合标签、< 出现在属性值里、<script> 里的 </script> 转义……)。Vue 要尽量贴近浏览器行为,于是把 htmlparser2 的 tokenizer 移植了过来(见 packages/compiler-core/src/tokenizer.ts:1-23 的 MIT 授权声明)。
  2. Vue 在此之上叠加了自己的方言:{{ }} 插值、v- 指令、:/@/# 简写、[动态参数]、修饰符。这些都必须融进同一个字符流状态机里。
  3. 位置信息是编译期最主要的调试资产: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),三类选项在管线中被分别消费:

选项归属接口消费点作用
prefixIdentifiersParser 与 Transform 共享baseCompile:83-84 归一化;getBaseTransformPreset 决定是否加 transformExpression;codegen 决定用 with(this) 还是 _ctx.x表达式前缀化
hoistStaticTransformOptionstransform.ts:337 if (options.hoistStatic)把静态节点提升成 _hoisted_n
cacheHandlersTransformOptionsvOn.ts 内读取事件处理器缓存到 _cache[n]
modeCodegenOptionscodegen.ts:304 useWithBlock = !prefixIdentifiers && mode !== 'module'ES module vs 函数式
sourceMapCodegenOptionscodegen.ts:272是否构造 SourceMapGenerator
delimitersParserOptionsparser.ts:1067-1071自定义插值定界符

prefixIdentifiers 之所以横跨 parser/transform/codegen,是因为它在 parser 阶段就要决定是否用 Babel 预解析表达式(createExp,parser.ts:971-1010),在 transform 阶段决定是否插入 transformExpression,在 codegen 阶段决定外层包裹形式。三者必须一致,否则会生成语义错误的代码。

5.1.6 管线时序图

flowchart TD
  A[&#34;模板字符串&#34;] --> B[&#34;baseParse (parser.ts:1028)&#34;]
  B --> B1[&#34;reset() 清空模块级状态&#34;]
  B1 --> B2[&#34;currentOptions = extend({}, defaultParserOptions)&#34;]
  B2 --> B3[&#34;tokenizer.mode / inXML / delimiters 配置&#34;]
  B3 --> B4[&#34;tokenizer.parse(input) 状态机跑完&#34;]
  B4 --> B5[&#34;root.loc = getLoc(0, len)&#34;]
  B5 --> B6[&#34;root.children = condenseWhitespace(...)&#34;]
  B6 --> C[&#34;RootNode&#34;]
  C --> D[&#34;getBaseTransformPreset(prefixIdentifiers)&#34;]
  D --> E[&#34;transform(ast, options) 原地改写&#34;]
  E --> E1[&#34;挂 codegenNode / helpers / hoists&#34;]
  E1 --> F[&#34;generate(ast, resolvedOptions)&#34;]
  F --> F1[&#34;createCodegenContext&#34;]
  F1 --> F2[&#34;genFunctionPreamble / genNode&#34;]
  F2 --> G[&#34;CodegenResult { code, ast, map }&#34;]

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/decodedecodeHtmlBrowser 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 需要:

  1. 零运行时开销:把状态机内联进 compiler,避免额外的函数调用与对象分配(htmlparser2 的 Parser 层对 Vue 是多余的)。
  2. 插入 Vue 方言:{{ }}、指令简写、动态参数必须进主状态机。
  3. 位置追踪的原生支持: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<?...>
注释/CDATABeforeComment / 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()
}

三个要点:

  1. newlines 记录:每遇到 \n 就把当前位置 push 进 newlines,供后续 getPos 计算行列(5.5 详述)。注意 InEntity 状态例外——实体解码时 index 会跳变,记录会错乱。
  2. 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 排在最前,因为一篇模板里文本与插值占绝大多数。
  3. 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: '&#34;'
  BeforeAttrValue --> InAttrValueSq: &#34;'&#34;
  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...其它字母说明
BASEInTagNameInTagNameInTagName不做特殊标签处理
HTML(非 XML)BeforeSpecialTBeforeSpecialSInTagName探测 title/textarea/script/style
HTML + XML 命名空间InTagNameInTagNameInTagNameXML 不认 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结束条件
"InAttrValueDqindex+1下一个 "
'InAttrValueSqindex+1下一个 '
其它非空白InAttrValueNqindex空白或 >

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 规范里属性值对实体的处理与文本不同(属性值里 &notin; 不会被解码等历史怪癖)。

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 }
}

算法解读:

  1. 默认 line = 1, column = index + 1(没有任何换行时,列号 = offset+1)。
  2. j 是"index 之前最近的换行位置"在 newlines 中的下标。找到后,line = j + 2(因为 j 为 0-based,行号 1-based 再加 1),column = index - newlines[j](相对该换行的偏移)。
  3. 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)。它有三个用途:

  1. 报错信息:createCompilerError 把 loc 挂在 error 上,上层(如 playground)可高亮原始片段。
  2. SFC 重写:compiler-sfc 用模板里表达式的 loc.source 精确回写。
  3. 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 状态不记 newlinetokenizer.ts:948实体跨行时行号漏算
fastForwardTo 需自行记 newlinetokenizer.ts:497-499遗漏则后续行号全错
InRCDATA 伪造 indextokenizer.ts:431-436文本上报位置需与真实边界对齐
late attachment(end 为空)parser.ts:919-921若忘记 setLocEnd 则 end 为 undefined
column 1-based vs source-map 0-basedcodegen.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)
    }
  }
}

三个动作:

  1. inVPre = tokenizer.inVPre = true:同时置 parser 与 tokenizer 的标志——tokenizer 用它关闭插值(stateText:343),parser 用它把 {{ }} 当文本(oninterpolation:112-114)。
  2. currentVPreBoundary = currentOpenTag:记录 v-pre 作用域到哪个元素为止,onCloseTag 匹配(parser.ts:667-670)才复位。
  3. 把已有指令还原为属性: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。要点:

  1. inSFCRoot 时给 innerLoc:SFC 根层元素需要有"内部内容"的位置(<template> 的内容区),供 SFC 重写用。初始 end 与 start 相同,等 onCloseTag 时再补(parser.ts:625-636)。
  2. 节点先 addNode 再决定入栈:因为节点归属父节点(当前 stack[0]/root),而自己成为新的 stack[0]。
  3. void 标签直接 onCloseTag:<img> 等不需要闭合标签。注意此时它没被 push 进 stack——因为已经关闭。这是 void 标签与普通元素最大的区别。
  4. inXML:进入 SVG/MathML 后,tokenizer 关闭 RCDATA 处理(tokenizer.ts:576-588),因为 XML 里 <script> 不是 RCDATA。

5.7.4 onCloseTag:元素闭合(含类型 refine 与空白压缩)

parser.ts:616-753 是 element 生命周期的收尾,做了一长串事:

步骤代码作用
附着 endsetLocEnd(el.loc, ...)implied 用 backTrack(end, '<'),否则 lookAhead(end, '>')+1
SFC innerLocparser.ts:625-636补 innerLoc.end = 最后一个 child 的 end
tagType refineparser.ts:638-648slot / template(有 v-if/for/slot) / component
空白压缩parser.ts:651-653非 RCDATA 才 condenseWhitespace(children)
首换行忽略parser.ts:655-662pre/textarea
inPre 递减parser.ts:664-666—
v-pre 复位parser.ts:667-670currentVPreBoundary === 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(&#34;div&#34;)
  P->>P: currentOpenTag = {div}
  T->>P: onopentagend
  P->>P: endOpenTag: addNode(div); stack.unshift(div)
  Note over T: 触发 onopentagname(&#34;span&#34;)
  P->>P: currentOpenTag = {span}
  T->>P: onopentagend
  P->>P: endOpenTag: addNode(span→div.children); stack.unshift(span)
  T->>P: ontext(&#34;a&#34;)
  P->>P: addNode(text→span.children)
  T->>P: onclosetag(&#34;span&#34;)
  P->>P: stack[0]=span 匹配 → shift → onCloseTag(span)
  T->>P: onclosetag(&#34;div&#34;)
  P->>P: stack[0]=div 匹配 → shift → onCloseTag(div)
  T->>P: onend
  P->>P: (栈空)无错误

5.7.7 onend:EOF 处理

parser.ts:422-470。两件事:

  1. 按 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 在这里兜底。
  2. 关闭所有未闭合元素: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/DIRECTIVEparser 产生
容器层COMPOUND_EXPRESSION/IF/IF_BRANCH/FOR/TEXT_CALLparser 或 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):

字段类型含义
typeNodeTypes.ROOT固定 0
sourcestring整个模板源码
childrenTemplateChildNode[]根子节点
helpersSet<symbol>需要 import 的运行时 helper(transform 填)
componentsstring[]需 resolveComponent 的组件名
directivesstring[]需 resolveDirective 的指令名
hoists(JSChildNode|null)[]静态提升出的 _hoisted_n 值
importsImportItem[]import 项(name/source)
cached(CacheExpression|null)[]_cache[n] 槽位
tempsnumber临时变量计数
ssrHelperssymbol[]?SSR 专用 helper
codegenNodeTemplateChildNode|JSChildNode|BlockStatement?根的函数返回体
transformedboolean?是否已 transform
filtersstring[]?仅 2.x 兼容

ElementNode(ast.ts:123-171)是四种子类型的联合(PlainElementNode/ComponentNode/SlotOutletNode/TemplateNode)。BaseElementNode(ast.ts:126-135)公共字段:

字段类型含义
nsNamespace命名空间(HTML=0/SVG=1/MATH_ML=2)
tagstring标签名原文
tagTypeElementTypes元素类别(onCloseTag refine)
props(AttributeNode|DirectiveNode)[]属性与指令
childrenTemplateChildNode[]子节点
isSelfClosingboolean?是否 /> 自闭合
innerLocSourceLocation?仅 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名字的位置(不含值)
valueTextNode | 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)
modifiersSimpleExpressionNode[](.stop/.prevent…)
forParseResult仅 v-for:缓存 parseForExpression 结果

SimpleExpressionNode(ast.ts:222-244):

字段含义
content表达式文本
isStatic是否静态(如 :foo="'bar'" 里 arg 静态、v-bind 的字符串字面量)
constTypeConstantTypes
astBabel 解析结果: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)
keyAliaskey 别名((item, key) in list)
objectIndexAlias索引别名((item, key, index))
parseResultForParseResult(与 forParseResult 同构)
children子节点
codegenNodeForCodegenNode(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 节点:

字段含义
tagstring | symbol | CallExpression('div' 或 _Fragment 或组件解析调用)
propsPropsExpression | undefined
children多种:子节点数组 / 单个文本 / slots / renderList / hoisted / cached
patchFlagPatchFlags
dynamicProps动态 prop 名(字符串或表达式)
directiveswithDirectives 参数
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 语法层[&#34;语法层(parser 产出)&#34;]
    R[ROOT] --> E[ELEMENT]
    E --> A[ATTRIBUTE]
    E --> D[DIRECTIVE]
    E --> T[TEXT]
    E --> I[INTERPOLATION]
  end
  subgraph 容器层[&#34;容器层(parser/transform 产出)&#34;]
    IF[IF] --> IFS[IF_BRANCH]
    FOR[FOR]
    CE[COMPOUND_EXPRESSION]
  end
  subgraph codegen层[&#34;codegen 层(transform 产出)&#34;]
    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-ififraw.slice(2)
v-bind / :bind冒号简写
v-on / @onat 简写
v-slot / #slot井号简写
.bind + modifier prop点简写 = .prop 修饰符
其它 v-xxxxxx直接切片

点简写 .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。

指令简写对应关系速查:

写法nameargmodifiers
v-on:click.stop.preventonclick[stop, prevent]
@keyup.enteronkeyup[enter]
v-bind:foo.syncbindfoo[sync](兼容模式转 model)
:listbindlist[]
#[name]slot动态 name[]
.foobindfoo[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 + ')')
Paramsv-slot 参数、v-for 别名parseExpression('(' + content + ')=>{}')
Statementsv-on 含 ;(多语句)parse(' ' + content + ' ').program
Skipv-for 的原始表达式不解析(留给 parseForExpression)

之所以要区分,是因为同一个模板表达式在不同位置语法角色不同:v-for="(a,b) of x" 的 (a,b) 在 Normal 模式下会被 Babel 当成"逗号序列表达式"(合法但无意义),而 Params 模式下包成箭头函数参数才是正确语义。


5.10 表达式处理

5.10.1 SIMPLE_EXPRESSION 与 isStatic

SimpleExpressionNode 是表达式的最小单元。它有两种角色:

  1. 指令参数/修饰符:isStatic = true(除非是 [动态] 参数),content 是纯文本(如 click、stop)。
  2. 指令值/插值: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
}

四个设计点:

  1. 快速路径 ast = null:isSimpleIdentifier(content)(utils.ts:67-69,正则 nonIdentifierRE = /^$|^\d|[^\$\w\xA0-\uFFFF]/)为真时,说明是简单标识符,无需 Babel 解析,直接置 ast = null。这是一个高频优化——模板里大量 {{ msg }}、@click="foo" 都命中。
  2. 包括号 ( + content + ):防止 {a:1} 被解析成 block statement 而非对象。这是模板表达式的经典陷阱。
  3. Params 模式包 =>:(a, b) => {} 让 a, b 成为函数参数,从而 Babel 能真正把 a/b 识别为"声明",供 transformExpression 做作用域分析。
  4. 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 &amp; b }} 里的 &amp; 要解码成 &,否则表达式语法错误。这是"向后兼容"(旧模板里确实有人这么写)。
  • 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 方言解析错误
transform26 ~ 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[&#34;ErrorCodes (errors.ts:40)&#34;] --> B[&#34;0-20 HTML 解析&#34;]
  A --> C[&#34;21-25 Vue 解析&#34;]
  A --> D[&#34;26-47 transform&#34;]
  A --> E[&#34;48-51 通用/选项&#34;]
  A --> F[&#34;52 保留&#34;]
  A --> G[&#34;53 __EXTEND_POINT__ 哨兵&#34;]
  G --> H[&#34;DOMErrorCodes (compiler-dom) 54-64&#34;]
  H --> I[&#34;SSRErrorCodes (compiler-ssr) 接续&#34;]

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_NAMEAttribute name cannot contain U+0022 ("), U+0027 ('), and U+003C (<).tokenizer.ts:712-722
X_MISSING_INTERPOLATION_ENDInterpolation end sign was not found.parser.ts:431-437
X_INVALID_END_TAGInvalid end tag.parser.ts:176
X_MISSING_END_TAGElement is missing end tag.parser.ts:166, 468
DUPLICATE_ATTRIBUTEDuplicate attribute.parser.ts:303-309
X_INVALID_EXPRESSIONError 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)onopentagendendOpenTag 里 isVoidTag 为真 → onCloseTag否
<br/>(void + 自闭合)onselfclosingtagendOpenTag(void → 不入栈)→ stack[0]?.tag === name 为假 → 不再 shift否
<div/>(非 void 自闭合)onselfclosingtagendOpenTag(入栈)→ stack[0].tag === name 为真 → shift + onCloseTag入栈后立即出栈
<img> 后跟内容onopentagendvoid 不入栈,内容归父节点否

差异点: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)的判定顺序:

  1. parent === 'template' → 任意 child 合法。
  2. parent in onlyValidChildren → 查白名单(onlyValidChildren[parent].has(child))。
  3. child in onlyValidParents → 查"唯一合法父"白名单(onlyValidParents[child].has(parent))。
  4. parent in knownInvalidChildren → 命中则非法。
  5. child in knownInvalidParents → 命中则非法。
  6. 兜底 true。

四张表(htmlNesting.ts:51-201):

onlyValidChildren(父 → 只允许哪些子):

父允许的子
headbase,basefront,bgsound,link,meta,title,noscript,noframes,style,script,template
optgroupoption
selectoptgroup,option,hr
tablecaption,colgroup,tbody,tfoot,thead
trtd,th
colgroupcol
tbody/thead/tfoottr
script/iframe/option/textarea/style/titleemptySet(不能有子元素)

onlyValidParents(子 → 只允许哪些父):

子允许的父
htmlemptySet(无父)
body/headhtml
td/thtr
colgroup/caption/tbody/tfoot/theadtable
colcolgroup
trtbody,thead,tfoot
dd/dtdl,div
figcaptionfigure
summarydetails
areamap

knownInvalidChildren(父 → 明确非法的子)与 knownInvalidParents(子 → 明确非法的父):

表例子
knownInvalidChildrenp 不能含 div/h1..h6/ul/ol/li/table/pre/...;svg 不能含 div/span/br/...
knownInvalidParentsa 不能在 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:

  1. 报错定位:transform 的每个错误(如 X_V_IF_NO_EXPRESSION)都传入 node.loc,最终 createCompilerError(code, loc)。
  2. 重写表达式后平移: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,
  })
}

三个要点:

  1. column - 1:把 Vue 的 1-based 列转成 source-map 的 0-based。这是最容易被忽略的转换。
  2. name 提取:只对"非静态的简单表达式"尝试提取 _ctx. 前缀后的标识符,作为 mapping 的 name。这样 devtools 里能显示变量名,便于调试。
  3. 绕开 source-map-js 的公开 API:注释说明直接写私有字段 _names/_mappings 是为了跳过 addMapping() 的一堆校验——热路径优化(codegen 会调用上千次)。
  4. 只在 !__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[&#34;模板源码<br/>line/col&#34;] -->|getPos| L[&#34;node.loc (Position)&#34;]
  L -->|addMapping(originalLine/Column)| M[&#34;SourceMapGenerator._mappings&#34;]
  C[&#34;codegen 生成代码<br/>context.line/column&#34;] -->|addMapping(generatedLine/Column)| M
  M -->|context.map.toString()| S[&#34;CodegenResult.map&#34;]

因果链: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 命名空间下 CDATACDATA_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 本章要点回顾

  1. 管线三步:baseParse(tokenizer + parser)→ transform(原地改写)→ generate(序列化)。baseCompile(compile.ts:67)负责选项归一化(prefixIdentifiers)与 transform preset 装配。

  2. 三种编译时机:构建期预编译(默认,运行时无编译器)、运行时即时编译(__BROWSER__=true,浏览器版自带编译器 + DOM 解码)、function 模式 + new Function。__BROWSER__ 在 tokenizer 层就分出两套实现(实体解码、属性值快进、Babel 预解析、source map)。

  3. Tokenizer 是单例状态机:State 枚举 30+ 状态,parse 主循环按"命中概率"排序 case。它不建 AST,通过 Callbacks 把 token 交给 parser。

  4. 回退 + reconsume:遇到不匹配就把当前字符交给目标状态重扫。{{ }}、RCDATA、注释/CDATA(sequenceIndex)、实体(entityDecoder)各有专门处理。

  5. 位置追踪:tokenizer 只记 newlines,getPos(tokenizer.ts:296)用"短数组倒序线性 / 长数组二分"反算 line/column。column 1-based,转 source map 时 -1。fastForwardTo 必须自维护 newlines,InEntity 刻意不记 newline。

  6. parser 无显式 ParserContext 类:用模块级变量(currentOptions/currentOpenTag/currentProp/inPre/inVPre/stack)充当上下文,单线程非可重入。reset() 有清理不彻底的瑕疵(不重置 inPre/inVPre/inXML)。

  7. node 栈:stack[0] 最内层,addNode 挂到 stack[0] || currentRoot。endOpenTag 决定"入栈 or 直接关闭"(void),onCloseTag 负责类型 refine、空白压缩与状态复位。隐式关闭用 backTrack,显式用 lookAhead。

  8. AST 三层:语法层、容器层(COMPOUND_EXPRESSION/IF/IF_BRANCH/FOR/TEXT_CALL)、codegen 层(VNODE_CALL + JS_*)。codegenNode 是接口,NodeTypes/ElementTypes/ConstantTypes 是骨架。

  9. 指令解析:v-/:/@/#/. 在 tokenizer 识别,ondirname 归一化(:/.→bind,@→on,#→slot,. 预置 prop)。ondirarg 处理静态/动态参数(剥 [ ]),ondirmodifier 处理修饰符(v-slot 特例),onattribend 是值的最终化点。

  10. 表达式处理:createExp(parser.ts:971)="简单标识符快速路径 + 四种 ExpParseMode"。ast=null 免解析、ast=false 解析失败。浏览器版用 isMemberExpressionBrowser 替代 Babel。COMPOUND_EXPRESSION.children 字符串与节点混排。

  11. 错误处理:CompilerError extends SyntaxError,createCompilerError 在浏览器生产降级为文档链接。ErrorCodes 分段 + __EXTEND_POINT__ 哨兵 + 测试期断言,保证 DOMErrorCodes 续接。默认 defaultOnError 是 never(fail-fast)。

  12. HTML 嵌套与 void:isVoidTag 在 shared/domTagConfig.ts(与 runtime-dom 共享),base 模式默认 NO。isValidHTMLNesting(compiler-dom/src/htmlNesting.ts)用四表判嵌套,仅 __DEV__ 启用。condenseWhitespace 是 parser 对子节点的最后加工。

  13. 位置与 source map:codegen.push 有 node 时用 node.loc.start/end 调 addMapping,column - 1 转 0-based;只对非静态简单表达式提取 _ctx. 后的 name。source-map-js 惰性引入、仅非浏览器构建。

  14. 实战结论: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 树。


本章完。