Vue 3 核心源码深度剖析系列(v3.5.44)-第 08 章 编译器内幕(四):`compiler-dom` 的 DOM 特化层

2 阅读27分钟

Vue 3 核心源码深度剖析系列(v3.5.44)-第 08 章 编译器内幕(四):compiler-dom 的 DOM 特化层

本章对应 vuejs/core 3.5.44,主讲 packages/compiler-dom/src/ 全部源码:index.ts、parserOptions.ts、htmlNesting.ts、decodeHtmlBrowser.ts、runtimeHelpers.ts、errors.ts,以及 transforms/ 下的 vOn.ts、vModel.ts、vHtml.ts、vText.ts、vShow.ts、stringifyStatic.ts、transformStyle.ts、Transition.ts、ignoreSideEffectTags.ts、validateHtmlNesting.ts;并向下串联 packages/compiler-core/src/transforms/transformExpression.ts、packages/compiler-core/src/babelUtils.ts、packages/compiler-core/src/validateExpression.ts、packages/compiler-core/src/options.ts、packages/compiler-core/src/parser.ts;再向下串联 packages/runtime-dom/src/directives/vModel.ts、packages/runtime-dom/src/directives/vOn.ts 以解释"编译器生成的 helper 到了运行时干什么"。

第 05 章讲了 compiler-core 的 parser(体素化 tokenizer + AST 构造),第 06 章讲了 transform(节点变换、指令变换、静态提升的判定),第 07 章讲了 codegen(AST → render 源码 + patchFlags + Block 树)。这三章一直有一个"悬空"的设定:它们描述的是一个"平台无关"的编译器。compiler-core 里没有 <div>,没有 v-model 对 input 的特殊处理,没有 onclick 大小写,没有 <pre> 里的空白规则——这些在 compiler-core 里全都是可注入的回调与可覆盖的表。

这一章回答的问题是:把 compiler-core 这个"空壳编译器"变成"能编译网页模板的编译器",究竟需要补上哪些东西? 而这些补上的东西,只有 1000 多行代码,却承担了 Vue 3 相比 Vue 2 在 DOM 平台上的全部编译期优势:stringifyStatic 的字符串化静态节点、v-model 的五种运行时指令分流、v-on 修饰符的三分类编译。

读完这一章,你会理解一句容易被忽略的判断:compiler-dom 不是"compiler-core 的封装",它是"把平台知识以最小侵入方式注入核心编译器"的适配层范本。 理解了它,你不仅能读懂 compiler-ssr、compiler-sfc,还能理解为什么 Vue 3 的编译器可以同时服务浏览器、SSR、Vapor 三套运行时。


导语:一个 1000 行的适配层,如何决定整个平台的编译行为

先看一个反直觉的事实。在 vuejs/core 仓库里,packages/compiler-core/src 有大约 30 个源文件、上万行代码;而 packages/compiler-dom/src 只有 14 个源文件、约 1400 行(含注释)。也就是说,整个"网页编译器"的 DOM 特化部分,只占核心编译器的十分之一不到。

这个比例本身就是一种设计宣言。它说明 Vue 团队在 2020 年重构编译器时的核心决策是:把"什么是 HTML"和"如何编译任意模板"彻底解耦。compiler-core 负责所有模板语言共通的部分(插值、指令、v-if/v-for、表达式改写、代码生成、静态提升的判定框架),而 compiler-dom 只负责回答那些只有"目标是浏览器"时才成立的问题:

  1. 哪些标签是原生标签(isNativeTag)?
  2. 哪些标签是自闭合的(isVoidTag,如 <br>、<img>)?
  3. 哪些标签内部的空白必须保留(isPreTag)?哪些标签要吃掉第一个换行(isIgnoreNewlineTag)?
  4. <script>/<style>/<title>/<textarea> 的内容应该被当成"纯文本"还是"可解析标记"(RCDATA/RAWTEXT)?
  5. svg/math 命名空间如何切换(getNamespace)?
  6. v-model 在 input/select/textarea/checkbox/radio 上分别要生成哪个运行时指令?
  7. v-on 的 .stop.prevent.once 这类修饰符如何转成 withModifiers(...) / withKeys(...)?
  8. 大段静态 DOM 在 Node.js 构建时能不能直接序列化成 innerHTML 字符串?

这八个问题的答案,全部集中在一个 const parserOptions(parserOptions.ts:6-68)、一张指令变换表(index.ts:33-40)、一个静态字符串化器(stringifyStatic.ts:77-173)里。这一章就是把这 1400 行逐行拆开。

本章阅读路线

  • 解析与指令(8.1–8.4):index.ts 三次合并 → parserOptions 七项走读 → htmlNesting 嵌套规则 → decodeHtmlBrowser 与属性大小写。
  • 指令变换(8.5–8.7):vOn 修饰符三分类与两条包装链 → vModel 五种运行时指令分流 → vHtml / vText 的 children 替换。
  • 表达式与优化(8.8–8.11):transformExpression 两条路径 → babelUtils 逐函数 → validateExpression → stringifyStatic 阈值与准入。
  • 收尾(8.12–8.14):三条剪枝线与 tree-shaking → 一段产物逐行解读 → 本章要点回顾。

术语约定:本文把 packages/compiler-dom/src 称为"DOM 特化层",把 packages/compiler-core/src 称为"核心编译器"。文中提到的 transformOn.ts / transformModel.ts 是任务大纲给的旧命名,仓库中实际文件名是 vOn.ts / vModel.ts(与核心编译器的 transforms/vOn.ts / vModel.ts 同名但目录不同),下文一律以真实路径为准并标注行号。


8.1 compiler-dom 的定位:index.ts 里的三次合并

8.1.1 公开 API 面:compile / parse 与 re-export

compiler-dom 的对外接口极其小巧(index.ts:42-77):

export function compile(
  src: string | RootNode,
  options: CompilerOptions = {},
): CodegenResult {
  return baseCompile(
    src,
    extend({}, parserOptions, options, {
      nodeTransforms: [
        ignoreSideEffectTags,
        ...DOMNodeTransforms,
        ...(options.nodeTransforms || []),
      ],
      directiveTransforms: extend(
        {},
        DOMDirectiveTransforms,
        options.directiveTransforms || {},
      ),
      transformHoist: __BROWSER__ ? null : stringifyStatic,
    }),
  )
}

export function parse(template: string, options: ParserOptions = {}): RootNode {
  return baseParse(template, extend({}, parserOptions, options))
}

注意两件事:

其一,compile 与 parse 都不包含任何算法,它们只是"组装参数、然后委托给 baseCompile / baseParse"。这正是适配层的价值:平台差异全部表达为数据(options),而不是表达为控制流。

其二,文件末尾(index.ts:71-78)做了一整套 re-export:

export * from './runtimeHelpers'      // 注册 DOM 专属 helper 名
export { transformStyle } from './transforms/transformStyle'
export {
  createDOMCompilerError,
  DOMErrorCodes,
  DOMErrorMessages,
} from './errors'
export * from '@vue/compiler-core'    // 把整个 compiler-core 的导出透传出去

最后的 export * from '@vue/compiler-core' 是关键:它让 import { baseParse, NodeTypes, createSimpleExpression } from '@vue/compiler-dom' 这种用法成立,也就是说 compiler-dom 在使用者是"compiler-core 的超集"——凡是核心编译器有的,DOM 编译器都有,只是其中一部分实现被 DOM 版覆盖了。

8.1.2 第一次合并:parserOptions(解析行为)

extend({}, parserOptions, options, {...})(index.ts:48)的顺序决定了优先级:

默认值(compiler-core defaultParserOptions)  <  DOM parserOptions  <  用户 options  <  硬编码的三项(nodeTransforms/directiveTransforms/transformHoist)

这里有一个容易看漏的细节:parserOptions 只作为 baseCompile 的选项被传入,baseParse 内部还会再和 defaultParserOptions 做一次 extend(parser.ts:1031-1041),并且会跳过值为 null / undefined 的键:

currentOptions = extend({}, defaultParserOptions)
if (options) {
  let key: keyof ParserOptions
  for (key in options) {
    if (options[key] != null) {
      // @ts-expect-error
      currentOptions[key] = options[key]
    }
  }
}

所以 parserOptions.ts 里可以安全地写 decodeEntities: __BROWSER__ ? decodeHtmlBrowser : undefined——在非浏览器构建里这个键是 undefined,baseParse 会跳过它,从而不会把 defaultParserOptions 里的默认值(本就没有该键)覆盖成 undefined,也就不会触发 __DEV__ 下"decodeEntities 在非浏览器构建里被忽略"的警告(parser.ts:1044-1047)。

8.1.3 第二次合并:nodeTransforms 的注册顺序

nodeTransforms 是一个有序数组,它的顺序就是变换的执行顺序。index.ts:49-56 组装出的顺序是:

nodeTransforms: [
  ignoreSideEffectTags,        // ① DOM 专属:先干掉 <script>/<style>
  ...DOMNodeTransforms,        // ②  = [transformStyle, transformTransition, (validateHtmlNesting)]
  ...(options.nodeTransforms || []),
]

而 baseCompile 在 transform 之前又把它拼到核心变换数组的前面(compile.ts:108-118):

nodeTransforms: [
  ...nodeTransforms,              // getBaseTransformPreset() 返回的核心变换
  ...(options.nodeTransforms || []), // ← 这里塞进的就是上面那段 DOM 数组
],

getBaseTransformPreset()(compile.ts:32-63)返回的核心顺序是:

[
  transformVBindShorthand,   // :foo  简写
  transformOnce,             // v-once
  transformIf,               // v-if / v-else-if / v-else
  transformMemo,             // v-memo
  transformFor,              // v-for
  ...(__COMPAT__ ? [transformFilter] : []),
  ...(!__BROWSER__ && prefixIdentifiers ? [trackVForSlotScopes, transformExpression] : ...),
  transformSlotOutlet,
  transformElement,
  trackSlotScopes,
  transformText,
]

于是最终的完整顺序是:

┌────────────────────────────────────────────────────────────┐
│ ① ignoreSideEffectTags        ← DOM 最外层:删除副作用标签   │
│ ② transformStyle              ← DOM:style="..." → :style   │
│ ③ transformTransition         ← DOM:<Transition> 校验      │
│ ④ validateHtmlNesting(__DEV__)← DOM:非法嵌套告警(仅开发)  │
├────────────────────────────────────────────────────────────┤
│ ⑤ transformVBindShorthand                                  │
│ ⑥ transformOnce                                            │
│ ⑦ transformIf                                              │
│ ⑧ transformMemo                                            │
│ ⑨ transformFor                                             │
│ ⑩ trackVForSlotScopes / transformExpression(前缀标识符)   │
│ ⑪ transformSlotOutlet                                      │
│ ⑫ transformElement                                         │
│ ⑬ trackSlotScopes                                          │
│ ⑭ transformText                                            │
├────────────────────────────────────────────────────────────┤
│ ⑮ ...用户自定义 nodeTransforms(options.nodeTransforms)    │
└────────────────────────────────────────────────────────────┘

这个顺序不是随意的,而是有一连串"必须如此"的因果:

  • ① 必须在最前:<script> 里的内容一旦被当作元素解析就会污染 AST,甚至让后面的变换看到不该看到的节点。ignoreSideEffectTags 在 enter 阶段就 context.removeNode()(ignoreSideEffectTags.ts:17),把它从父节点上摘掉。
  • ② 必须在 transformElement⑫ 之前:transformStyle 把静态 style="color:red" 就地改写成 :style='{\"color\":\"red\"}'(transformStyle.ts:20-30),之后 transformElement 才会把它当作一条 v-bind 处理,产出 patchFlag 与 props。若顺序颠倒,transformElement 读到的是老的 ATTRIBUTE 节点,静态样式就不会被纳入"可序列化"的判定。
  • ③ transformTransition 的 enter 只做登记,真正逻辑在 return 的 exit 回调(Transition.ts:19-56)。这意味着它对 <Transition> 子节点的处理发生在所有子节点变换完之后——所以它的多个子节点校验能看见完整的 children。
  • ④ validateHtmlNesting 仅 __DEV__:它是纯诊断,不影响产物,生产构建里整段被 __DEV__ 条件剪掉(index.ts:30)。
  • ⑩ transformExpression 必须在 transformElement⑫ 前:它负责把 {{ foo }} 改成 _ctx.foo;而 transformElement 负责收集 props 与生成 VNODE_CALL。若表达式没先改写,props 里存的还是原始字符串。同理,transformFor⑨ 必须先于 transformExpression⑩,因为 v-for 的别名(item、index)要先进 context.identifiers,transformExpression 才能据此判断"这个标识符是局部变量,不该加 _ctx."。

一句话总结:nodeTransforms 的顺序编码了"谁先看到 AST 的哪个状态"这一时序契约。DOM 特化层选择"尽量往前插",正是因为它的变换(删标签、改写静态 style、校验嵌套)都属于"越早做越好"的预处理。

8.1.4 第三次合并:directiveTransforms 的覆盖语义

directiveTransforms(index.ts:33-40)是一个字典,合并规则是后者覆盖前者:

export const DOMDirectiveTransforms: Record<string, DirectiveTransform> = {
  cloak: noopDirectiveTransform,   // v-cloak 无编译期作用
  html:  transformVHtml,           // v-html → innerHTML
  text:  transformVText,           // v-text → textContent
  model: transformModel,           // override compiler-core  ← 覆盖
  on:    transformOn,              // override compiler-core  ← 覆盖
  show:  transformShow,            // v-show → vShow 运行时指令
}

对照 getBaseTransformPreset() 里的核心表(compile.ts:57-61):

{
  on: transformOn,      // 核心版
  bind: transformBind,
  model: transformModel, // 核心版
}

可以清楚看到覆盖只发生在 on 和 model 两项:

指令核心版DOM 版覆盖方式
bindtransformBind(compiler-core/src/transforms/vBind.ts)无直接复用
ontransformOn(compiler-core/src/transforms/vOn.ts:29-179)transformOn(compiler-dom/src/transforms/vOn.ts:108-153)包装:DOM 版调用核心版并传 augmentor
modeltransformModel(compiler-core/src/transforms/vModel.ts:23-165)transformModel(compiler-dom/src/transforms/vModel.ts:20-127)后处理:DOM 版先调核心版,再改写
html无transformVHtmlDOM 新增
text无transformVTextDOM 新增
show无transformShowDOM 新增
cloak无noopDirectiveTransformDOM 新增(空实现)

这两种"覆盖"的写法差异非常重要,因为它们代表了两种不同的扩展模式:

  • on 是"注入式"覆盖:DOM 版把自己当成核心版的后置增强器,通过 augmentor 回调(vOn.ts:109、vOn.ts:165-167)拿到核心版产出的 props 后再加工。核心版并不需要知道 DOM 版的存在。
  • model 是"前置 + 后置"覆盖:DOM 版先跑核心版(vModel.ts:21),拿到基础 props 后,再决定是否把 props 换成一个"运行时指令触发"结构(needRuntime),并删掉 modelValue(vModel.ts:118-124)。

compiler-dom 之所以不直接在核心版里写 if (isDom),是因为核心版还必须服务 compiler-ssr。SSR 场景下 v-model 走的是 SSR 版变换(compiler-ssr/src/transforms/ssrVModel.ts),完全不同的 props 结构。所以核心版只生产"平台无关的语义骨架",DOM 版负责"把它落成真实 DOM 行为"。

8.1.5 transformHoist:一个被"预留"的钩子

baseCompile 里有这么一段(compile.ts:106-119),核心版调 transform(ast, {...}) 时把 nodeTransforms/directiveTransforms 拼进去。而 transformHoist 不在这里——它在 transform.ts 的 transform 上下文里作为 context.transformHoist 存在(transform.ts:138、transform.ts:166),并且在 cacheStatic 的最后一步被调用(cacheStatic.ts:248-250):

if (toCache.length && context.transformHoist) {
  context.transformHoist(children, context, node)
}

也就是说:只有在 hoistStatic 打开、并且确实产生了要缓存的静态节点时,transformHoist 才会被触发一次。DOM 版把它设为 stringifyStatic(非浏览器构建)或 null(浏览器构建):

transformHoist: __BROWSER__ ? null : stringifyStatic,   // index.ts:62

这条 __BROWSER__ 判断是本章最关键的性能分界线之一,8.11 会专门展开。此处只需记住结论:字符串化静态节点是"构建期优化",它需要 @vue/shared 里的 escapeHtml / normalizeClass / stringifyStyle 等一大票工具函数,还要做 new Function eval(stringifyStatic.ts:412-431),因此在浏览器里跑的 runtime compiler 干脆不做这件事(__BROWSER__ ? null : ...)。


8.2 parserOptions 逐项:七个回调如何改变解析结果

parserOptions.ts 全文只有 68 行,但它定义了 compiler-dom 的"世界模型"。我们逐字段走读,并标注每个回调在 parser 的哪一行被调用、传什么参数。

8.2.1 parseMode: 'html':切换 tokenizer 状态机

export const parserOptions: ParserOptions = {
  parseMode: 'html',        // parserOptions.ts:7
  ...

baseParse 把 parseMode 映射成 tokenizer 的 ParseMode(parser.ts:1056-1061):

tokenizer.mode =
  currentOptions.parseMode === 'html'
    ? ParseMode.HTML
    : currentOptions.parseMode === 'sfc'
      ? ParseMode.SFC
      : ParseMode.BASE

三种模式的差别集中在 stateBeforeTagName(tokenizer.ts:559-595):

  • BASE:所有标签一视同仁,全部走 State.InTagName,不存在特殊内容区。
  • HTML:遇到 <t... 会进 State.BeforeSpecialT(准备匹配 <title / <textarea),遇到 <s... 会进 State.BeforeSpecialS(准备匹配 <script / <style)。匹配成功后 <script>/<style> 进入 RAWTEXT(内容当纯文本,只找 </tag),<title>/<textarea> 进入 RCDATA(内容当文本,但仍解码实体)。
  • SFC:在 SFC 根级别,除 <template> 外的一切标签内容都是 RAWTEXT(tokenizer.ts:601-608:if (tag !== 'template') this.enterRCDATA(toCharCodes('</' + tag), 0)),因为 <script lang="ts">、<style scoped> 的内容绝不能被当成模板解析。

parseMode: 'html' 的直接影响是:同一个 <textarea>{{ a }}</textarea> 在 base 模式里会被解析成一个插值节点,在 html 模式里会被解析成一段纯文本。这解释了为什么"把模板交给 compiler-core 的 baseParse 直接编译"往往得不到预期结果——你缺了 parseMode: 'html' 这一行。

RCDATA 与 RAWTEXT 的关系(tokenizer.ts:553-557):

public enterRCDATA(sequence: Uint8Array, offset: number): void {
  this.inRCDATA = true
  this.currentSequence = sequence   // 例如 `</textarea`
  this.sequenceIndex = offset
}

此后状态机的目标变成"找到 currentSequence"。inRCDATA 还是"空白浓缩"的开关:onCloseTag 里 if (!tokenizer.inRCDATA) { el.children = condenseWhitespace(children) }(parser.ts:651-653)——RCDATA 内部不做空白压缩,这正是 <textarea> 内容能原样保留的关键。

8.2.2 isVoidTag: isVoidTag:自闭合标签的两处判定

isVoidTag 来自 @vue/shared(domTagConfig.ts:63-64),白名单是:

const VOID_TAGS =
  'area,base,br,col,embed,hr,img,input,link,meta,param,source,track,wbr'

它在 parser 中被调用两次,语义不同:

第一次在 onclosetag(parser.ts:159):当遇到一个结束标签时,如果这个名字是 void tag,就直接忽略该结束标签(<br></br> 里的 </br> 被丢弃):

onclosetag(start, end) {
  const name = getSlice(start, end)
  if (!currentOptions.isVoidTag(name)) {
    // ... 在 stack 里回退匹配
  }
}

第二次在 endOpenTag(parser.ts:583-590):刚遇到一个开始标签,如果名字是 void tag,就立即关闭它,不进栈:

if (currentOptions.isVoidTag(tag)) {
  onCloseTag(currentOpenTag!, end)
} else {
  stack.unshift(currentOpenTag!)
  if (ns === Namespaces.SVG || ns === Namespaces.MATH_ML) {
    tokenizer.inXML = true
  }
}

这两处合起来实现了"<img src="x"> 不需要写 </img>"。注意:isVoidTag 只对 Namespaces.HTML 的标签产生"P5"效果吗?其实 endOpenTag 里的判断没检查 ns。但由于 SVG 里 br/img 等标签名不在 void 白名单的语义范围内(白名单是 HTML 的),实际影响是:若 SVG 里恰好出现 <br>,也会被当作 void。这与浏览器行为一致(浏览器在 foreign content 里对 HTML void tag 的处理也是特判)。

一个易错点:<div/> 这种"自闭合非 void 元素"的写法在 Vue 里是允许的(onselfclosingtag,parser.ts:181-188),Vue 会在 isSelfClosing = true 之后手动关闭它。但在浏览器 HTML 解析里 <div/> 并不意味着自闭合(末尾斜杠被忽略)。这就是 Vue 模板与"直接贴进 HTML"的一个已知差异。

8.2.3 isNativeTag:决定"是元素还是组件"

isNativeTag: tag => isHTMLTag(tag) || isSVGTag(tag) || isMathMLTag(tag),  // parserOptions.ts:9

三个白名单分别来自 domTagConfig.ts:6-39(HTML 100+ 标签、SVG 60+ 标签、MathML 40+ 标签)。这个回调在 isComponent 里被使用(parser.ts:782-793):

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" 属性特判
}

逻辑反过来读更清楚:"如果它不是原生标签,那么它就是组件"。所以:

  • <div> → isNativeTag('div') === true → !true === false → 不因此判为组件 → 最终 tagType = ELEMENT。
  • <MyComp> → 首字母大写 → 直接 tagType = COMPONENT。
  • <Foo-Bar> → 首字母小写,不在 HTML 白名单 → isNativeTag 返回 false → !false === true → tagType = COMPONENT。
  • <xyz> → 不在白名单 → 判为组件(会去 resolveComponent('xyz'),运行时若无注册则告警)。这是新手最容易踩的坑:拼错的标签名不会报"未知 HTML 标签",而是报"未知组件"。

isNativeTag 的判定结果最终凝结在 node.tagType 上(parser.ts:638-648 的 onCloseTag),而 tagType 在整个 transform / codegen 阶段被大量消费:transformModel 用它区分"元素 v-model"和"组件 v-model"(compiler-dom/src/transforms/vModel.ts:23),vOn.ts 用它决定事件名是否要 camelCase(compiler-core/src/transforms/vOn.ts:50-58),stringifyStatic 用它判断节点是否可字符串化(stringifyStatic.ts:179-181)。

8.2.4 isPreTag 与 isIgnoreNewlineTag:空白守恒

isPreTag: tag => tag === 'pre',                                    // parserOptions.ts:10
isIgnoreNewlineTag: tag => tag === 'pre' || tag === 'textarea',    // parserOptions.ts:11

isPreTag 控制"进入 pre 状态",进而控制空白压缩:

  • endOpenTag 里 if (ns === Namespaces.HTML && currentOptions.isPreTag(tag)) { inPre++ }(parser.ts:580-582)。
  • onCloseTag 里 if (ns === Namespaces.HTML && currentOptions.isPreTag(tag)) { inPre-- }(parser.ts:664-666)。
  • condenseWhitespace 里 if (!inPre) { ...空白处理... } else { node.content = node.content.replace(windowsNewlineRE, '\n') }(parser.ts:842-879)。

注意这是一个计数器(let inPre = 0,parser.ts:95),而不是布尔量。原因是 <pre> 可以嵌套(例如 <pre><code><pre>),用计数才能正确配对增减。此外,inPre 只在 ns === HTML 时增减——SVG 里的 <pre> 不算(SVG 里没有 pre 的空白语义)。

pre 分支里那行 node.content.replace(/\r\n/g, '\n')(parser.ts:874-878)解决的是 #6410:SSR 服务端渲染的 <pre> 里若有 \r\n,浏览器在把它变成 DOM 时会归一化成 \n,于是 hydration 前后文本不一致。编译器主动做同样的归一化,保证客户端与服务端文本一致。这是一个典型的"编译期替运行时擦屁股"的例子。

isIgnoreNewlineTag 控制"吃掉第一个换行"。onCloseTag 中(parser.ts:655-662):

if (ns === Namespaces.HTML && currentOptions.isIgnoreNewlineTag(tag)) {
  // remove leading newline for <textarea> and <pre> per html spec
  // https://html.spec.whatwg.org/multipage/parsing.html#parsing-main-inbody
  const first = children[0]
  if (first && first.type === NodeTypes.TEXT) {
    first.content = first.content.replace(/^\r?\n/, '')
  }
}

这对应 HTML 规范的"in body"插入模式:<pre> 与 <textarea> 的第一个换行符被忽略。如果不实现这一条,就会出现这样的诡异现象:

<pre>
hello</pre>

浏览器渲染出 hello(吃掉第一个 \n),而 Vue 的 createElement('pre', 'hello\n') 会渲染出前面多一个换行。这是 hydration mismatch 的高频来源之一,所以 Vue 必须精确复刻浏览器的行为。

顺带一提 isPreTag 与 isIgnoreNewlineTag 在 DOM 版里是"不同标签集"(前者只有 pre,后者是 pre + textarea),这恰好对应规范里两者的不同适用范围。

8.2.5 isBuiltInComponent:让 <Transition> 走进"组件"通道

isBuiltInComponent: tag => {
  if (tag === 'Transition' || tag === 'transition') {
    return TRANSITION
  } else if (tag === 'TransitionGroup' || tag === 'transition-group') {
    return TRANSITION_GROUP
  }
},   // parserOptions.ts:14-20

它同时接受大小写两种写法。返回值是 runtimeHelpers.ts:24-27 里注册的符号:

export const TRANSITION: unique symbol = Symbol(__DEV__ ? `Transition` : ``)
export const TRANSITION_GROUP: unique symbol = Symbol(__DEV__ ? `TransitionGroup` : ``)

在 isComponent 里(parser.ts:789-791),只要 isBuiltInComponent(tag) 返回真值,就判为组件。于是 <transition>(小写)会被编译成 _createBlock(_Transition, ...)(内建组件),而不是 _createElementBlock("transition", ...)(原生未知元素)。

这个设计有两个收益:

  1. <transition> 参与运行时组件逻辑(BaseTransition 会拦截子节点、处理 mode、appear 等),而原生元素无法承载这些语义。
  2. 树摇友好:不写 <Transition> 的模板不会引用 TRANSITION 符号,Transition 组件不会被拉进产物。

而在 transformTransition(Transition.ts:12-58)里,这个符号又被用来做"子节点数量校验"和"persisted 属性注入"——前者是 <Transition> 只能有一个子元素的硬约束,后者是当唯一子元素带 v-show 时注入 persisted: true,让 v-show 的显示切换能被 transition 感知(Transition.ts:40-55)。

8.2.6 getNamespace:SVG / MathML 命名空间的"状态传播"

这是 parserOptions 里最长的一段(parserOptions.ts:22-68),它逐字复刻了 HTML 规范的 tree construction dispatcher(注释里直接给了 WHATWG 链接)。

核心思想:命名空间不是由标签名单独决定的,而是由"父节点的命名空间 + 父节点标签 + 当前标签名"三者共同决定。这与"看到 <svg> 就切 SVG"的朴素做法不同。

getNamespace(tag, parent, rootNamespace) {
  let ns = parent ? parent.ns : rootNamespace
  if (parent && ns === Namespaces.MATH_ML) {
    if (parent.tag === 'annotation-xml') {
      if (tag === 'svg') return Namespaces.SVG
      if (parent.props.some(a =>
            a.type === NodeTypes.ATTRIBUTE && a.name === 'encoding' &&
            a.value != null &&
            (a.value.content === 'text/html' ||
             a.value.content === 'application/xhtml+xml'))) {
        ns = Namespaces.HTML
      }
    } else if (/^m(?:[ions]|text)$/.test(parent.tag) &&
               tag !== 'mglyph' && tag !== 'malignmark') {
      ns = Namespaces.HTML
    }
  } else if (parent && ns === Namespaces.SVG) {
    if (parent.tag === 'foreignObject' || parent.tag === 'desc' || parent.tag === 'title') {
      ns = Namespaces.HTML
    }
  }
  if (ns === Namespaces.HTML) {
    if (tag === 'svg') return Namespaces.SVG
    if (tag === 'math') return Namespaces.MATH_ML
  }
  return ns
}

逐条解释:

  • annotation-xml 的 encoding 特判:MathML 的 annotation-xml 医学/数学用途里可以嵌 HTML。若 encoding="text/html" 或 application/xhtml+xml,其子内容回到 HTML 命名空间;若子标签是 <svg>,则进 SVG。
  • mi/mtext/mo/mn/ms 的子内容回到 HTML(正则 /^m(?:[ions]|text)$/ 匹配 mi、mn、mo、ms、mtext):这些是 MathML 的"叶子文本容器",规范允许它们内部放 HTML phrasing content。但 mglyph 与 malignmark 例外(它们本身是 MathML 元素)。
  • SVG 的"HTML 出口":foreignObject(可嵌任意 HTML)、desc、title 三个标签的子内容回到 HTML。
  • 根级别进入:只有当当前 ns 是 HTML 时,<svg> 才切 SVG、<math> 才切 MathML。也就是说,SVG 里再写 <svg> 不会重复切换(它是 SVG 内的嵌套 svg)。

getNamespace 在 onopentagname 时被调用(parser.ts:144):

ns: currentOptions.getNamespace(name, stack[0], currentOptions.ns)

注意这里传的 parent 是 stack[0](当前的栈顶元素),而不是所有祖先。这实现了"命名空间沿标签栈向下传播":每开一个新标签,就从当前栈顶继承 ns 并按规则修正,然后这个 ns 会跟着新元素进栈,成为下一个标签的 parent。

命名空间一旦确定,会带来一连串连锁反应:

影响点位置行为
tokenizer XML 模式parser.ts:587-589进 SVG/MathML 时 tokenizer.inXML = true(属性大小写敏感、允许 xmlns)
出 XML 模式parser.ts:671-676回到 HTML ns 时 inXML = false
CDATA 处理parser.ts:472-478非 HTML ns 下 <![CDATA[...]]> 当文本;HTML ns 下报 CDATA_IN_HTML_CONTENT
空白压缩parser.ts:651RCDATA 下跳过;pre 判定还额外要求 ns === HTML
静态字符串化的属性白名单stringifyStatic.ts:190-200HTML / SVG / MathML 各用一套 isKnown*Attr
运行时 patchPropruntime-dom/src/patchProp.tsSVG 走 setAttributeNS(xlink 等)

可以说,getNamespace 是"DOM 特化层里最接近浏览器规范原文的一段代码"——它甚至没有做任何简化。

8.2.7 decodeEntities:浏览器专属的实体解码入口

decodeEntities: __BROWSER__ ? decodeHtmlBrowser : undefined,   // parserOptions.ts:12

这条在 8.4 专门展开。此处只需注意它的平台二态性:浏览器构建用 decodeHtmlBrowser(借助真实 DOM),非浏览器构建用 undefined(走 entities/decode 的纯 JS 实现,由 compiler-core 内部处理,见 parser.ts:127-130 与 parser.ts:1044-1053)。

至此,parserOptions 的七个回调全部走完。把它压缩成一张"契约表":

回调调用点(parser.ts 行号)输入作用域
isVoidTag159 / 583标签名开始/结束标签处理
isNativeTag792(经 isComponent)标签名tagType 判定
isPreTag580 / 664标签名 + ns空白压缩
isIgnoreNewlineTag655标签名 + ns首个换行剔除
isBuiltInComponent790(经 isComponent)标签名tagType 判定
getNamespace144标签名 + parent + rootNs命名空间传播
decodeEntities127 / 319 / 598文本 + asAttr实体解码

这张表就是"compiler-core 定义的全部平台钩子"。要支持一个新平台(比如小程序、NativeScript),你只需要实现这七件事,而不用碰核心编译器的任何一行。


8.3 HTML 嵌套规则:htmlNesting.ts 与"浏览器会怎么解析"

8.3.1 为什么需要这张表:模板作者与浏览器的"理解分歧"

HTML 规范对嵌套有大量隐式规则,而这些规则几乎都不是"报错",而是"自动改树"。最经典的例子:

<p>一段文字<div>块</div></p>

浏览器解析这段 HTML 时,遇到 <div> 会隐式关闭 <p>,于是实际的 DOM 树是:

p "一段文字"
div "块"
(孤立且被忽略的 </p>)

也就是说,<p> 里根本放不进 <div>。如果 Vue 的模板编译器不理解这一点,它会生成一个"看起来正确"的 vnode 树:

createElementBlock('p', null, [
  '一段文字',
  createElementBlock('div', null, '块')
])

客户端渲染(createElement 逐层 append)会得到"正确的嵌套",但服务端 SSR 吐出的 HTML 被浏览器解析后会变成兄弟节点——于是 hydration 时 DOM 结构与 vnode 结构不一致,产生:

  1. 结构错位导致的 DOM 复用错乱(vnode 把 div 当成 p 的子节点去 patch,但真实 DOM 里它是兄弟);
  2. 更隐蔽的是,Vue 3 的"块树 + 动态子节点"优化会因结构错位而 patch 到错误的元素。

validate-html-nesting 的作者 MananTank 为此写了一篇长文,而 Vue 直接把它的规则表整文件复制进来(htmlNesting.ts:1-8 的注释交代了来源与 ISC 许可):

/**
 * Copied from https://github.com/MananTank/validate-html-nesting
 * with ISC license
 *
 * To avoid runtime dependency on validate-html-nesting
 * This file should not change very often in the original repo
 * but we may need to keep it up-to-date from time to time.
 */

注意"To avoid runtime dependency"这句——Vue 选择复制而非依赖,是为了不把一个小工具包带进 @vue/compiler-dom 的生产依赖里。这是一个典型的"代码复制优于依赖引入"决策:规则表很稳定(浏览器规范不变),依赖的风险(供应链、体积、版本兼容)却持续存在。

8.3.2 isValidHTMLNesting:五级短路判定

判定函数只有 30 行(htmlNesting.ts:13-44),但短路顺序有讲究:

export function isValidHTMLNesting(parent: string, child: string): boolean {
  // 1. template 可以包含任何子节点
  if (parent === 'template') return true

  // 2. 若已知 parent 的白名单子节点集合 → 完全由白名单决定
  if (parent in onlyValidChildren) return onlyValidChildren[parent].has(child)

  // 3. 若已知 child 的白名单父节点集合 → 完全由白名单决定
  if (child in onlyValidParents) return onlyValidParents[child].has(parent)

  // 4. 若已知 parent 的黑名单子节点集合 → 命中则 false
  if (parent in knownInvalidChildren) {
    if (knownInvalidChildren[parent].has(child)) return false
  }

  // 5. 若已知 child 的黑名单父节点集合 → 命中则 false
  if (child in knownInvalidParents) {
    if (knownInvalidParents[child].has(parent)) return false
  }

  return true   // 默认合法
}

设计要点:

  • "白名单优先,且是唯一权威":一旦 parent 出现在 onlyValidChildren 里(如 table、tr、select),判定立即结束,不再看后面两张黑名单。因为对这类严格的容器,"不在白名单里"就等于"不合法"。举例:isValidHTMLNesting('table', 'td') → onlyValidChildren.table.has('td') → false(td 必须在 tr 里)。
  • 默认 true(宽松):绝大多数标签组合(如 div > span、ul > li、section > p)都返回 true。这张表只收录"有明确规则"的组合,不做穷举。这与浏览器行为一致——浏览器对绝大多数组合也是宽容的。
  • template 无条件 true:因为 Vue 的 <template> 是编译期语法糖(v-if / v-for / v-slot),它在运行时并不产生 DOM 元素,所以不该受 DOM 嵌套约束。

8.3.3 四张规则表逐表解读

表一:onlyValidChildren(唯一合法子元素白名单)(htmlNesting.ts:51-81)

const emptySet = new Set([])
const onlyValidChildren: Record<string, Set<string>> = {
  head: new Set(['base','basefront','bgsound','link','meta','title',
                 'noscript','noframes','style','script','template']),
  optgroup: new Set(['option']),
  select: new Set(['optgroup', 'option', 'hr']),
  // table
  table: new Set(['caption', 'colgroup', 'tbody', 'tfoot', 'thead']),
  tr: new Set(['td', 'th']),   colgroup: new Set(['col']),
  tbody: new Set(['tr']), thead: new Set(['tr']), tfoot: new Set(['tr']),
  // 这些元素不能有任何元素子节点
  script: emptySet, iframe: emptySet, option: emptySet,
  textarea: emptySet, style: emptySet, title: emptySet,
}

逐个看语义:

  • head:白名单保留 basefront(Firefox 旧扩展)、bgsound(IE 背景音)、noframes 等历史标签,意味着只报"真的会出问题的组合",不制造误报。
  • select:含 hr——HTML 规范近年更新后 <select> 的 content model 已包含 <hr>,Vue 精确跟随。
  • table / tr / tbody…:最常被违反、后果最严重的一组。注意 table 的白名单不含 tr、tbody 的不含 td,所以 <table><tr><td>1</td></tr></table> 是错的:浏览器会自动补 <tbody>,真实 DOM 是 table > tbody > tr > td,而 Vue 编译出的是 table > tr > td——SSR 后会差一层。
  • emptySet 组:script / style / iframe / option / textarea / title 不允许元素子节点;文本子节点不受约束(validateHtmlNesting 只处理元素节点,validateHtmlNesting.ts:12-15)。

表二:onlyValidParents(唯一合法父元素白名单)(htmlNesting.ts:84-107)

const onlyValidParents: Record<string, Set<string>> = {
  html: emptySet,                      // html 不能有任何父元素
  body: new Set(['html']),
  head: new Set(['html']),
  // table
  td: new Set(['tr']),
  colgroup: new Set(['table']),
  caption: new Set(['table']),
  tbody: new Set(['table']),
  tfoot: new Set(['table']),
  col: new Set(['colgroup']),
  th: new Set(['tr']),
  thead: new Set(['table']),
  tr: new Set(['tbody', 'thead', 'tfoot']),
  // data list
  dd: new Set(['dl', 'div']),
  dt: new Set(['dl', 'div']),
  // other
  figcaption: new Set(['figure']),
  // li: new Set(["ul", "ol"]),          ← 注意这行被注释掉了
  summary: new Set(['details']),
  area: new Set(['map']),
} as const

值得单独拎出来讲的三点:

  1. li 被故意注释掉。源码里明确留了一行 // li: new Set(["ul", "ol"])。原因推理:<li> 的父元素在浏览器里其实是不受限的——<li> 可以出现在 <ul>、<ol>、<menu>,且只要在列表容器内即合法;更重要的是 <li> 与 <li> 的"兄弟"关系在 knownInvalidParents 里被处理(见下),把 li 放进 onlyValidParents 反而会因"白名单不含 ul/ol 之外的一切"而产生误报。这是一处"主动放弃规则以避免误报"的克制。
  2. dd / dt 允许 div 作父。这看起来奇怪,其实是因为 <div> 在 HTML 里可以作为 <dl> 组的包裹(规范允许 <dl> 里放 <div> 包裹一组 dt/dd)。
  3. html 的父集合是 emptySet。含义是"<html> 只能是根",isValidHTMLNesting(parent, 'html') 恒为 false。

表三:knownInvalidChildren(已知非法子元素黑名单)(htmlNesting.ts:110-184)

const knownInvalidChildren: Record<string, Set<string>> = {
  p: new Set([
    'address','article','aside','blockquote','center','details','dialog','dir',
    'div','dl','fieldset','figure','footer','form','h1','h2','h3','h4','h5','h6',
    'header','hgroup','hr','li','main','nav','menu','ol','p','pre','section',
    'table','ul',
  ]),
  svg: new Set([
    'b','blockquote','br','code','dd','div','dl','dt','em','embed','h1','h2','h3',
    'h4','h5','h6','hr','i','img','li','menu','meta','ol','p','pre','ruby','s',
    'small','span','strong','sub','sup','table','u','ul','var',
  ]),
}

p 的黑名单是这份文件里最有用的一条:它精确对应规范中"<p> 只能包含 phrasing content"这一约束。名单里既有 <div>(最常被误用)、<table>,也有 <hr>(


会隐式关闭 p)、<li>、<ul>、以及 <p> 自身(嵌套 p 会隐式闭合)。

svg 的黑名单则是一条实用主义规则:<svg> 是 SVG 命名空间,往里面塞 <div> / <span> 这类 HTML 元素在浏览器里会被当成 SVG 未知元素(不渲染、无法交互)。名单还是"HTML 常见词法元素"的超集。

表四:knownInvalidParents(已知非法父元素黑名单)(htmlNesting.ts:187-200)

const headings = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'])

const knownInvalidParents: Record<string, Set<string>> = {
  a: new Set(['a']),
  button: new Set(['button']),
  dd: new Set(['dd', 'dt']),
  dt: new Set(['dd', 'dt']),
  form: new Set(['form']),
  li: new Set(['li']),
  h1: headings, h2: headings, h3: headings,
  h4: headings, h5: headings, h6: headings,
}

这张表抓的是"同类元素不能互相嵌套":

  • a > a:<a> 不能嵌 <a>(浏览器会拆开)。
  • button > button:交互元素不能嵌套。
  • dd/dt 之间互斥。
  • form > form:表单不能嵌套。
  • li > li:<li> 不能直接嵌 <li>(要放在 <ul>/<ol> 里)。
  • h1..h6 > h1..h6:标题之间不能互相嵌套(用同一张 headings 集合共享)。

这里再次体现了"宽松优先":a 只禁止父 a,并不禁止父是别的任何东西。

8.3.4 validateHtmlNesting:只在 __DEV__ 的告警变换

规则表要生效,需要一个 transform 去调用它(validateHtmlNesting.ts:9-27):

export const validateHtmlNesting: NodeTransform = (node, context) => {
  if (
    node.type === NodeTypes.ELEMENT &&
    node.tagType === ElementTypes.ELEMENT &&
    context.parent &&
    context.parent.type === NodeTypes.ELEMENT &&
    context.parent.tagType === ElementTypes.ELEMENT &&
    !isValidHTMLNesting(context.parent.tag, node.tag)
  ) {
    const error = new SyntaxError(
      `<${node.tag}> cannot be child of <${context.parent.tag}>, ` +
        'according to HTML specifications. ' +
        'This can cause hydration errors or ' +
        'potentially disrupt future functionality.',
    ) as CompilerError
    error.loc = node.loc
    context.onWarn(error)
  }
}

五个关键设计:

  1. 双侧过滤 tagType === ELEMENT:只校验"真元素 > 真元素",组件、<template>、<slot> 全部跳过。
  2. 只检查直接父节点(context.parent 而非任意祖先):HTML 嵌套规则本质是局部规则(浏览器隐式闭合也是逐层的),检查全部祖先既慢又会误报。
  3. 用 SyntaxError + context.onWarn:是 onWarn 而非 onError——非法嵌套只是警告,不阻断编译,否则会引发海量既存项目构建失败。
  4. 注册受 __DEV__ 保护(index.ts:30):DOMNodeTransforms = [transformStyle, transformTransition, ...(__DEV__ ? [validateHtmlNesting] : [])],生产构建里根本不出现,零运行时开销、零代码体积(与 Transition.ts:27-38 对 X_TRANSITION_INVALID_CHILDREN 的处理同策略)。
  5. 必须在 enter 阶段执行:transform 维护 parentStack,context.parent 取栈顶;若放在 exit,context.parent 已弹成更上层。这就是 validateHtmlNesting 直接返回、而 transformTransition 返回 exit 闭包的原因。

8.3.5 与浏览器解析行为的差异清单

必须强调:htmlNesting.ts 是"编译期校验器",它不会改写 AST 去模拟浏览器的自动闭合。也就是说,Vue 编译出 p > div 这样的 AST 后,产出的 vnode 结构就是 p > div,不会提前把 div 提升成 p 的兄弟。真正被"提醒"的是开发者。

在这个前提下,二者行为对照如下:

场景浏览器 HTML 解析Vue 编译产物差异后果
<p><div></div></p>自动闭合 p,div 成兄弟p > divSSR 后 hydration mismatch(Vue 会告警)
<table><tr><td>自动补 <tbody>table > tr > td同上,Android 老 WebView 甚至更严重
<li><li>第二 <li> 与前一个成兄弟li > li结构错位
<div/> 自闭合 div斜杠被忽略,不自闭合自闭合(isSelfClosing=true)Vue 更严格,不会吞掉后续内容
<br></br></br> 被丢弃同(isVoidTag 丢弃)一致
<select><hr>允许允许(白名单含 hr)一致
<svg><div>div 变成 SVG 未知元素svg > div,仍会渲染出无意义节点Vue 告警(svg 黑名单)

结论:Vue 不模仿浏览器的容错,而是把错误在开发期用告警暴露给作者——若真去模拟自动闭合,AST 会与源码字面结构严重偏离,source map、IDE 跳转、心智模型全部崩坏。

面试问答:"为什么 Vue 不在生产环境做嵌套校验?" 答:因为它是纯诊断信息,不影响产物正确性;放进生产只会增大编译器体积与构建耗时。Vue 把它放在 __DEV__ ? [...] : [] 里,配合 onWarn,做到"开发期提示、生产期零成本"。


8.4 实体解码与属性大小写:decodeHtmlBrowser 与三处特判

8.4.1 decodeHtmlBrowser:拿 DOM 当解码器

let decoder: HTMLDivElement

export function decodeHtmlBrowser(raw: string, asAttr = false): string {
  if (!decoder) {
    decoder = document.createElement('div')
  }
  if (asAttr) {
    decoder.innerHTML = `<div foo="${raw.replace(/"/g, '&quot;')}">`
    return decoder.children[0].getAttribute('foo')!
  } else {
    decoder.innerHTML = raw
    return decoder.textContent!
  }
}

(decodeHtmlBrowser.ts:3-15,文件头还有 /* eslint-disable no-restricted-globals */,因为它在模块顶层类型标注里用了 HTMLDivElement。)

每行都有理由:

① 单例缓存 decoder:document.createElement 有成本,而解码在解析一个模板时会被调用成千上万次,缓存一个模块级 div 反复使用。

② asAttr 走"塞进属性再取回"路径:decoder.innerHTML = '<div foo="...">' 后用 getAttribute('foo') 读回。属性值里的实体解码规则与文本内容不同(有些实体只在某个上下文解码),只有真实 DOM 的 getAttribute 能返回与浏览器完全一致的规范值;raw.replace(/"/g, '&quot;') 是必要保护,否则 raw 里的 " 会提前关闭属性引号。

③ asAttr = false 走文本路径:innerHTML = raw 后取 textContent,它会丢掉标签只留文本(raw = 'a<b>c' → ac);但 Vue 只在 raw 含 & 时调用解码,这种场景几乎只出现在非法模板里,无害。

调用点有三处,且全部在 __BROWSER__ 分支内:

// ① 插值表达式(parser.ts:125-131)
__BROWSER__ ? (exp = decodeEntities(exp, false)) : (exp = decodeHTML(exp))
// ② 属性值(parser.ts:318-323)——asAttr = true
__BROWSER__ && attrValue.includes('&') && (attrValue = decodeEntities(attrValue, true))
// ③ 文本节点(parser.ts:595-600)
__BROWSER__ && tag !== 'script' && tag !== 'style' && (content = decodeEntities(content, false))

注意 ③ 里的 tag !== 'script' && tag !== 'style':RAWTEXT 内容不解码实体(<script> 里的 &amp; 必须原样保留)。而 RCDATA(<title>/<textarea>)的行内文本是要解码的(浏览器对 RCDATA 会解码实体)——这里没排除 title/textarea,正符合规范。

为什么非浏览器构建不用它? 因为它依赖 document。Node 端 compiler-core 走 entities/decode 的 decodeHTML(parser.ts:49),是纯 JS 实体表实现,两者被刻意对齐(同一套 WHATWG 实体表)。

为什么浏览器构建不能省略它? baseParse 会在 __DEV__ && !__TEST__ 下强制要求 decodeEntities(parser.ts:1049-1053)并抛错,因为浏览器构建是"完整版 runtime compiler",不解码实体的话 {{ '&amp;' }} 就会渲染出字面 &amp;。

8.4.2 属性名大小写:inXML 与两类事件名特判

属性名的大小写处理与属性值不同,它由 tokenizer 的 inXML 状态控制:

  • inXML === false(HTML 模式):属性名在 tokenizer 层被转小写,所以 <div CLASS="x"> 与 <div class="x"> 是同一个属性(这也意味着 Vue 模板不应该指望 HTML 元素的属性名保留大小写)。
  • inXML === true(SVG/MathML):属性名大小写敏感,所以 SVG 的 viewBox、preserveAspectRatio、gradientTransform 必须原样书写。

inXML 的开关在 endOpenTag(parser.ts:587-589,进 SVG/MathML 时置 true)与 onCloseTag(parser.ts:671-676,回到 HTML ns 时置 false)。这套机制保证了 <svg viewBox="..."> 里的 viewBox 不被误伤。

属性名的另一处特判在事件名。v-on 的事件名生成(compiler-core/src/transforms/vOn.ts:39-59):

const eventString =
  node.tagType !== ElementTypes.ELEMENT ||
  rawName.startsWith('vnode') ||
  !/[A-Z]/.test(rawName)
    ? toHandlerKey(camelize(rawName))       // 自动转 camelCase
    : `on:${rawName}`                       // 保留大小写
eventName = createSimpleExpression(eventString, true, arg.loc)

规则是:组件监听器、vnode-* 钩子以及不含大写字母的事件名,都自动 camelize + toHandlerKey;而"原生元素上含大写字母的事件名"保留原样并加 on: 前缀。

动机(issue #2249):自定义元素的事件名大小写敏感——<my-el @someEvent> 的事件名就是 someEvent,若 camelize 成 someevent 就监听不到。于是 Vue 生成 "on:someEvent" 这个原样字符串 key,交给运行时 patchProp 去 addEventListener('someEvent')。on: 前缀在运行时被剥离(runtime-dom 的 events 模块),用于与 onXxx 普通 prop 区分。

8.4.3 xlink: 与命名空间属性

SVG 里有三个"命名空间限定"的属性家族:xlink:*(如 xlink:href)、xml:*(如 xml:lang、xml:space)、以及 xmlns / xmlns:xlink。它们:

  • 在 @vue/shared 的 isKnownSvgAttr 白名单里被显式列出(domAttrConfig.ts:122-123):

    xlink:actuate,xlink:arcrole,xlink:href,xlink:role,xlink:show,xlink:title,xlink:type,xmlns:xlink,xml:base,xml:lang,...
    

    这直接影响 stringifyStatic 的属性白名单判定(stringifyStatic.ts:190-200 → isKnownSvgAttr):只有白名单内的属性才允许被字符串化进 innerHTML。若一个 SVG 属性不在白名单,静态块会被"拆开",不做字符串化。

  • 在 MathML 白名单里也列了 xlink:href,xlink:show,xlink:type,xmlns(domAttrConfig.ts:143)。

  • 在运行时,patchAttr 对 xlink: 前缀做 setAttributeNS(runtime-dom/src/modules/attrs.ts:24-29):

    if (isSVG && key.startsWith('xlink:')) {
      if (value == null) {
        el.removeAttributeNS(xlinkNS, key.slice(6, key.length))
      } else {
        el.setAttributeNS(xlinkNS, key, value)
      }
    }
    

    xlinkNS = 'http://www.w3.org/1999/xlink'(attrs.ts:14)。注意 key.slice(6) 得到的是本地名(localName),例如 xlink:href → href;而 setAttributeNS 需要"完整限定名"(含前缀),所以传的是完整 key。

编译期注意点:xlink:href 这种带冒号属性在 HTML 模式下被当作普通属性名(HTML 属性名允许冒号),只有在 inXML 为真的 SVG 子树里才按命名空间语义走。若把 <svg> 写成 <SVG>,getNamespace 的 tag === 'svg' 判断失败,整个子树不再是 SVG 命名空间,xlink:href 退化成普通 attribute 而不生效——非常隐蔽的坑:SVG 根标签必须小写。

8.4.4 boolean attribute 的三处特殊处理

HTML 里有一类属性(disabled、checked、selected、multiple、readonly 等),它们的"存在即真"。@vue/shared 定义了两套名单:

// 只收录"属性名与 DOM prop 名不同"的那些(客户端需要特判)
const specialBooleanAttrs =
  `itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,playsinline,readonly`
export const isSpecialBooleanAttr = makeMap(specialBooleanAttrs)

// SSR / 静态字符串化用完整名单
export const isBooleanAttr = makeMap(
  specialBooleanAttrs + `,async,autofocus,…,disabled,checked,muted,multiple,selected`,
)

export function includeBooleanAttr(value: unknown): boolean {
  return !!value || value === ''
}

(domAttrConfig.ts:15-35。)

第一处:运行时 patchAttr(attrs.ts:22、attrs.ts:37-45):只有 isSpecialBooleanAttr 的属性在运行时走"布尔语义"(值不是 '' 而是 null/false 时 removeAttribute,是真值时 setAttribute(key, ''))。其余属性(包括 disabled)走 DOM property 通道(patchProp 里 shouldSetAsProp 会优先用 el.disabled = ...),因为 DOM 属性本身就是布尔的。

第二处:SSR 的 ssrRenderAttrs 需要完整名单来决定"要不要输出 disabled 这个单词"。

第三处、也是本章最需要讲清的一处:stringifyStatic 里的两条特判(stringifyStatic.ts:349-372):

// #6568 —— 字符串化时的布尔属性
if ((isBooleanAttr(arg) || arg === 'hidden') && exp.content === 'false') continue   // 整条丢掉
let evaluated = evaluateConstant(exp)
if (evaluated != null) {
  if (arg === 'hidden' && typeof evaluated === 'number' && !includeBooleanAttr(evaluated)) continue
  res += ` ${arg}="${escapeHtml(evaluated)}"`
}

两条特判针对**"静态布尔属性被字符串化进 HTML"时的语义保真**:

  1. :disabled="false":字面输出 disabled="false" 会让浏览器认为 disabled 存在(布尔属性只看存在与否)→ 元素被禁用,与 v-bind 语义相反,必须整条丢掉(#6568)。
  2. :hidden="0":hidden 兼具布尔与数值语义,求值为数字且 includeBooleanAttr(0) === false 时同样丢掉;:hidden="1" 则输出 hidden="1",语义正确。

顺带看 evaluated 的其它规范化(stringifyStatic.ts:368-376):

if (arg === 'class') evaluated = normalizeClass(evaluated)
else if (arg === 'style') evaluated = stringifyStyle(normalizeStyle(evaluated))
res += ` ${(p.arg as SimpleExpressionNode).content}="${escapeHtml(evaluated)}"`

:class="['a','b']" 会先被 normalizeClass 合成 "a b" 再输出;:style="{color:'red'}" 会被 stringifyStyle 合成 "color:red;"。这两步是必须的,因为 normalizeClass/stringifyStyle 与运行时的同名实现一致(都来自 @vue/shared),若不做,字符串化后的 class/style 与运行时计算的写法不同,就会在 hydration 时产生差异。

8.4.5 属性值的引号与转义:escapeHtml

字符串化时所有文本与属性值都过 escapeHtml(shared/src/escapeHtml.ts:3-45):

const escapeRE = /["'&<>]/
export function escapeHtml(string: unknown): string {
  const str = '' + string
  if (!escapeRE.test(str)) return str    // 快速路径:无特殊字符直接返回
  // ... 逐字符替换 " & ' < >(& 必须先转,避免与已有实体混淆)
}
  • 正则预检 + 快速路径:绝大多数字符串不含这五个字符,一次检测失败即返回,这是高频路径(每个文本节点、每个属性)上的性能关键。
  • 转义集合 " & ' < >:两种引号都转(可能出现在单/双引号属性里),尖括号转义避免破坏标签;不转反引号(HTML 属性不用它)。
  • '' + string:先强制成字符串,兼容 number/boolean(静态绑定求值结果可能是数字)。

这些正确性细节是 8.11 的前菜——stringifyStatic 收益巨大,代价就是必须把边角全部对上。


8.5 v-on 的 DOM 特化:修饰符三分类与两条包装链

8.5.1 为什么 DOM 版只是"包一层"

先看 DOM 版 transformOn 的结构(vOn.ts:108-153)——它没有任何自己的解析逻辑,全部是"调核心版 + 拿结果再改":

export const transformOn: DirectiveTransform = (dir, node, context) => {
  return baseTransform(dir, node, context, baseResult => {
    const { modifiers } = dir
    if (!modifiers.length) return baseResult
    // ... 对 baseResult.props[0] 做加工
    return { props: [createObjectProperty(key, handlerExp)] }
  })
}

baseTransform 即核心版(compiler-core/src/transforms/vOn.ts:29-179),它的第四个参数 augmentor 会在核心版把 props 组装好之后、缓存之前被调用(vOn.ts:164-167):

let ret: DirectiveTransformResult = {
  props: [createObjectProperty(eventName, exp || createSimpleExpression(`() => {}`, false, loc))],
}
// apply extended compiler augmentor
if (augmentor) {
  ret = augmentor(ret)
}

这一"包装 + augmentor"的设计,把 DOM 修饰符逻辑完全隔离在核心编译器之外,同时避免了在核心版里写 if (isDom)。核心版只负责:事件名规范化(toHandlerKey(camelize(...)))、内联语句包裹成 $event => (...){}、cacheHandlers 与 isHandlerKey 标记。

DOM 版为什么把这一段写在 augmentor 而不是"先调 base 再改返回值"?其实两者等价——源码选择 augmentor 是因为核心版需要 isHandlerKey 等收尾动作在 augmentor 之后仍能作用于新 props(vOn.ts:177)。如果把加工放到外面,就得多写一次相同的收尾。

8.5.2 修饰符三分类:resolveModifiers

DOM 版把修饰符分成三类(vOn.ts:33-90):

const isEventOptionModifier = makeMap(`passive,once,capture`)
const isNonKeyModifier = makeMap(
  // 事件传播管理
  `stop,prevent,self,` +
    // 系统修饰符 + exact
    `ctrl,shift,alt,meta,exact,` +
    // 鼠标
    `middle`,
)
const maybeKeyModifier = makeMap('left,right')
const isKeyboardEvent = makeMap(`onkeyup,onkeydown,onkeypress`)

resolveModifiers 的遍历逻辑:

for (let i = 0; i < modifiers.length; i++) {
  const modifier = modifiers[i].content
  if (__COMPAT__ && modifier === 'native' && checkCompatEnabled(...)) {
    eventOptionModifiers.push(modifier)
  } else if (isEventOptionModifier(modifier)) {
    eventOptionModifiers.push(modifier)
  } else {
    if (maybeKeyModifier(modifier)) {
      if (isStaticExp(key)) {
        if (isKeyboardEvent(key.content.toLowerCase())) keyModifiers.push(modifier)
        else nonKeyModifiers.push(modifier)
      } else {
        // 事件名是动态的 → 两边都放
        keyModifiers.push(modifier)
        nonKeyModifiers.push(modifier)
      }
    } else {
      if (isNonKeyModifier(modifier)) nonKeyModifiers.push(modifier)
      else keyModifiers.push(modifier)
    }
  }
}

分类结果与它们的编译落点:

类别成员编译落点运行时落点
事件选项修饰符passive、once、capture拼进事件名(onClickOnce)patchEvent 解析后缀 → addEventListener(type, fn, { once: true })
非按键修饰符stop、prevent、self、ctrl、shift、alt、meta、exact、middle包进 _withModifiers(fn, [...])withModifiers 的 modifierGuards 表
按键修饰符left、right(键盘事件时)+ 一切未被前两类命中的(enter、esc、tab、delete 等)包进 _withKeys(fn, [...])withKeys 的 key 匹配
特殊.native(仅 __COMPAT__)归入事件选项组(向后兼容)无(旧语义)

为什么 passive/once/capture 要拼进事件名而不是运行时包装? 因为它们不是"拦截函数"而是"addEventListener 的第三个参数"。运行时 patchEvent 在解析事件名时会按大写后缀拆出这些选项(详见 runtime-dom/src/modules/events.ts)。拼进 key 的好处是:同一个 handler 引用在事件名不同的情况下会被分别注册,且 addEventListener 选项只设置一次(而非每次调用都判断)。

8.5.3 left / right 的双身份:为什么需要"两边都放"

left 与 right 在鼠标事件里是"鼠标键"(MouseEvent.button),在键盘事件里是"方向键"(ArrowLeft / ArrowRight)。编译期有可能通过事件名判断:

  • 事件名是静态(@keyup.left、@click.right):isKeyboardEvent(key.content.toLowerCase()) 检查 key 是否为 onkeyup/onkeydown/onkeypress。是 → 只放 keyModifiers;否 → 只放 nonKeyModifiers。
  • 事件名是动态(@[dynamic].left):无法判断 → 两边都放(keyModifiers.push(modifier); nonKeyModifiers.push(modifier))。

"两边都放"是一个巧妙的容错:生成 _withModifiers(_withKeys(fn, ['left']), ['left']),运行时两者都生效——withKeys 检查键盘按键名,withModifiers 检查 MouseEvent.button。而 modifierGuards.left 的实现(runtime-dom/src/directives/vOn.ts:42):

left: (e: Event) => 'button' in e && (e as MouseEvent).button !== 0,

注意语义是反的:返回 true 表示"应该拦截"。所以 button !== 0 为真时(不是左键)就拦截。middle(button !== 1)、right(button !== 2)同理。

8.5.4 click.right / click.middle 的重写

vOn.ts:118-123:

if (nonKeyModifiers.includes('right')) key = transformClick(key, `onContextmenu`)
if (nonKeyModifiers.includes('middle')) key = transformClick(key, `onMouseup`)

原因很直白:click 事件在右键/中键点击时根本不触发(各浏览器行为也不一致)。所以:

  • @click.right → 改监听 contextmenu(右键菜单事件);
  • @click.middle → 改监听 mouseup(因为中键没有专门事件,用 mouseup + button !== 1 的守卫判断)。

transformClick(vOn.ts:92-106)处理三种 key 形态:

const transformClick = (key: ExpressionNode, event: string) => {
  const isStaticClick = isStaticExp(key) && key.content.toLowerCase() === 'onclick'
  return isStaticClick
    ? createSimpleExpression(event, true)                       // 静态 onClick → 直接换名
    : key.type !== NodeTypes.SIMPLE_EXPRESSION
      ? createCompoundExpression([                              // 复合(动态事件名)
          `(`, key, `) === "onClick" ? "${event}" : (`, key, `)`,
        ])
      : key                                                     // 静态但非 onClick → 不动
}

三种情况:

  1. 静态且等于 onclick:直接替换成 onContextmenu。这是绝大多数用法,产物最简。
  2. 复合表达式(动态事件名):生成三元判断 (key) === "onClick" ? "onContextmenu" : (key)。只对 onClick 应用右键处理,其他动态事件名保留原样——因为无法确定它是 click 还是别的。
  3. 静态但非 onclick:key 原样返回(例如 @mouseup.right 不改名,只加 _withModifiers 的 button 守卫)。

注意三元表达式里的字符串是 "onClick"(而不是 "onclick"),因为核心版的事件名生成已经把静态事件名规范成 onClick 形式(toHandlerKey(camelize('click')) === 'onClick')。

8.5.5 两条包装链的生成条件

第一条:withModifiers(vOn.ts:125-130)

if (nonKeyModifiers.length) {
  handlerExp = createCallExpression(context.helper(V_ON_WITH_MODIFIERS), [
    handlerExp,
    JSON.stringify(nonKeyModifiers),
  ])
}

V_ON_WITH_MODIFIERS 在 runtimeHelpers.ts:15-17 定义为符号,并在 registerRuntimeHelpers 里映射到 withModifiers(runtimeHelpers.ts:35)。也就是说,最终产物里出现的是 withModifiers 这个名字。

第二条:withKeys(vOn.ts:132-141)

if (
  keyModifiers.length &&
  // 事件名是动态的,永远加 keys 守卫
  (!isStaticExp(key) || isKeyboardEvent(key.content.toLowerCase()))
) {
  handlerExp = createCallExpression(context.helper(V_ON_WITH_KEYS), [
    handlerExp,
    JSON.stringify(keyModifiers),
  ])
}

条件比第一条多一层:要么事件名是动态的,要么它是键盘事件。因为 keyModifiers 里可能混入"动态事件名时两边都放"的 left/right。实际意义:静态 @click.left 不会生成 withKeys(left 被判为 nonKey,走 withModifiers);而 @keyup.left 是键盘事件,会生成 withKeys。

两条链可以同时出现,例如 @click.stop.left:

nonKeyModifiers = ['stop', 'left']   → withModifiers(fn, ["stop","left"])
keyModifiers    = []                  (click 非键盘事件)

产物:{ onClick: withModifiers(handler, ["stop","left"]) }。 而 @keyup.enter.stop:

nonKeyModifiers = ['stop']    → withModifiers(fn, ["stop"])
keyModifiers    = ['enter']   → withKeys(..., ["enter"])

产物:{ onKeyup: withKeys(withModifiers(handler, ["stop"]), ["enter"]) }——注意嵌套顺序:先 withModifiers,再 withKeys。因为 withKeys 在里层会先判断按键,若不匹配直接 return(不执行内部函数),那么 stop 守卫也不会执行,符合"按键不匹配时什么都不做"的直觉。

8.5.6 eventOptionModifiers 的拼接与 capitalize

vOn.ts:143-148:

if (eventOptionModifiers.length) {
  const modifierPostfix = eventOptionModifiers.map(capitalize).join('')
  key = isStaticExp(key)
    ? createSimpleExpression(`${key.content}${modifierPostfix}`, true)
    : createCompoundExpression([`(`, key, `) + "${modifierPostfix}"`])
}

capitalize 来自 @vue/shared,把首字母转大写:

修饰符capitalize 后结果事件名(以 click 为例)
.onceOnceonClickOnce
.captureCaptureonClickCapture
.passivePassiveonClickPassive
.once.captureOnceCaptureonClickOnceCapture

运行时 parseName 会把 onClickOnceCapture 拆成 onClick + once + capture(runtime-dom/src/modules/events.ts 中通过 eventName[0] === 'o' && eventName[1] === 'n' 与 optionsModifierRE 匹配)。顺序敏感:.once.capture 与 .capture.once 会生成不同后缀,但都合法(运行时按后缀集合解析)。

resolveModifiers 的 context / loc 参数(vOn.ts:33-38)当前只被 __COMPAT__ 的 checkCompatEnabled 用到(vOn.ts:46-55),非 compat 构建里是预留位(为未来的修饰符告警留接口),不影响产物。

8.5.8 一个完整对照:@click.stop.prevent.once.passive.capture 的产物

模板:

<button @click.stop.prevent.once.passive.capture="onClick">点我</button>

分类:

eventOptionModifiers = ['once', 'passive', 'capture']
nonKeyModifiers      = ['stop', 'prevent']
keyModifiers         = []

生成(mode: 'module'):

import { withModifiers as _withModifiers, createElementBlock as _createElementBlock, openBlock as _openBlock } from "vue"

function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("button", {
    onClickOncePassiveCapture: _withModifiers(_ctx.onClick, ["stop", "prevent"])
  }, "点我"))
}

逐点说明:

  • 事件名 onClickOncePassiveCapture:后缀顺序跟随书写顺序(once → passive → capture)。
  • _withModifiers(_ctx.onClick, ["stop","prevent"]):注意修饰符数组顺序也跟随书写顺序(stop 先于 prevent)。这不是小事:withModifiers 内部按数组顺序执行守卫,stop 先于 prevent 意味着"先阻止冒泡、再阻止默认行为"。
  • _ctx.onClick:因为 onClick 是方法(isMemberExp),核心版不会把它包成箭头函数($event 参数由 withModifiers 内部透传给 fn(event, ...args))。
  • 无 _withKeys:因为没有按键修饰符。

8.5.9 运行时侧:withModifiers 与 withKeys 的实现要点

withModifiers(runtime-dom/src/directives/vOn.ts:52-71):

export const withModifiers = (fn, modifiers) => {
  if (!fn) return fn                              // 允许 withModifiers(undefined, ...)
  const cache = fn._withMods || (fn._withMods = {})
  const cacheKey = modifiers.join('.')
  return cache[cacheKey] || (cache[cacheKey] = (event, ...args) => {
    for (let i = 0; i < modifiers.length; i++) {
      const guard = modifierGuards[modifiers[i]]
      if (guard && guard(event, modifiers)) return   // guard 返回 true 表示"拦截"
    }
    return fn(event, ...args)
  })
}

三个要点:

  1. 在 fn 上挂 _withMods 缓存:按 modifiers.join('.') 作 key 缓存包装函数,于是同一个 _ctx.onClick 在不同模板位置用不同修饰符会产生不同但稳定的函数引用,避免每次渲染新建函数导致子组件更新。
  2. guard(event, modifiers) 整个数组也传进去:只有 exact 需要它(vOn.ts:45-47)——任一系统键(ctrl/shift/alt/meta)按下却不在 modifiers 里时拦截。
  3. if (!fn) return fn:允许 withModifiers(undefined, [...])。

withKeys(vOn.ts:91-162)的实现要点:

  • 'key' in event 前置检查(vOn.ts:120-122):非键盘事件没有 key,直接 return(不执行 handler)。这保证 withKeys 用在鼠标事件上不会误触发。
  • hyphenate(event.key)(vOn.ts:124):把 event.key 归一化。event.key 可能是 'ArrowUp'、' '(空格)、'Escape',hyphenate 把 ArrowUp → arrow-up,于是模板里的 .up 能匹配。
  • keyNames 兼容表(vOn.ts:75-86):esc→escape、space→' '、up→arrow-up、down→arrow-down、delete→backspace。这层是 Vue 2 兼容:Vue 2 里 .space、.esc 是简写。
  • 数字 keyCode 与 globalKeyCodes(vOn.ts:135-158):仅在 __COMPAT__ 下,支持 .13(数字)以及全局 Vue.config.keyCodes 配置。这是 Vue 2 → 3 迁移期遗留,生产不启用。
  • _withKeys 缓存(vOn.ts:114-118):与 _withMods 同样的缓存机制。

一个易错点:withModifiers/withKeys 是 @private(源码注释标注),不在公开 API 里,它们只是"编译产物的运行时契约"。所以你不能在用户代码里依赖它们,但可以放心:只要编译器和运行时版本匹配,这个契约就成立(这也是为什么 @vue/compiler-dom 与 vue 必须版本对齐)。


8.6 v-model 的 DOM 特化:五种运行时指令的分流矩阵

8.6.1 覆盖结构:先跑核心版,再"分流"

DOM 版 transformModel(vModel.ts:20-127)的骨架:

export const transformModel: DirectiveTransform = (dir, node, context) => {
  const baseResult = baseTransform(dir, node, context)     // ① 核心版
  // 核心版报错、或组件 v-model(只需要 props)→ 早返回
  if (!baseResult.props.length || node.tagType === ElementTypes.COMPONENT) return baseResult
  if (dir.arg) context.onError(...X_V_MODEL_ARG_ON_ELEMENT...)   // ② 元素上不允许带参数
  // ③ 按 tag / type 决定用哪个运行时指令(见 8.6.3)
  baseResult.needRuntime = context.helper(directiveToUse)
  // ④ 删掉 modelValue prop(运行时用 binding.value 即可,减小产物体积)
  baseResult.props = baseResult.props.filter(p => /* key !== 'modelValue' */)
  return baseResult
}

四步各自的意图:

  • ① 复用核心版:核心版(compiler-core/src/transforms/vModel.ts:23-165)负责与平台无关的部分:生成 modelValue prop、onUpdate:modelValue 事件、组件场景的 modelModifiers、五种错误校验(X_V_MODEL_ON_PROPS / ON_CONST / ON_SCOPE_VARIABLE 等)、以及 inline 模式下对 ref 的 .value / isRef 改写。
  • ② 提前返回组件分支:组件 v-model 只需 modelValue + onUpdate:modelValue 两个 props,不需要运行时指令。
  • ③ 元素分支决定"哪个运行时指令":DOM 特化层的核心,下一节展开。
  • ④ 删 modelValue:运行时指令通过 binding.value 读值,元素场景的 modelValue prop 冗余;源码注释明确"removing it reduces code size"(vModel.ts:116-118)。

8.6.2 needRuntime:把 props 换成"运行时指令"的机制

baseResult.needRuntime = context.helper(directiveToUse)(vModel.ts:105)是整个机制的关键。needRuntime 是 DirectiveTransformResult 上的一个字段,transformElement 看到它时会:

  1. 生成 _resolveDirective("model")(或对 v-model 而言,用 _withDirectives 包裹元素 vnode);
  2. 把 needRuntime 指向的 helper 作为指令的 dir 值。

也就是说,元素 v-model 产物的形态是"_withDirectives(vnode, [[_vModelText, bindingValue, modifiers]])",而其中的第三个参数(指令本体)就是 context.helper(V_MODEL_TEXT) 产出的符号,最终映射成运行时导出的 vModelText。

V_MODEL_TEXT 等符号的定义与注册(runtimeHelpers.ts:3-13、runtimeHelpers.ts:29-40):

export const V_MODEL_TEXT: unique symbol = Symbol(__DEV__ ? `vModelText` : ``)
// V_MODEL_RADIO / V_MODEL_CHECKBOX / V_MODEL_SELECT / V_MODEL_DYNAMIC 同构

registerRuntimeHelpers({
  [V_MODEL_TEXT]: `vModelText`,
  // …五个 vModel* 符号
  [V_ON_WITH_MODIFIERS]: `withModifiers`,
  [V_ON_WITH_KEYS]: `withKeys`,
  [V_SHOW]: `vShow`,
  [TRANSITION]: `Transition`,
  [TRANSITION_GROUP]: `TransitionGroup`,
})

注意 Symbol(__DEV__ ? 'vModelText' : ''):生产构建里符号描述是空串,进一步省字节(符号描述本身会进产物吗?不会,但 Symbol() 调用参数在 minify 后可省)。

8.6.3 分流矩阵:tag × type → 运行时指令

判定逻辑(vModel.ts:48-106):

if (tag === 'input' || tag === 'textarea' || tag === 'select' || isCustomElement) {
  let directiveToUse = V_MODEL_TEXT          // 默认:文本
  let isInvalidType = false
  if (tag === 'input' || isCustomElement) {
    const type = findProp(node, `type`)
    if (type) {
      if (type.type === NodeTypes.DIRECTIVE) directiveToUse = V_MODEL_DYNAMIC   // :type="foo"
      else switch (type.value?.content) {
        case 'radio':    directiveToUse = V_MODEL_RADIO; break
        case 'checkbox': directiveToUse = V_MODEL_CHECKBOX; break
        case 'file':
          isInvalidType = true
          context.onError(...X_V_MODEL_ON_FILE_INPUT_ELEMENT...)
          break
        default: __DEV__ && checkDuplicatedValue()   // text / number / email …
      }
    } else if (hasDynamicKeyVBind(node)) {
      directiveToUse = V_MODEL_DYNAMIC   // v-bind="obj" / v-bind:[k] 里可能含 type
    } else __DEV__ && checkDuplicatedValue()
  } else if (tag === 'select') directiveToUse = V_MODEL_SELECT
  else __DEV__ && checkDuplicatedValue()          // textarea

  if (!isInvalidType) baseResult.needRuntime = context.helper(directiveToUse)
} else {
  context.onError(...X_V_MODEL_ON_INVALID_ELEMENT...)
}

把它变成矩阵:

tagtype 情况选用的运行时指令备注
input无 typevModelText默认为文本
inputtype="text" 或其它非 radio/checkbox/filevModelText含 number、email、password 等
inputtype="radio"vModelRadio
inputtype="checkbox"vModelCheckbox
inputtype="file"报错,不注入文件输入只读,X_V_MODEL_ON_FILE_INPUT_ELEMENT
input:type="x"(动态绑定)vModelDynamic运行时按实际 type 分流
input有 v-bind 动态 key(v-bind="obj"、v-bind:[k])vModelDynamic因为对象里可能含 type
textarea—vModelText与 input 文本共用 vModelText
select—vModelSelect
自定义元素(isCustomElement)任意按上面 input 的逻辑分流自定义元素可能伪装成 input
其他标签(如 div)—报错X_V_MODEL_ON_INVALID_ELEMENT
组件—不注入(早返回)走 modelValue / onUpdate:modelValue

其中 hasDynamicKeyVBind(来自 compiler-core/src/transforms/vBind.ts 的具名导出)专门检测 v-bind="obj"(无 arg 的全量绑定)与 v-bind:[key]="x"(动态 arg)。这两种情况下对象里可能悄悄含 type: 'checkbox',编译期无法确定,只能交给 vModelDynamic 在运行时按真实 DOM 的 type 分流。

8.6.4 五种运行时指令在运行时的行为差异

编译期只负责"选哪个",真正的元素/事件差异在运行时体现(runtime-dom/src/directives/vModel.ts)。

vModelText(vModel.ts:61-148)——处理 input(文本)与 textarea:

created(el, { modifiers: { lazy, trim, number } }, vnode) {
  el[assignKey] = getModelAssigner(vnode)
  const castToNumber = number || (vnode.props && vnode.props.type === 'number')
  addEventListener(el, lazy ? 'change' : 'input', e => {
    if ((e.target as any).composing) return      // IME 组合中不更新
    el[assignKey](castValue(el.value, trim, castToNumber))
  })
  if (trim || castToNumber) {
    addEventListener(el, 'change', () => { el.value = castValue(el.value, trim, castToNumber) })
  }
  if (!lazy) { /* compositionstart/end + change:处理 IME 与 Safari < 10.2 自动填充 */ }
},
// mounted:hydration 场景用 defaultValue 初始化 initialValueKey
// beforeUpdate:遇到 composing 直接返回,避免打断未完成的输入 (#2302)

这里能看到 .lazy / .number / .trim 三个修饰符的完整落点:

修饰符编译期运行时落点
.lazy只是修饰符数组的一个成员,进入 binding.modifierscreated 里把绑定事件从 input 换成 change;同时不注册 composition 事件
.number同上castToNumber = number || props.type === 'number';castValue 里 looseToNumber(value);beforeUpdate 里 /^0\d/ 特判(保留前导零的字符串)
.trim同上castValue 里 value.trim();额外注册 change 事件做"失焦时回写 trim"
.number.trim组合castValue(value, trim, number) 先 trim 再 number

castValue(vModel.ts:53-57)先 trim 再 number:' 42 ' 若不先 trim,looseToNumber 会得到 NaN。

function castValue(value, trim, number) {
  if (trim) value = value.trim(); if (number) value = looseToNumber(value); return value
}

vModelCheckbox(vModel.ts:150-189)——注意 deep: true:

export const vModelCheckbox: ModelDirective<HTMLInputElement> = {
  deep: true,      // #4096 数组内的对象元素可能被改,需要深比较
  created(el, _, vnode) {
    el[assignKey] = getModelAssigner(vnode)
    addEventListener(el, 'change', () => {
      const modelValue = (el as any)._modelValue
      const elementValue = getValue(el)
      if (isArray(modelValue)) {
        // 选中:concat 进数组;取消:splice 出数组(不可变地生成新数组)
      } else if (isSet(modelValue)) {
        const cloned = new Set(modelValue)
        el.checked ? cloned.add(elementValue) : cloned.delete(elementValue)
        assign(cloned)
      } else {
        assign(getCheckboxValue(el, el.checked))
      }
    })
  },
  mounted: setChecked,
  beforeUpdate(el, binding, vnode) { el[assignKey] = getModelAssigner(vnode); setChecked(el, binding, vnode) },
}

要点:

  • 三种模型值形态:数组(多选 + :value)、Set(3.5 新增支持)、布尔(单选 checkbox)。
  • deep: true:数组里的元素对象可能被改(而非数组本身被替换),需深比较才能感知(#4096),因此 vModelCheckbox 会对模型值做深度遍历,是性能敏感点。
  • getCheckboxValue / getValue(vModel.ts:347-359):前者处理 :true-value / :false-value,让 checkbox 可绑定任意值;后者优先用 el._value(:value 写入的原始值),保证 v-model + :value="obj" 时拿到对象本身而非 "[object Object]"。

vModelRadio(vModel.ts:216-230):

created(el, { value }, vnode) {
  el.checked = looseEqual(value, vnode.props!.value)
  el[assignKey] = getModelAssigner(vnode)
  addEventListener(el, 'change', () => { el[assignKey](getValue(el)) })
},
beforeUpdate(el, { value, oldValue }, vnode) {
  el[assignKey] = getModelAssigner(vnode)
  if (value !== oldValue) el.checked = looseEqual(value, vnode.props!.value)   // 注意是 !==(引用)
},

created 用 looseEqual 宽松比较,beforeUpdate 用 value !== oldValue 引用比较——对原始值两者等价,对对象则走"重设 checked"分支,是"够用即可"、避免每次做 looseEqual 的选择。

vModelSelect(vModel.ts:232-291):最复杂的一个。

export const vModelSelect: ModelDirective<HTMLSelectElement, 'number'> = {
  deep: true,      // <select multiple> value 需深遍历
  created(el, { value, modifiers: { number } }, vnode) {
    (el as any)._modelValue = value
    addEventListener(el, 'change', () => {
      const selectedVal = Array.prototype.filter
        .call(el.options, (o: HTMLOptionElement) => o.selected)
        .map((o: HTMLOptionElement) => number ? looseToNumber(getValue(o)) : getValue(o))
      const assignedValue = el.multiple
        ? (isSet((el as any)._modelValue) ? new Set(selectedVal) : selectedVal)
        : selectedVal[0]
      // 快照 _pendingValue,避免用户代码在同一 tick 内改写模型值
      const pending = (el._pendingValue = [el.multiple, assignedValue])
      try { el[assignKey](assignedValue) }
      finally { nextTick(() => { if (el._pendingValue === pending) el._pendingValue = undefined }) }
    })
    el[assignKey] = getModelAssigner(vnode)
  },
  updated(el, { value }) {
    // 与 _pendingValue 快照比较后再 setSelected,区分"用户改的"与"自己触发的"
  },
}

_pendingValue 这套机制解决的是一个真实 bug:用户在 change 里赋值后,模型值可能在同一个 tick 内被用户代码改写,若立刻 updated 用新值 setSelected,会与用户的意图打架。快照 + nextTick 重置 + updated 里的 isSameSelectValue 比较,能区分"用户改的还是自己触发的"。

setSelected(vModel.ts:310-345)还处理 <select multiple> 的类型校验:若不是数组/Set 会 warn(vModel.ts:313-319),并支持字符串/数字的快速路径(String(v) === String(optionValue))。

vModelDynamic(vModel.ts:361-409):

function resolveDynamicModel(tagName: string, type: string | undefined) {
  switch (tagName) {
    case 'SELECT': return vModelSelect
    case 'TEXTAREA': return vModelText
    default: switch (type) {
      case 'checkbox': return vModelCheckbox
      case 'radio': return vModelRadio
      default: return vModelText
    }
  }
}
// vModelDynamic:created / mounted / beforeUpdate / updated 四个 hook 全部转发给
//             callModelHook(..., resolveDynamicModel(el.tagName, vnode.props.type))

它对每一个 hook 都转发给"按 el.tagName + vnode.props.type 实时决定"的真指令。这也解释了为什么它是 ObjectDirective 而非 ModelDirective——它需要转发全部四个 hook(created/mounted/beforeUpdate/updated),而 vModelText 等只实现了其中三个(无 updated)。

代价:vModelDynamic 每个 hook 都要重新 resolveDynamicModel(一次 switch),且无法享受"编译期已知 type"的优化。这就是为什么"能静态确定 type 就不要用动态绑定"是一条实用建议。

8.6.5 组件 v-model 与 modelModifiers(核心版负责)

组件场景由核心版处理(compiler-core/src/transforms/vModel.ts:86-162)。要点:

  • prop 名:默认 modelValue;带参数时 v-model:foo → prop 名 foo、事件名 onUpdate:foo(vModel.ts:86-91):

    const propName = arg ? arg : createSimpleExpression('modelValue', true)
    const eventName = arg
      ? isStaticExp(arg)
        ? `onUpdate:${camelize(arg.content)}`
        : createCompoundExpression(['"onUpdate:" + ', arg])
      : `onUpdate:modelValue`
    
  • 赋值表达式:$event => ((exp) = $event)(vModel.ts:115-119)。这一层箭头函数是必须的,因为 onUpdate:modelValue 必须是一个"接收新值的回调"。

  • modelModifiers(vModel.ts:140-162):仅当组件 + 有修饰符时生成:

    if (dir.modifiers.length && node.tagType === ElementTypes.COMPONENT) {
      const modifiers = dir.modifiers.map(m => m.content)
        .map(m => (isSimpleIdentifier(m) ? m : JSON.stringify(m)) + `: true`).join(`, `)
      const modifiersKey = arg
        ? isStaticExp(arg) ? `${arg.content}Modifiers` : createCompoundExpression([arg, ' + "Modifiers"'])
        : `modelModifiers`
      props.push(createObjectProperty(modifiersKey,
        createSimpleExpression(`{ ${modifiers} }`, false, dir.loc, ConstantTypes.CAN_CACHE)))
    }
    

    产物形如 modelModifiers: { trim: true } 或 fooModifiers: { "bar-baz": true }(非简单标识符要 JSON.stringify 成字符串 key)。子组件通过 props.modelModifiers 读取修饰符并自行决定行为——这是组件 v-model 修饰符"约定优于实现"的机制。注意 ConstantTypes.CAN_CACHE:这个对象字面量是常量,可以被缓存。

  • v-model 的错误校验(核心版 vModel.ts:24-84):空表达式 → X_V_MODEL_NO_EXPRESSION;绑定到 props 或 const → X_V_MODEL_ON_PROPS / X_V_MODEL_ON_CONST;非常量成员表达式 → X_V_MODEL_MALFORMED_EXPRESSION;绑定到作用域变量(v-for 别名等)→ X_V_MODEL_ON_SCOPE_VARIABLE。

  • inline 模式下的 ref 改写(vModel.ts:95-113):

    if (bindingType === BindingTypes.SETUP_REF) {
      assignmentExp = createCompoundExpression([`${eventArg} => ((`,
        createSimpleExpression(rawExp, false, exp.loc), `).value = $event)`])
    } else {
      const altAssignment = bindingType === BindingTypes.SETUP_LET ? `${rawExp} = $event` : `null`
      assignmentExp = createCompoundExpression([`${eventArg} => (${context.helperString(IS_REF)}(${rawExp}) ? (`,
        createSimpleExpression(rawExp, false, exp.loc), `).value = $event : ${altAssignment})`])
    }
    

    对确定的 ref(SETUP_REF)直接生成 x.value = $event;对"可能是 ref"(SETUP_MAYBE_REF / SETUP_LET)生成三元 isRef(x) ? (x.value = $event) : ...。SETUP_MAYBE_REF 的 altAssignment 是 null(表示"不是 ref 就不赋"),SETUP_LET 是 x = $event(局部 let 可赋值)。

8.6.6 元素 v-model 的完整产物对照

模板:

<input v-model.trim.number="msg" />

编译产物(mode: 'module',简化):

import { vModelText as _vModelText, withDirectives as _withDirectives,
         createElementVNode as _createElementVNode, openBlock as _openBlock,
         createElementBlock as _createElementBlock } from "vue"

function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("input", {
    "onUpdate:modelValue": $event => ((_ctx.msg) = $event)
  }, null, 512 /* NEED_PATCH */))
  // 实际 withDirectives 包裹后:
  // _withDirectives(_createElementBlock("input", { "onUpdate:modelValue": ... }, null, 512),
  //                [[_vModelText, _ctx.msg, { trim: true, number: true }]])
}

注意几点:

  • modelValue prop 被删掉了(第 ④ 步),只剩 onUpdate:modelValue;
  • withDirectives 的第三项是 { trim: true, number: true },由核心版生成 modelModifiers 的地方——但等等,元素场景不是"只有组件才有 modelModifiers"吗?

这里有一个容易混淆但极为重要的细节:核心版生成 modelModifiers 的条件是 node.tagType === ElementTypes.COMPONENT(核心版 vModel.ts:141),对元素不生成 modelModifiers prop。元素场景下的修饰符是通过 withDirectives 的第三个参数(binding.modifiers)传给运行时指令的——这个参数由 transformElement 在发现 needRuntime 时自动生成(读取 dir.modifiers)。所以我们看到 _vModelText 的 binding 里带了 { trim: true, number: true }。

  • 512 /* NEED_PATCH */:因为有指令,元素需要 patch。

8.7 v-html / v-text:两个"替换 children"的指令

8.7.1 transformVHtml:直接产出 innerHTML

export const transformVHtml: DirectiveTransform = (dir, node, context) => {
  const { exp, loc } = dir
  if (!exp) {
    context.onError(createDOMCompilerError(DOMErrorCodes.X_V_HTML_NO_EXPRESSION, loc))
  }
  if (node.children.length) {
    context.onError(createDOMCompilerError(DOMErrorCodes.X_V_HTML_WITH_CHILDREN, loc))
    node.children.length = 0
  }
  return {
    props: [
      createObjectProperty(
        createSimpleExpression(`innerHTML`, true, loc),
        exp || createSimpleExpression('', true),
      ),
    ],
  }
}

(vHtml.ts:8-29。)

三点:

  1. 无表达式报错(X_V_HTML_NO_EXPRESSION),但仍然返回 innerHTML: ''(用 exp || createSimpleExpression('', true)),保证产物结构稳定、不因错误而残缺。
  2. 有子节点时报错并清空:node.children.length = 0 是就地修改 AST。注意这里的报错信息是"v-html will override element children"(errors.ts:53)——它是"警告性错误",因为浏览器的 innerHTML 语义本身就是覆盖。清空 children 是必要的:否则"子节点 vnode 与 innerHTML"会同时存在,patch 时行为不可预期。
  3. innerHTML 是静态 key(createSimpleExpression('innerHTML', true, loc) 的第二个参数 isStatic = true)。这很重要:静态 key 让 transformElement 知道这是"元素属性"而非"组件 prop",从而在 prop 规范化时走 patchProp 路径。

产物(<div v-html="raw"></div>):

_createElementVNode("div", { innerHTML: _ctx.raw }, null, 8 /* PROPS */, ["innerHTML"])

注意 dynamicProps: ["innerHTML"]——因为 patchFlag 的 PROPS 需要列出动态 prop 名,运行时 patchProp 才能精确 patch 这一个属性(而不是 diff 整个 props 对象)。

8.7.2 transformVText:textContent 与 toDisplayString 的条件注入

export const transformVText: DirectiveTransform = (dir, node, context) => {
  const { exp, loc } = dir
  if (!exp) context.onError(createDOMCompilerError(DOMErrorCodes.X_V_TEXT_NO_EXPRESSION, loc))
  if (node.children.length) {
    context.onError(createDOMCompilerError(DOMErrorCodes.X_V_TEXT_WITH_CHILDREN, loc))
    node.children.length = 0
  }
  return {
    props: [
      createObjectProperty(
        createSimpleExpression(`textContent`, true),
        exp
          ? getConstantType(exp, context) > 0
            ? exp                                       // 常量:直接用
            : createCallExpression(context.helperString(TO_DISPLAY_STRING), [exp], loc)
          : createSimpleExpression('', true),
      ),
    ],
  }
}

(vText.ts:11-40。)

关键在 getConstantType(exp, context) > 0 这个判断:

  • 若是常量表达式(如 v-text="'hello'",或者 SFC 里被 compileScript 标记为 CAN_STRINGIFY 的绑定),直接用 exp,不包 toDisplayString;
  • 否则(动态值)包一层 toDisplayString。

为什么动态值必须包 toDisplayString?因为 textContent 只接受字符串,而表达式的值可能是 null / undefined / 数字 / 对象。toDisplayString(shared/src/toDisplayString.ts)把 null/undefined 归一成 '',对象序列化成 JSON.stringify,其余 String()。若不包装,textContent = null 会渲染出 "null" 字符串。这与插值 {{ }} 的行为一致,是"两条路径输出同一结果"的一致性原则。

产物(<div v-text="msg"></div>):

_createElementVNode("div", { textContent: _toDisplayString(_ctx.msg) }, null, 8 /* PROPS */, ["textContent"])

8.7.3 安全提示:v-html 是 XSS 的"官方入口"

innerHTML 的内容会被浏览器当作 HTML 解析。因此 v-html 是 Vue 模板里唯一"有意绕过文本转义"的指令。官方文档明确标注:永远不要在 v-html 里使用用户提供的内容。

编译层面,Vue 做过的相关加固:

  • v-html 的子节点被强制清空(防止"用户输入的 innerHTML + 作者写的子节点"混在一起);

  • 静态字符串化路径里,v-html 的常量值会被 evaluateConstant 求值后直接拼进 HTML(stringifyStatic.ts:377-380):

    } else if (p.name === 'html') {
      // #5439 v-html with constant value
      innerHTML = evaluateConstant(p.exp as SimpleExpressionNode) ?? ''
    }
    

    注释里的"not sure why would anyone do this but it can happen"很诚实:v-html 用常量值没有实际意义,但既然能出现,就必须处理对。

反过来说,插值 {{ }} 与 v-text 都是安全的:插值走 toDisplayString + 运行时 setElementText,v-text 走 textContent,二者都不解析 HTML。

对比表:

指令编译产物 key值处理XSS 风险
{{ msg }}children 里的 _toDisplayString转义(DOM 文本节点)无
v-text="msg"textContenttoDisplayString无
v-html="msg"innerHTML原样有(用户内容需自行过滤/sanitize)

8.8 transformExpression:两条路径与"标识符改写"

8.8 起进入 compiler-core,但这是理解 compiler-dom 产物的必需前置:compiler-dom 的 parserOptions 不设置 prefixIdentifiers,但 compile() 的默认 mode 与用户配置会决定这个开关,进而决定表达式是"改写"还是"包进 with"。

8.8.1 prefixIdentifiers 的两条路径

transformExpression 的入口(transformExpression.ts:49-92)负责遍历插值与指令,把表达式交给 processExpression。而 processExpression 的第一段就分流(transformExpression.ts:115-121):

if (__BROWSER__) {
  if (__DEV__) {
    validateBrowserExpression(node, context, asParams, asRawStatements)
  }
  return node          // ← 浏览器构建:不改写,原样返回
}

if (!context.prefixIdentifiers || !node.content.trim()) {
  return node          // ← 未开 prefixIdentifiers:不改写
}

于是有三条路径:

构建 / 配置表达式处理生成的 render 函数
浏览器构建(runtime compiler)validateBrowserExpression 校验,不改写with (this) { return ... }(mode: 'function')
非浏览器 + prefixIdentifiers: true(或 mode: 'module')逐标识符改写成 _ctx.x / $setup.x / x.value 等独立函数 render(_ctx, _cache)
非浏览器 + prefixIdentifiers: false不改写with (this) {...}(仍可用,但模块模式下被禁)

为什么模块模式强制开 prefixIdentifiers?baseCompile 的注释(compile.ts:83-84)与选项文档说明了:ES 模块默认是 strict mode,strict mode 下禁止 with。所以 mode: 'module'(Vue 3 默认的发布方式)必须走改写路径。

8.8.2 rewriteIdentifier:一张"绑定类型 → 改写形态"的完整映射表

这是 transformExpression 里最核心的函数(transformExpression.ts:128-234)。它先取绑定类型:

const type = hasOwn(bindingMetadata, raw) && bindingMetadata[raw]

然后分 inline(SFC <script setup> 内联模式)与 非 inline 两条分支。

非 inline 模式(即普通 setup() / options API,绑定通过 $setup / $props / $data 等访问):

if ((type && type.startsWith('setup')) || type === BindingTypes.LITERAL_CONST) {
  return `$setup.${raw}`          // setup 绑定
} else if (type === BindingTypes.PROPS_ALIASED) {
  return `$props['${bindingMetadata.__propsAliases![raw]}']`   // 解构重命名的 prop
} else if (type) {
  return `$${type}.${raw}`        // data/props/options 等
}
// 未识别的标识符
return `_ctx.${raw}`

对照表(非 inline):

BindingTypes改写结果说明
setup-const / setup-let / setup-ref / setup-maybe-ref$setup.x统一走 $setup(由 setup 返回对象挂载)
props-aliased$props['origName']用原始 prop 名(__propsAliases 查表)
props$props.x
data$data.x
options$options.xcomputed/inject 等
literal-const$setup.x
未标记_ctx.x兜底

inline 模式(SFC <script setup> 编译后的内联 render,直接访问局部变量):

const isAssignmentLVal = parent && parent.type === 'AssignmentExpression' && parent.left === id
const isUpdateArg = parent && parent.type === 'UpdateExpression' && parent.argument === id
const isDestructureAssignment = parent && isInDestructureAssignment(parent, parentStack)
if (isConst(type) || type === BindingTypes.SETUP_REACTIVE_CONST || localVars[raw]) {
  return raw                                     // 常量 / 局部变量:不动
} else if (type === BindingTypes.SETUP_REF) {
  return `${raw}.value`                          // 确定是 ref:.value
} else if (type === BindingTypes.SETUP_MAYBE_REF) {
  return isAssignmentLVal || isUpdateArg || isDestructureAssignment
    ? `${raw}.value`                             // 赋值场景假定是 ref(高效)
    : `unref(${raw})`                            // 读取场景(new 表达式里会加括号)
} else if (type === BindingTypes.SETUP_LET) {
  // ... 见下
} else if (type === BindingTypes.PROPS) {
  return genPropsAccessExp(raw)                  // __props.x(TS 模式类型友好)
} else if (type === BindingTypes.PROPS_ALIASED) {
  return genPropsAccessExp(bindingMetadata.__propsAliases![raw])
}

对照表(inline 模式):

场景原始改写后备注
setup-const / literal-const / setup-reactive-const / 局部变量countcount不需要 .value
setup-refcountcount.value确定是 ref
setup-maybe-ref 读取countunref(count)可能是普通值
setup-maybe-ref 赋值count = vcount.value = v假定 ref(更高效)
setup-let 读取countunref(count)可能是普通值
setup-let 赋值count = visRef(count) ? count.value = v : count复制右值语义
setup-let 自增count++isRef(count) ? count.value++ : count++见下
propstitle__props.title
props-aliasedtitle(本地别名)__props.origTitle
全局白名单(Math、console 等)MathMath不加前缀
其他foo_ctx.foo兜底

setup-let 的两处改写值得细看。赋值(transformExpression.ts:167-186):

const { right: rVal, operator } = parent as AssignmentExpression
const rExp = rawExp.slice(rVal.start! - 1, rVal.end! - 1)
const rExpString = stringifyExpression(
  processExpression(createSimpleExpression(rExp, false), context, false, false, knownIds),
)
return `${context.helperString(IS_REF)}(${raw})${context.isTS ? ` //@ts-ignore\n` : ``} ? ${raw}.value ${operator} ${rExpString} : ${raw}`

生成 isRef(x) ? x.value = y : x。注意 isTS 时会插入 //@ts-ignore\n——因为 TS 无法理解"同一个变量有时是 ref 有时是普通值"的联合类型,这句注释是给 TS 兜底(且 // 注释在表达式里换行也合法)。

自增(transformExpression.ts:187-199):

id!.start = parent!.start          // 让整个 `x++` 的源码范围由这个 id 覆盖
id!.end = parent!.end
const { prefix: isPrefix, operator } = parent as UpdateExpression
const prefix = isPrefix ? operator : ``
const postfix = isPrefix ? `` : operator
return `${context.helperString(IS_REF)}(${raw})${context.isTS ? ` //@ts-ignore\n` : ``} ? ${prefix}${raw}.value${postfix} : ${prefix}${raw}${postfix}`

生成 isRef(x) ? x.value++ : x++(前缀自增则是 ++x.value : ++x)。id.start/end 被改写为父节点范围是关键:这样后续"按范围切片重建复合表达式"时,会把 x 替换成上面这段完整三元,而不是只替换 x 本身。

8.8.3 "快路径"与常量类型推断

当表达式是简单标识符时,走快路径(transformExpression.ts:236-270):

if (ast === null || (!ast && isSimpleIdentifier(rawExp))) {
  const isScopeVarReference = context.identifiers[rawExp]
  const isAllowedGlobal = isGloballyAllowed(rawExp)
  const isLiteral = isLiteralWhitelisted(rawExp)   // 'true,false,null,this'
  if (!asParams && !isScopeVarReference && !isLiteral &&
      (!isAllowedGlobal || bindingMetadata[rawExp])) {
    if (isConst(bindingMetadata[rawExp])) node.constType = ConstantTypes.CAN_SKIP_PATCH
    node.content = rewriteIdentifier(rawExp)
  } else if (!isScopeVarReference) {
    node.constType = isLiteral ? ConstantTypes.CAN_STRINGIFY : ConstantTypes.CAN_CACHE
  }
  return node
}

四种情况:

  1. 未识别的自由标识符(如 {{ foo }},foo 不在绑定元数据、不是作用域变量、不是白名单全局)→ 改写成 _ctx.foo。若它是 setup 的 const 绑定,额外标 CAN_SKIP_PATCH(因为它不会变,patch 时可跳过)。
  2. 作用域变量(v-for 别名 item)→ 不改写、不改常量类型(它是局部的,随每次渲染变化)。
  3. 字面量白名单(true/false/null/this)→ 不改写,标 CAN_STRINGIFY(这一点对 stringifyStatic 至关重要:只有 CAN_STRINGIFY 级及以上的表达式才能被求值进字符串)。
  4. 白名单全局(Math、Date、console…)→ 不改写,标 CAN_CACHE(编译期不变但不一定可字符串化)。

isGloballyAllowed 的白名单来自 shared/src/globalsAllowList.ts(含 Infinity、undefined、isFinite、parseInt、decodeURI、Map、Set、Symbol、Promise、Proxy、Reflect、JSON、Math、Date…被刻意排除的是 window、document、globalThis 这类"平台对象",因为它们在 SSR 下不存在)。

8.8.4 复合表达式的构造:children 与 source map

非简单表达式走完整解析(transformExpression.ts:272-297):

const source = asRawStatements
  ? ` ${rawExp} `                        // v-on 多语句:前后垫空格保证偏移
  : `(${rawExp})${asParams ? `=>{}` : ``}`  // 表达式:包括号;参数:包成参数位
try {
  ast = parseExpression(source, { sourceType: 'module', plugins: context.expressionPlugins })
} catch (e: any) {
  context.onError(createCompilerError(ErrorCodes.X_INVALID_EXPRESSION, node.loc, undefined, e.message))
  return node
}

三种解析模式与"垫字符"策略:

场景source 形态垫的字符数用途
普通表达式({{ a + b }})(a + b)前后各 1(括号)避免 {...} 被解析成块语句
v-slot 参数 / v-for 别名(item, index)=>{}括号 + =>{}放进"函数参数位"
v-on 多语句(含 ;)a = 1; b = 2前后各 1(空格)避免当成表达式

"偏移 -1" 的由来:解析用的源码比原始表达式多了 1 个前导字符(( 或空格),所以 AST 里的 start/end 都相对原始表达式偏移了 +1。源码因此在切片时统一减 1(transformExpression.ts:354-356):

const start = id.start - 1
const end = id.end - 1

随后按标识符顺序切片拼装(transformExpression.ts:350-379):

ids.sort((a, b) => a.start - b.start)
ids.forEach((id, i) => {
  const last = ids[i - 1]
  const leadingText = rawExp.slice(last ? last.end - 1 : 0, start)
  if (leadingText.length || id.prefix) children.push(leadingText + (id.prefix || ``))
  children.push(createSimpleExpression(id.name, false, {
    source, /* 用 advancePositionWithClone 把内部偏移换算成模板绝对位置 */
  }, id.isConstant ? ConstantTypes.CAN_STRINGIFY : ConstantTypes.NOT_CONSTANT))
  if (i === ids.length - 1 && end < rawExp.length) children.push(rawExp.slice(end))
})

产物是字符串与子表达式的混合数组(CompoundExpressionNode.children)。例如 {{ a + b }} → ['', SimpleExp('_ctx.a'), ' + ', SimpleExp('_ctx.b')]。

这样做的唯一目的是 source map:每个被改写的标识符都保留自己的 loc,于是 DevTools 里的断点/报错能精确定位到模板源码,而不是整段表达式。

8.8.5 walkIdentifiers 的调用与"该加前缀 / 不该加前缀"的判定

walkIdentifiers(ast, (node, parent, _, isReferenced, isLocal) => {
  if (isStaticPropertyKey(node, parent!)) return        // 跳过 { foo: 1 } 里的 foo(key)
  if (__COMPAT__ && node.name.startsWith('_filter_')) return
  const needPrefix = isReferenced && canPrefix(node)
  if (needPrefix && !isLocal) {
    if (isStaticProperty(parent!) && parent.shorthand) {
      ;(node as QualifiedId).prefix = `${node.name}: `   // { foo } → { foo: _ctx.foo }
    }
    node.name = rewriteIdentifier(node.name, parent, node)
  } else if (!(needPrefix && isLocal) &&
             (!parent || (parent.type !== 'CallExpression' &&
                          parent.type !== 'NewExpression' &&
                          parent.type !== 'MemberExpression'))) {
    ;(node as QualifiedId).isConstant = true   // 可字符串化
  }
  ids.push(node as QualifiedId)   // 无论是否加前缀都 push,以生成子表达式(source map)
}, true /* includeAll */, parentStack, knownIds)

canPrefix(transformExpression.ts:393-403)的两条排除:

function canPrefix(id: Identifier) {
  if (isGloballyAllowed(id.name)) return false    // 白名单全局
  if (id.name === 'require') return false          // webpack 编译特例
  return true
}

require 单独排除是历史原因:某些打包/测试环境会注入 require,若被改写成 _ctx.require 就取不到了。

shorthand 特判值得强调:{{ { foo } }} 里的 foo 是对象简写属性(等价于 { foo: foo })。若只把值改写成 _ctx.foo,就变成 { foo } 仍引用局部 foo——所以必须补上 key:{ foo: _ctx.foo }。这就是 (node as QualifiedId).prefix = '${node.name}: ' 的作用,它会在拼接时前插到 leadingText 位置(children.push(leadingText + (id.prefix || '')))。

isConstant 标记的连锁效应:标了 isConstant 的子表达式会得到 ConstantTypes.CAN_STRINGIFY(transformExpression.ts:371-373),于是:

  • getConstantType 会把整段复合表达式判为"可字符串化"(如果所有子表达式都可字符串化);
  • stringifyStatic 的 exp.constType < ConstantTypes.CAN_STRINGIFY 判断(stringifyStatic.ts:260-265)就不会 bail;
  • 最终这段表达式可以被 evaluateConstant 求值并写进 innerHTML。

这就是"表达式改写"与"静态字符串化"之间的隐形契约:改写的副产品(constType)是 stringifyStatic 的输入前提。

8.8.6 ret.identifiers:为下游变换传递"这个表达式引用了哪些变量"

processExpression 结尾(transformExpression.ts:381-390):

ret = children.length ? createCompoundExpression(children, node.loc) : node
ret.ast = ast
if (!children.length) ret.constType = ConstantTypes.CAN_STRINGIFY   // 全是静态文本
ret.identifiers = Object.keys(knownIds)

ret.identifiers 记录了表达式引用的全部已知标识符(含 _ctx.x 改写后的形式),被 vOn.ts 的 hasScopeRef 用来判断"handler 是否引用了 v-for/v-slot 的闭包变量"(compiler-core/src/transforms/vOn.ts:114):没有引用作用域变量 → handler 可 cacheHandlers 缓存;引用了 → 必须每次传入新函数(否则闭包捕获旧值)。


8.9 babelUtils.ts 逐函数剖析:用 Babel 做"轻量静态作用域分析"

babelUtils.ts 共 557 行,是 compiler-core 里最"考古"的一份代码:它把 Babel 的 isReferenced 判定逻辑(babel-types/src/validators/isReferenced.ts)整段复制进来(babelUtils.ts:364-372 的注释直接给了链接与 LICENSE),以避免运行时依赖 @babel/types。本节逐函数走读。

8.9.1 walkIdentifiers:带作用域模拟的遍历

签名(babelUtils.ts:21-33):

export function walkIdentifiers(
  root: Node,
  onIdentifier: (node, parent, parentStack, isReference, isLocal) => void,
  includeAll = false,
  parentStack: Node[] = [],
  knownIds: Record<string, number> = Object.create(null),
): void {
  if (__BROWSER__) return     // 浏览器构建直接整段剪掉
  ...
}

第一行 if (__BROWSER__) return 是整个 babelUtils 在浏览器构建里被 tree-shake 的钩子——8.12 会讲。

rootExp 提取(babelUtils.ts:38-41):root.type === 'Program' ? root.body[0].expression : root,用于 leave 阶段判断"是否是根表达式",从而不在根节点退出时清掉自己的 scopeIds(babelUtils.ts:114-121):leave 里对非根节点逐个 knownIds[id]--,减到 0 再 delete(引用计数式清理)。

knownIds 是引用计数(Record<string, number>),不是布尔集合。因为同一名字可能被多层作用域声明(例如两个不同的箭头函数参数都叫 e),用计数才能正确地在退出内层作用域时不误删外层的声明。

enter 里的作用域登记(babelUtils.ts:43-111)分七种情况:

节点类型处理目的
父节点是 TS* 且不在 TS_NODE_TYPES 白名单this.skip()跳过纯类型节点(不想进类型注解里找标识符)
IdentifierisLocal = !!knownIds[name];isRefed = isReferencedIdentifier(...);满足 includeAll || (isRefed && !isLocal) 时回调判定"是否引用、是否局部"
ObjectProperty 且父是 ObjectPatternnode.inPattern = true标记解构模式,供 isReferenced 判断
函数类型(isFunctionType)有 scopeIds → 复用;否则 walkFunctionParams 登记参数参数名是局部变量
BlockStatement / SwitchStatement / CatchClause / for 语句有 scopeIds → 复用;否则分别用 walkBlockDeclarations / walkSwitchStatement / extractIdentifiers / walkForStatement 登记块级作用域(#3445)

scopeIds 复用的意义:同一个 AST 可能被 walkIdentifiers 遍历多次(transformExpression 会在每次处理表达式时调用)。第一次遍历时把"这个函数/块的局部名集合"缓存在节点上(markScopeIdentifier 里 (node.scopeIds || (node.scopeIds = new Set())).add(name),babelUtils.ts:349),后续遍历直接复用,省掉重复的子节点扫描。同时 leave 时的引用计数清理保证正确性。

8.9.2 isReferencedIdentifier:为什么要给 Babel 的判定打补丁

export function isReferencedIdentifier(id: Identifier, parent: Node | null, parentStack: Node[]): boolean {
  if (__BROWSER__) return false
  if (!parent) return true                       // 根标识符一定被引用
  if (id.name === 'arguments') return false      // 'arguments' 是关键字,不是引用
  if (isReferenced(id, parent, parentStack[parentStack.length - 2])) return true
  // 补丁:babel 的 isReferenced 对被赋值的 id 返回 false,这里补上
  switch (parent.type) {
    case 'AssignmentExpression':
    case 'AssignmentPattern':
      return true
    case 'ObjectProperty':
      return parent.key !== id && isInDestructureAssignment(parent, parentStack)
    case 'ArrayPattern':
      return isInDestructureAssignment(parent, parentStack)
  }
  return false
}

三个补丁分别对应:赋值左值(x = 1 里的 x,Babel 视为"非引用",但 Vue 必须改写成 _ctx.x = 1 才能真正赋到实例上)、默认值模式(f(a = b) 里的 b 是引用)、解构赋值(({ x } = obj) 里的 x,仅当整体是"解构赋值"而非"解构声明"时才是改写目标,由 isInDestructureAssignment 判断)。注意 isReferenced 的"祖父节点"取 parentStack[parentStack.length - 2],因为栈顶已是 parent 本身。

8.9.3 isInDestructureAssignment:向上回溯到 AssignmentExpression

export function isInDestructureAssignment(parent: Node, parentStack: Node[]): boolean {
  if (parent && (parent.type === 'ObjectProperty' || parent.type === 'ArrayPattern')) {
    let i = parentStack.length
    while (i--) {
      const p = parentStack[i]
      if (p.type === 'AssignmentExpression') return true
      else if (p.type !== 'ObjectProperty' && !p.type.endsWith('Pattern')) break
    }
  }
  return false
}

从栈顶向上找:遇到 AssignmentExpression → 返回 true(是解构赋值);遇到既非 ObjectProperty 也非 *Pattern 的节点 → break(说明这不是解构链,返回 false)。这条"逐层上溯"是必要的:({ a: { b } } = obj) 里 b 的 parentStack 会经过多个 ObjectProperty / ObjectPattern。

8.9.4 isInNewExpression 与 setup-let 的括号问题

export function isInNewExpression(parentStack) {   // 向上找 NewExpression,可穿透 MemberExpression 链
  let i = parentStack.length
  while (i--) {
    const p = parentStack[i]
    if (p.type === 'NewExpression') return true
    else if (p.type !== 'MemberExpression') break
  }
  return false
}
// wrapWithUnref:isNewExpression ? `(${unref(raw)})` : `unref(raw)`

原因:new unref(Foo)() 与 new (unref(Foo))() 语义完全不同。前者把 unref(Foo) 当成构造函数名(实际是语法错误或错误行为),后者才是"先求值再 new"。所以当 unref(...) 出现在 new 表达式的被调用位置时,必须加括号。MemberExpression 的循环是因为 new a.b.c() 里 a.b.c 是 MemberExpression 链。

8.9.5 extractIdentifiers:五种参数形态的递归解构

export function extractIdentifiers(param, nodes = []) {
  switch (param.type) {
    case 'Identifier': nodes.push(param); break
    case 'MemberExpression': /* 取最内层 object */ break
    case 'ObjectPattern':
      for (const prop of param.properties)
        extractIdentifiers(prop.type === 'RestElement' ? prop.argument : prop.value, nodes)   // value 而非 key
      break
    case 'ArrayPattern': param.elements.forEach(el => el && extractIdentifiers(el, nodes)); break
    case 'RestElement': extractIdentifiers(param.argument, nodes); break
    case 'AssignmentPattern': extractIdentifiers(param.left, nodes); break
  }
  return nodes
}

要点:

  • ObjectPattern 取 prop.value:因为 key 是属性名(不是变量),value 才是绑定目标。{ a: b } 里绑定的是 b。
  • AssignmentPattern 取 left:a = 1 里绑定的是 a。
  • MemberExpression 取最内层 object:f(require('x').foo) 这类参数——虽然现实中函数参数不会是成员表达式,但 walkBlockDeclarations 只处理声明,这里其实是给"非常规写法"兜底。
  • ArrayPattern 允许空洞([a, , b])→ if (element) 过滤 null。

8.9.6 块级声明与 for/switch 的登记函数

walkBlockDeclarations(babelUtils.ts:208-233)处理四类语句:

for (const stmt of body) {
  if (stmt.type === 'VariableDeclaration') {
    if (stmt.declare) continue            // declare 只存在于 TS,跳过
    for (const decl of stmt.declarations) for (const id of extractIdentifiers(decl.id)) onIdent(id)
  } else if (stmt.type === 'FunctionDeclaration' || stmt.type === 'ClassDeclaration') {
    if (stmt.declare || !stmt.id) continue
    onIdent(stmt.id)
  } else if (isForStatement(stmt)) {
    walkForStatement(stmt, true, onIdent)   // isVar=true:for 里只登记 var(自身有块)
  } else if (stmt.type === 'SwitchStatement') {
    walkSwitchStatement(stmt, true, onIdent)
  }
}

isVar 参数容易看错,需与 walkForStatement(babelUtils.ts:245-262)的 (variable.kind === 'var' ? isVar : !isVar) 一起读:块内传 isVar = true 只登记 var 声明的循环变量,isForStatement 分支传 isVar = false 只登记 let/const——这个 XOR 判据保证"循环变量只被登记一次",从而引用计数不会双重递增。

walkSwitchStatement(babelUtils.ts:264-284)多一层递归:除了处理每个 case 的 consequent 里的声明,还会对每个 case 调用 walkBlockDeclarations(cs, onIdent)(因为 SwitchCase 的 consequent 数组本身也是块)。

8.9.7 isStaticProperty / isStaticPropertyKey / isFunctionType

export const isFunctionType = (node: Node): node is Function =>
  /Function(?:Expression|Declaration)$|Method$/.test(node.type)

export const isStaticProperty = (node: Node): node is ObjectProperty =>
  node && (node.type === 'ObjectProperty' || node.type === 'ObjectMethod') && !node.computed

export const isStaticPropertyKey = (node: Node, parent: Node): boolean =>
  isStaticProperty(parent) && parent.key === node

(babelUtils.ts:352-362。)

  • isFunctionType 用 /Function(?:Expression|Declaration)$|Method$/ 匹配 FunctionExpression / FunctionDeclaration / ObjectMethod 等。注意 ArrowFunctionExpression 不匹配(Arrow 不满足结尾要求)——看似是 bug,但实际链路上影响很小:v-on 表达式被 transformExpression 跳过(transformExpression.ts:69),真正需要登记参数的场景主要来自 v-slot(asParams = true)。读源码不能只看函数名推断行为,要看它被谁调用、传什么 AST(若模板里出现"内联箭头函数参数被加了 _ctx.",即此正则覆盖不全所致)。

  • isStaticProperty:非 computed 的 ObjectProperty / ObjectMethod。computed({ [k]: v })不算静态属性,因为 k 是要被引用的标识符。

  • isStaticPropertyKey:parent.key === node,即"这个标识符是静态属性名"({ foo: 1 } 的 foo)→ 不该加前缀。这正是 transformExpression.ts:307-309 的第一个 return。

8.9.8 isReferenced(复制自 Babel)与 TS 节点工具

babelUtils.ts:373-541 是从 Babel 复制的巨型 switch (parent.type),逐分支判断"标识符在父节点里是否处于被引用位置"。对 Vue 有实际影响的几条:MemberExpression(a.b 里 a 是引用、b 不是,除非 a[b] 的 b)、VariableDeclarator(仅 init)、ArrowFunctionExpression(仅 body)、ObjectProperty(要区分对象字面量与对象解构)、AssignmentExpression(仅 right,左侧由 isReferencedIdentifier 补丁处理);而 CatchClause / RestElement / FunctionDeclaration / ImportSpecifier 等位置的标识符是"声明名",不是引用。

TS_NODE_TYPES(babelUtils.ts:543-549)与 unwrapTSNode(babelUtils.ts:551-557)是配套工具:前者列出 TSAsExpression / TSTypeAssertion / TSNonNullExpression / TSInstantiationExpression / TSSatisfiesExpression 五种 TS 包装节点,后者递归取出其内部 expression。它被其他变换(如 isMemberExpression)用来"透过 TS 语法糖看真实表达式"——{{ foo as string }} 里的 foo 必须先 unwrap 才能正确识别,这是 Vue 支持 TS 模板的关键基础设施。


8.10 validateExpression:浏览器构建的"穷人版"校验

8.10.1 为什么需要一份独立实现

浏览器构建(runtime compiler)没有 Babel。若不做任何校验,用户写的 {{ a + }} 要到运行时 new Function 才抛错,且错误信息与模板位置无法对应。所以 compiler-core 提供了一份用 new Function 试运行的轻量校验(validateExpression.ts:28-65):

export function validateBrowserExpression(
  node: SimpleExpressionNode,
  context: TransformContext,
  asParams = false,
  asRawStatements = false,
): void {
  const exp = node.content
  if (!exp.trim()) return       // 空表达式由各指令自行决定是否合法
  try {
    new Function(
      asRawStatements
        ? ` ${exp} `
        : `return ${asParams ? `(${exp}) => {}` : `(${exp})`}`,
    )
  } catch (e: any) {
    let message = e.message
    const keywordMatch = exp.replace(stripStringRE, '').match(prohibitedKeywordRE)
    if (keywordMatch) {
      message = `avoid using JavaScript keyword as property name: "${keywordMatch[0]}"`
    }
    context.onError(createCompilerError(ErrorCodes.X_INVALID_EXPRESSION, node.loc, undefined, message))
  }
}

它的三条路径与 8.8.4 的解析模式一一对应(asRawStatements → 垫空格;asParams → 包成参数位;否则包括号)。这保证了"浏览器校验通过的语法"与"Node 端 Babel 能解析的语法"尽量一致。

8.10.2 两个正则的巧妙之处

const prohibitedKeywordRE = new RegExp(
  '\\b' + (
    'arguments,await,break,case,catch,class,const,continue,debugger,default,' +
    'delete,do,else,export,extends,finally,for,function,if,import,let,new,' +
    'return,super,switch,throw,try,var,void,while,with,yield'
  ).split(',').join('\\b|\\b') + '\\b',
)

const stripStringRE =
  /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*\$\{|\}(?:[^`\\]|\\.)*`|`(?:[^`\\]|\\.)*`/g

(validateExpression.ts:7-21。)

  • prohibitedKeywordRE:只收录"不能作为表达式关键字出现"的词。注意有意排除了 typeof、instanceof、in、of、this、new——等等,名单里有 new 吗?有的。但注释明确说:"these keywords should not appear inside expressions, but operators like 'typeof', 'instanceof', and 'in' are allowed"。所以名单里没有 typeof/instanceof/in/of/void(void 有,但它是运算符……实际上名单里确实有 void,这是一个略带瑕疵的名单:void 0 是合法表达式,但会被这条规则误判为"避免使用关键字"。不过这条分支只在 new Function 已经抛错之后才执行,所以只有"语法本就错误"的表达式才会走到这一步,误判影响极小)。
  • stripStringRE:先把字符串字面量剥掉,再在剩下的部分里找关键字。这样 {{ 'function' }}(字符串里含关键字但语法合法)不会误报——它根本不会走到 catch 分支;即便走到,剥离后也找不到关键字。

8.10.3 与 Babel 路径的分工

构建校验器谁调用
__BROWSER__validateBrowserExpressionprocessExpression(transformExpression.ts:116-119)、核心版 vOn.ts:128-135
非浏览器Babel parseExpression / parseparser.ts 的 createExp(parser.ts:988-1007)+ processExpression 的 parseExpression 兜底

注意 createExp 在 parser 阶段就会预解析(exp.ast)并把错误信息(e.message)通过 emitError(ErrorCodes.X_INVALID_EXPRESSION, ...) 抛出(parser.ts:1004-1006):

} catch (e: any) {
  exp.ast = false        // 标记错误
  emitError(ErrorCodes.X_INVALID_EXPRESSION, loc.start.offset, e.message)
}

而 processExpression 看到 ast === false 就直接返回原节点(transformExpression.ts:241-244):

if (ast === false) {
  // ast being false means it has caused an error already during parse phase
  return node
}

这就是"错误只报一次"的实现:parser 已经报过,transform 不重复报。


8.11 stringifyStatic:把静态子树"拍扁"成 innerHTML 字符串

这是 Vue 3 相对 3.0 最重要的一项编译优化:对足够大的静态节点序列,不再逐个创建 vnode,而是把它们序列化成一段 HTML 字符串,交给运行时一次性 createStaticVNode(html, count)。收益是"更少的 vnode 创建 / 更小的产物体积",代价是字符串化结果必须与运行时渲染逐字节一致,否则 hydration mismatch——所以整套白名单与求值规则都在为这一点服务。

8.11.1 双闸门阈值:StringifyThresholds

const StringifyThresholds = {
  ELEMENT_WITH_BINDING_COUNT: 5,   // ② 纵向:单个元素内"可字符串化绑定"数 ≥ 5
  NODE_COUNT: 20,                  // ① 横向:相邻静态兄弟节点数 ≥ 20
}

两个入口对应两个方向:横向——一段连续的静态节点累计到 NODE_COUNT 就整段字符串化;纵向——单个元素内部 CAN_STRINGIFY 级的绑定累计到 5 个,就把该元素整体字符串化。低于阈值不生效,因为拆散小节点反而更慢、产物更大。

8.11.2 逐节点准入:analyzeNode 的三层判断

  • 只接受 ELEMENT && tagType === ELEMENT(组件、<slot>、<template> 直接出局);
  • 结构黑名单 isNonStringifiable:caption / thead / tr / th / tbody / td / tfoot / colgroup / col——浏览器会把这类表格标签从 innerHTML 里剔除或重排,字符串化会改变结构;
  • 属性白名单 isStringifiableAttr:普通属性 + isKnownSvgAttr / isBooleanAttr(这正是 8.4.3 里 xlink:* 等被显式列出的原因);
  • 表达式必须满足 exp.constType >= ConstantTypes.CAN_STRINGIFY——这直接接上了 8.8 的"改写副产品":isConstant / 字面量白名单把 constType 抬到 CAN_STRINGIFY,字符串化才放行。

8.11.3 常量求值与属性归一化

evaluateConstant 依据 __UNSAFE__ 标记决定是否允许求值——只有编译期显式标注过的调用(如 Math.* 这类纯函数)才能被求值进字符串,避免任意代码执行。拼接时:

  • 文本与属性值一律过 escapeHtml(8.4.5);
  • :class 先 normalizeClass、:style 先 stringifyStyle(normalizeStyle(...)),与运行时算法同源,保证 hydration 一致;
  • :disabled="false" 等布尔属性整条丢弃(8.4.4,#6568);
  • 最后用 expReplaceRE 把占位(如 __SIMPLE__)还原为真实片段。

8.11.4 与 transformHoist / SSR 的差异

const _hoisted_1 = /*@__PURE__*/_createStaticVNode("<div class=\"a\">…</div>", 12)

第二参数是节点个数:运行时据此生成等长的占位/锚点,保证 patch 时能对齐。它与 cacheStatic 的 transformHoist(CAN_CACHE 级)不同——hoist 仍然创建 vnode(只是复用引用),而 stringify 直接产出 HTML 字符串,粒度更大。

与 SSR 的差异:SSR 本来就把组件渲染成 HTML 字符串,不需要"把 vnode 拍扁",所以 stringifyStatic 只在客户端编译链(__SSR__ 为假)启用。一句话概括:客户端需要它来减少 vnode,SSR 已经天然是字符串。

8.11.5 收益账本

20 个静态节点:普通 hoist = 20 个 vnode + 1 个数组,每次挂载都要走 createElement + patchProps;stringify = 1 个 Static vnode + 1 个字符串,挂载时只解析一次 innerHTML。体积上,HTML 字符串远短于等价的 vnode 调用表达式。代价是任何不一致(属性顺序、boolean 语义、实体编码、class/style 归一化)都会立刻变成 hydration mismatch,所以这套白名单与求值规则必须极其严格。

8.12 产物格式与 tree-shaking

compiler-dom 发布四种格式(package.json 的 buildOptions.formats):esm-bundler / esm-browser / cjs / global。产物的 helper 通过 runtimeModuleName(默认 'vue')导入,并做别名,例如 import { createElementBlock as _createElementBlock } from "vue";optimizeImports 打开时则改为从 vue 的具名导入合并。

三条编译期常量构成剪枝线:

  • __BROWSER__:babelUtils.ts:33 的 if (__BROWSER__) return 让整套 Babel 作用域分析在浏览器构建里被整段剪掉;decodeHtmlBrowser 只在 __BROWSER__ 分支使用;SourceMapGenerator 用惰性 require 且非浏览器才加载(codegen.ts:272-278)。这正是"runtime compiler 产物更小"的原因——它不需要完整版 transformExpression。
  • __DEV__:validateHtmlNesting、vModel 的重复值检查、vOn 的 checkCompatEnabled 等只在开发构建存在。
  • __COMPAT__:.native 修饰符、keyCode、__propsAliases 等 Vue 2 迁移逻辑单独打包。

8.13 实战:一段产物的逐行解读

模板:

<ul class="list">
  <li v-for="i in items" :key="i" @click.stop="onClick(i)">{{ i }}</li>
</ul>

mode: 'module' 下的产物(节选):

import { createElementBlock as _createElementBlock, openBlock as _openBlock,
  createElementVNode as _createElementVNode, renderList as _renderList,
  Fragment as _Fragment, withModifiers as _withModifiers,
  toDisplayString as _toDisplayString } from "vue"

function render(_ctx, _cache) {
  return (_openBlock(), _createElementBlock("ul", { class: "list" }, [
    (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.items, (i) => {
      return (_openBlock(), _createElementBlock("li", {
        key: i,
        onClick: _withModifiers(($event) => _ctx.onClick(i), ["stop"])
      }, _toDisplayString(i), 9 /* TEXT, KEYED, NEED_PATCH */))
    }), 128 /* KEYED_FRAGMENT */))
  ]))
}

逐点对照本章:

  • _openBlock(true) + 128 /* KEYED_FRAGMENT */:v-for 编译成带 key 的稳定片段(Block 树,见第 7 章);
  • 9 /* TEXT · KEYED · NEED_PATCH */:{{ i }} 是动态文本,且节点需要 patch——toDisplayString 正来自 8.7;
  • _withModifiers(..., ["stop"]):.stop 属于"非按键修饰符",走 withModifiers(8.5);
  • class: "list" 是静态 prop,会被 hoist;_ctx.items / _ctx.onClick 的重写来自 8.8 的 prefixIdentifiers 路径。

8.14 本章要点回顾

  1. compiler-dom 是"平台适配层":7 个 parserOptions 钩子 + 3 个 DOM nodeTransforms + 4 个指令变换覆盖,几乎不新增解析逻辑,只做"注入 + 覆盖"。
  2. 三类差异:解析差异(void / pre / 命名空间 / 实体)、语义差异(v-model 分流、v-on 修饰符)、优化差异(stringifyStatic)。
  3. 隐形契约:constType >= CAN_STRINGIFY 既是表达式改写的产物,又是静态字符串化的准入门槛——8.8 与 8.11 通过它串联。
  4. 判定优先级:htmlNesting 一律"白名单 → 黑名单 → 默认宽松",只报真问题,不制造误报。
  5. 三条剪枝线:__BROWSER__ / __DEV__ / __COMPAT__ 决定"哪个构建包含哪些逻辑",这是理解 Vue 产物体积的一把钥匙。

至此,从"解析的 DOM 特化"到"编译期优化",本章沿着 compiler-dom 这条线把平台适配层的每个决策点都过了一遍;下一章将回到 compiler-sfc,看单文件组件如何把这些能力编排成一次完整的编译。