Vue 3 核心源码深度剖析系列(v3.5.44)-第 08 章 编译器内幕(四):compiler-dom 的 DOM 特化层
本章对应
vuejs/core3.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 只负责回答那些只有"目标是浏览器"时才成立的问题:
- 哪些标签是原生标签(
isNativeTag)? - 哪些标签是自闭合的(
isVoidTag,如<br>、<img>)? - 哪些标签内部的空白必须保留(
isPreTag)?哪些标签要吃掉第一个换行(isIgnoreNewlineTag)? <script>/<style>/<title>/<textarea>的内容应该被当成"纯文本"还是"可解析标记"(RCDATA/RAWTEXT)?svg/math命名空间如何切换(getNamespace)?v-model在input/select/textarea/checkbox/radio上分别要生成哪个运行时指令?v-on的.stop.prevent.once这类修饰符如何转成withModifiers(...)/withKeys(...)?- 大段静态 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 版 | 覆盖方式 |
|---|---|---|---|
bind | transformBind(compiler-core/src/transforms/vBind.ts) | 无 | 直接复用 |
on | transformOn(compiler-core/src/transforms/vOn.ts:29-179) | transformOn(compiler-dom/src/transforms/vOn.ts:108-153) | 包装:DOM 版调用核心版并传 augmentor |
model | transformModel(compiler-core/src/transforms/vModel.ts:23-165) | transformModel(compiler-dom/src/transforms/vModel.ts:20-127) | 后处理:DOM 版先调核心版,再改写 |
html | 无 | transformVHtml | DOM 新增 |
text | 无 | transformVText | DOM 新增 |
show | 无 | transformShow | DOM 新增 |
cloak | 无 | noopDirectiveTransform | DOM 新增(空实现) |
这两种"覆盖"的写法差异非常重要,因为它们代表了两种不同的扩展模式:
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", ...)(原生未知元素)。
这个设计有两个收益:
<transition>参与运行时组件逻辑(BaseTransition会拦截子节点、处理mode、appear等),而原生元素无法承载这些语义。- 树摇友好:不写
<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:651 | RCDATA 下跳过;pre 判定还额外要求 ns === HTML |
| 静态字符串化的属性白名单 | stringifyStatic.ts:190-200 | HTML / SVG / MathML 各用一套 isKnown*Attr |
| 运行时 patchProp | runtime-dom/src/patchProp.ts | SVG 走 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 行号) | 输入 | 作用域 |
|---|---|---|---|
isVoidTag | 159 / 583 | 标签名 | 开始/结束标签处理 |
isNativeTag | 792(经 isComponent) | 标签名 | tagType 判定 |
isPreTag | 580 / 664 | 标签名 + ns | 空白压缩 |
isIgnoreNewlineTag | 655 | 标签名 + ns | 首个换行剔除 |
isBuiltInComponent | 790(经 isComponent) | 标签名 | tagType 判定 |
getNamespace | 144 | 标签名 + parent + rootNs | 命名空间传播 |
decodeEntities | 127 / 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 结构不一致,产生:
- 结构错位导致的 DOM 复用错乱(vnode 把
div当成p的子节点去 patch,但真实 DOM 里它是兄弟); - 更隐蔽的是,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
值得单独拎出来讲的三点:
li被故意注释掉。源码里明确留了一行// li: new Set(["ul", "ol"])。原因推理:<li>的父元素在浏览器里其实是不受限的——<li>可以出现在<ul>、<ol>、<menu>,且只要在列表容器内即合法;更重要的是<li>与<li>的"兄弟"关系在knownInvalidParents里被处理(见下),把li放进onlyValidParents反而会因"白名单不含 ul/ol 之外的一切"而产生误报。这是一处"主动放弃规则以避免误报"的克制。dd/dt允许div作父。这看起来奇怪,其实是因为<div>在 HTML 里可以作为<dl>组的包裹(规范允许<dl>里放<div>包裹一组dt/dd)。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)
}
}
五个关键设计:
- 双侧过滤
tagType === ELEMENT:只校验"真元素 > 真元素",组件、<template>、<slot>全部跳过。 - 只检查直接父节点(
context.parent而非任意祖先):HTML 嵌套规则本质是局部规则(浏览器隐式闭合也是逐层的),检查全部祖先既慢又会误报。 - 用
SyntaxError+context.onWarn:是onWarn而非onError——非法嵌套只是警告,不阻断编译,否则会引发海量既存项目构建失败。 - 注册受
__DEV__保护(index.ts:30):DOMNodeTransforms = [transformStyle, transformTransition, ...(__DEV__ ? [validateHtmlNesting] : [])],生产构建里根本不出现,零运行时开销、零代码体积(与Transition.ts:27-38对X_TRANSITION_INVALID_CHILDREN的处理同策略)。 - 必须在
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 > div | SSR 后 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, '"')}">`
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, '"') 是必要保护,否则 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> 里的 & 必须原样保留)。而 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",不解码实体的话 {{ '&' }} 就会渲染出字面 &。
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"时的语义保真**:
:disabled="false":字面输出disabled="false"会让浏览器认为disabled存在(布尔属性只看存在与否)→ 元素被禁用,与 v-bind 语义相反,必须整条丢掉(#6568)。: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 → 不动
}
三种情况:
- 静态且等于
onclick:直接替换成onContextmenu。这是绝大多数用法,产物最简。 - 复合表达式(动态事件名):生成三元判断
(key) === "onClick" ? "onContextmenu" : (key)。只对onClick应用右键处理,其他动态事件名保留原样——因为无法确定它是 click 还是别的。 - 静态但非
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 为例) |
|---|---|---|
.once | Once | onClickOnce |
.capture | Capture | onClickCapture |
.passive | Passive | onClickPassive |
.once.capture | OnceCapture | onClickOnceCapture |
运行时 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)
})
}
三个要点:
- 在
fn上挂_withMods缓存:按modifiers.join('.')作 key 缓存包装函数,于是同一个_ctx.onClick在不同模板位置用不同修饰符会产生不同但稳定的函数引用,避免每次渲染新建函数导致子组件更新。 guard(event, modifiers)整个数组也传进去:只有exact需要它(vOn.ts:45-47)——任一系统键(ctrl/shift/alt/meta)按下却不在modifiers里时拦截。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)负责与平台无关的部分:生成modelValueprop、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读值,元素场景的modelValueprop 冗余;源码注释明确"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 看到它时会:
- 生成
_resolveDirective("model")(或对 v-model 而言,用_withDirectives包裹元素 vnode); - 把
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...)
}
把它变成矩阵:
tag | type 情况 | 选用的运行时指令 | 备注 |
|---|---|---|---|
input | 无 type | vModelText | 默认为文本 |
input | type="text" 或其它非 radio/checkbox/file | vModelText | 含 number、email、password 等 |
input | type="radio" | vModelRadio | |
input | type="checkbox" | vModelCheckbox | |
input | type="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.modifiers | created 里把绑定事件从 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 }]])
}
注意几点:
modelValueprop 被删掉了(第 ④ 步),只剩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。)
三点:
- 无表达式报错(
X_V_HTML_NO_EXPRESSION),但仍然返回innerHTML: ''(用exp || createSimpleExpression('', true)),保证产物结构稳定、不因错误而残缺。 - 有子节点时报错并清空:
node.children.length = 0是就地修改 AST。注意这里的报错信息是"v-htmlwill override element children"(errors.ts:53)——它是"警告性错误",因为浏览器的innerHTML语义本身就是覆盖。清空 children 是必要的:否则"子节点 vnode 与 innerHTML"会同时存在,patch 时行为不可预期。 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" | textContent | toDisplayString | 无 |
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.x | computed/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 / 局部变量 | count | count | 不需要 .value |
setup-ref | count | count.value | 确定是 ref |
setup-maybe-ref 读取 | count | unref(count) | 可能是普通值 |
setup-maybe-ref 赋值 | count = v | count.value = v | 假定 ref(更高效) |
setup-let 读取 | count | unref(count) | 可能是普通值 |
setup-let 赋值 | count = v | isRef(count) ? count.value = v : count | 复制右值语义 |
setup-let 自增 | count++ | isRef(count) ? count.value++ : count++ | 见下 |
props | title | __props.title | |
props-aliased | title(本地别名) | __props.origTitle | |
全局白名单(Math、console 等) | Math | Math | 不加前缀 |
| 其他 | 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
}
四种情况:
- 未识别的自由标识符(如
{{ foo }},foo不在绑定元数据、不是作用域变量、不是白名单全局)→ 改写成_ctx.foo。若它是 setup 的 const 绑定,额外标CAN_SKIP_PATCH(因为它不会变,patch 时可跳过)。 - 作用域变量(
v-for别名item)→ 不改写、不改常量类型(它是局部的,随每次渲染变化)。 - 字面量白名单(
true/false/null/this)→ 不改写,标CAN_STRINGIFY(这一点对stringifyStatic至关重要:只有CAN_STRINGIFY级及以上的表达式才能被求值进字符串)。 - 白名单全局(
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() | 跳过纯类型节点(不想进类型注解里找标识符) |
Identifier | isLocal = !!knownIds[name];isRefed = isReferencedIdentifier(...);满足 includeAll || (isRefed && !isLocal) 时回调 | 判定"是否引用、是否局部" |
ObjectProperty 且父是 ObjectPattern | node.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__ | validateBrowserExpression | processExpression(transformExpression.ts:116-119)、核心版 vOn.ts:128-135 |
| 非浏览器 | Babel parseExpression / parse | parser.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 本章要点回顾
- compiler-dom 是"平台适配层":7 个
parserOptions钩子 + 3 个 DOMnodeTransforms+ 4 个指令变换覆盖,几乎不新增解析逻辑,只做"注入 + 覆盖"。 - 三类差异:解析差异(void / pre / 命名空间 / 实体)、语义差异(
v-model分流、v-on修饰符)、优化差异(stringifyStatic)。 - 隐形契约:
constType >= CAN_STRINGIFY既是表达式改写的产物,又是静态字符串化的准入门槛——8.8 与 8.11 通过它串联。 - 判定优先级:
htmlNesting一律"白名单 → 黑名单 → 默认宽松",只报真问题,不制造误报。 - 三条剪枝线:
__BROWSER__/__DEV__/__COMPAT__决定"哪个构建包含哪些逻辑",这是理解 Vue 产物体积的一把钥匙。
至此,从"解析的 DOM 特化"到"编译期优化",本章沿着 compiler-dom 这条线把平台适配层的每个决策点都过了一遍;下一章将回到 compiler-sfc,看单文件组件如何把这些能力编排成一次完整的编译。