Vue 3 核心源码深度剖析系列(v3.5.44)-第 09 章 编译器内幕(五):compiler-sfc 的 SFC 解析与 <script setup> 编译
本章对应
vuejs/core3.5.44,主讲packages/compiler-sfc/src/中的两块核心:SFC 解析(parse.ts) 与<script setup>编译(compileScript.ts+script/目录下的全部宏实现) 。具体覆盖文件:packages/compiler-sfc/src/parse.ts、packages/compiler-sfc/src/cache.ts、packages/compiler-sfc/src/rewriteDefault.ts、packages/compiler-sfc/src/index.ts、packages/compiler-sfc/src/compileScript.ts、packages/compiler-sfc/src/script/context.ts、script/utils.ts、script/normalScript.ts、script/analyzeScriptBindings.ts、script/defineProps.ts、script/definePropsDestructure.ts、script/defineEmits.ts、script/defineModel.ts、script/defineExpose.ts、script/defineOptions.ts、script/defineSlots.ts、script/resolveType.ts、script/topLevelAwait.ts、script/importUsageCheck.ts。第 05 章(parser)、第 06 章(transform)、第 07 章(codegen + Block 树)、第 08 章(
compiler-domDOM 特化层)讲的是"给定一段模板字符串,如何编译成 render 函数 "。这一章往前再走一层:模板字符串是从哪来的?在 Webpack/Vite 里,一个.vue文件到底经历了什么,才会变成 JS 模块?这一章回答的问题是:一个
.vue文件(HTML 外壳里混着 template、script、style 三种语言、若干种lang)如何被切成结构化的SFCDescriptor?<script setup>里那些看起来像函数调用的"宏"(defineProps/defineEmits/defineModel/defineExpose/defineOptions/defineSlots),凭什么能在编译期"凭空消失"并生成运行时声明?读完这一章,你会理解一句关键判断:
compiler-sfc不是一个"编译器",而是"Vue 单文件组件格式与 Webpack/Vite 模块系统之间的一块适配与改写层"------它同时扮演解析器、宏展开器、源码改写器(借助MagicString)、类型解析器(借助 TypeScript + Babel)三重角色。
导语:从一行 import Foo from './Foo.vue' 说起
在写 Vue 3 应用时,你几乎从没想过这样一行 import 背后发生了什么:
ts
import Foo from './Foo.vue'
浏览器不认识 .vue。Node.js 不认识 .vue。Webpack/Vite 也不天生认识 .vue。真正让它变成合法 JS 模块的,是一个包装在 loader/plugin 里的 compiler-sfc。
以 Vite 为例,@vitejs/plugin-vue 在 transform 钩子里做的事情,大致是:
ts
// @vitejs/plugin-vue 伪代码(非本仓库源码,示意用)
const { descriptor, errors } = parse(code, { filename: id })
// 1) <script setup> + <script> → JS
const script = compileScript(descriptor, {
id: scopeId,
isProd,
inlineTemplate: isProd && !descriptor.styles.some(s => s.scoped),
genDefaultAs: '__sfc__',
propsDestructure: true,
templateOptions: { ... },
})
// 2) <template> → render 函数(非 inline 模式时单独编译)
const template = compileTemplate({
source: descriptor.template.content,
filename: id,
id: scopeId,
scoped: descriptor.styles.some(s => s.scoped),
compilerOptions: { bindingMetadata: script.bindings },
})
// 3) <style> → 交给 css 插件;scoped / module 用 compileStyle 处理
for (const style of descriptor.styles) {
compileStyle({ source: style.content, id: scopeId, scoped: style.scoped, ... })
}
也就是说,一个 .vue 文件在构建链里被拆成四步:
scss
parse(source) compileScript(sfc) compileTemplate(block) compileStyle(block)
│ │ │ │
▼ ▼ ▼ ▼
. ──────► SFCDescriptor ──► JS(含组件定义) ──────► render 函数字符串 ────► 带 data-v-xxx 的 CSS
│ │
└─ 结构 + loc + map └─ bindings(供模板编译用)+ 宏产物
compiler-sfc 只负责前两步(外加 compileStyle 的 CSS 处理),它不负责模块打包,也不负责把这几段产物拼成一个文件 ------那是 plugin-vue / vue-loader 的事。但正是这一个"结构定义 + 宏展开 + 类型解析"的层,撑起了 Vue 3 <script setup> 的全部开发体验。
本章的叙事逻辑是"看一个 .vue 文件如何从左到右被吃进去、再从右到左被吐出来":
- 输入侧:一段字符串 →
parse()切成SFCDescriptor(§9.2、§9.3、§9.4); - 加工侧:
compileScript()拿着 descriptor,把宏一个个"消化"掉(§9.5 ~ §9.10); - 输出侧:
<script setup>顶层代码被重新组装进setup(),export default被改写成const __sfc__(§9.11、§9.12)。
9.1 SFC 编译总览:compiler-sfc 在构建链中的位置
9.1.1 什么是 SFC,为什么要有一个格式
SFC(Single File Component,单文件组件)本质上是一个"自定义的、以 HTML 语法为外壳的多语言容器":
vue
<template>
<div :class="cls">{{ msg }}</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const msg = ref('hello')
const cls = 'box'
</script>
<style scoped>
.box { color: red; }
</style>
<i18n lang="json">{ "en": { "hello": "hello" } }</i18n>
它要解决的问题是"把同一组件的模板、逻辑、样式在物理上放在一起 "。但它的实现思路很克制:它并没有发明一门新语法 ,而是定义了一组"顶层只允许出现特定标签"的 HTML 约束。于是解析它所需的全部能力,其实已经在 compiler-core 的 parser 里了------第 05 章讲过,compiler-core 的 parser 有一个 parseMode: 'sfc' 的开关。
parse.ts:144 正是这么用的:
ts
const ast = compiler.parse(source, {
parseMode: 'sfc',
prefixIdentifiers: true,
...templateParseOptions,
onError: e => {
errors.push(e)
},
})
也就是说,SFC 解析复用了模板 parser ,只是把模式切到 'sfc':在这种模式下,parser 会把顶层的 <template> / <script> / <style> / 其他标签全部当成普通元素节点,不区分"是不是有效 HTML"、也不做指令变换,纯粹做"元素切分"。
9.1.2 四步分工:parse / compileScript / compileTemplate / compileStyle
compiler-sfc 对外的 API 面非常小(index.ts:4-9):
ts
export { parse } from './parse'
export { compileTemplate } from './compileTemplate'
export { compileStyle, compileStyleAsync } from './compileStyle'
export { compileScript } from './compileScript'
export { rewriteDefault, rewriteDefaultAST } from './rewriteDefault'
export { resolveTypeElements, inferRuntimeType } from './script/resolveType'
四步的职责边界如下表:
| 步骤 | 入口函数 | 输入 | 输出 | 是否依赖别的步骤 |
|---|---|---|---|---|
| ① 解析 | parse(source, options) |
原始 .vue 文本 |
SFCParseResult { descriptor, errors } |
否(最底层) |
| ② 脚本编译 | compileScript(sfc, options) |
上一步的 SFCDescriptor 整体 |
改写后的 SFCScriptBlock(含 content、bindings、imports、map) |
依赖 descriptor;inline 模式下内部还会调 compileTemplate |
| ③ 模板编译 | compileTemplate(options) |
descriptor.template 的 content/ast + bindings |
{ code, ast, map, errors, tips } |
需要第二步产出的 bindings 才能做 prefixIdentifiers/unref 优化 |
| ④ 样式编译 | compileStyle(options) |
descriptor.styles[i] 的 content |
加了 data-v-xxx 作用域的 CSS |
与②③独立 |
关键点 :
compileScript之所以"需要整个 descriptor 而不是单个 script 块",是因为<script>(普通脚本,可能含export default)与<script setup>(编译后要合并进同一组件定义)必须一起处理并合并 。这在compileScript.ts:163-167的注释里说得很清楚:
xmlIt requires the whole SFC descriptor because we need to handle and merge normal <script> + <script setup> if both are present.
9.1.3 两种编译模式:inline 与 non-inline
第 ② 步有一个关键选项 inlineTemplate(compileScript.ts:96-103),其 JSDoc 说明:只影响 <script setup>,且仅应用于生产------因为它会阻止模板与组件状态分开热更新:
ts
inlineTemplate?: boolean
两种模式的产物形态差异,是理解本章后半部分(§9.11、§9.12)的主线:
- non-inline :
compileScript只生成setup(),return一个把所有顶层绑定暴露给模板的"绑定对象",并打上不可枚举的__isScriptSetup: true标记;compileTemplate单独把模板编译成一个render函数,通过bindingMetadata决定哪些绑定要unref()、哪些直接读。 - inline :
compileScript内部直接调compileTemplate({ ..., compilerOptions: { inline: true, bindingMetadata } }),把 render 函数内联进setup()的return。模板不再单独热更新(所以只适合生产)。
9.1.4 为什么 compiler-sfc 要单独成包
一个常见的疑问是:为什么不把这些逻辑塞进 @vue/compiler-dom?答案是依赖方向 。compiler-sfc 需要依赖:
@babel/parser(解析<script>里的 JS/TS)------这是compiler-core都不需要的重依赖;typescript作为 peerDependency(解析 TS 类型、模块解析)------只在 Node 端可用;magic-string(做"精确到字符的改写"而非重新生成代码);postcss(compileStyle处理 CSS);source-map-js(生成/合并 source map)。
这些都是"构建工具侧"的依赖,绝不该进入浏览器运行时的依赖图。所以 compiler-sfc 是独立包,且其 package.json 把 typescript 声明为 optional peer ------只有在"类型化 props/emits 需要跨文件解析类型"时才会 require('typescript')(见 resolveType.ts:998-1009 的按需 loadTS())。
易错点 :在浏览器里用
compiler-sfc(例如 Vue SFC Playground、@vue/repl)时,非相对路径的类型 import(如import type { Foo } from 'some-lib')无法解析 ,因为模块解析需要 TypeScript 和文件系统。resolveType.ts:991-997对__CJS__之外的环境直接报错:
pythonType import from non-relative sources is not supported in the browser build.这也是为什么
options.fs允许被注入(compileScript.ts:128-137),供 REPL 用虚拟文件系统替代真实 fs。
9.2 parse 阶段:SFCDescriptor 如何被切出来
9.2.1 SFCBlock 与 SFCDescriptor 数据结构
parse.ts:35-96 定义了一组分层的数据结构。先看基类 SFCBlock:
ts
export interface SFCBlock {
type: string
content: string
attrs: Record<string, string | true>
loc: SourceLocation
map?: RawSourceMap
lang?: string
src?: string
}
然后是三个特化的块类型(parse.ts:45-70)与顶层容器 SFCDescriptor(parse.ts:72-96):
ts
export interface SFCTemplateBlock extends SFCBlock { type: 'template'; ast?: RootNode }
export interface SFCScriptBlock extends SFCBlock {
type: 'script'
setup?: string | boolean // <script setup> 标志
bindings?: BindingMetadata // 模板编译用的绑定元数据
imports?: Record<string, ImportBinding>
scriptAst?: import('@babel/types').Statement[]
scriptSetupAst?: import('@babel/types').Statement[]
warnings?: string[]
deps?: string[] // 类型依赖文件路径(HMR 用)
}
export interface SFCStyleBlock extends SFCBlock {
type: 'style'
scoped?: boolean
module?: string | boolean
}
export interface SFCDescriptor {
filename: string
source: string
template: SFCTemplateBlock | null
script: SFCScriptBlock | null
scriptSetup: SFCScriptBlock | null
styles: SFCStyleBlock[]
customBlocks: SFCBlock[]
cssVars: string[]
slotted: boolean
shouldForceReload: (prevImports: Record<string, ImportBinding>) => boolean
}
逐字段说明:
| 字段 | 类型 | 含义 |
|---|---|---|
filename |
string |
文件名。默认 'anonymous.vue'(parse.ts:23 的 DEFAULT_FILENAME) |
source |
string |
原始 .vue 全文。所有块的内容都指向同一份 source 的切片 |
template |
`SFCTemplateBlock | null` |
script |
`SFCScriptBlock | null` |
scriptSetup |
`SFCScriptBlock | null` |
styles |
SFCStyleBlock[] |
允许多个 <style>(数组) |
customBlocks |
SFCBlock[] |
其他顶层标签,如 <i18n>、<docs> |
cssVars |
string[] |
所有 v-bind() in <style> 里用到的变量名(parseCssVars 产出) |
slotted |
boolean |
是否使用了 :slotted()(compiler 的优化提示) |
shouldForceReload |
(prev) => boolean |
HMR 判定:模板变化是否会导致 TS 未使用 import 的裁剪结果变化 |
注意三个关键设计:
script与scriptSetup是分开的两个字段 ,而不是一个"脚本列表"。这是为了后续compileScript能分别处理"普通脚本提供export default选项"与"<script setup>提供组件主体"两条流水线。styles是数组,template/script/scriptSetup是单值 。SFC 规范允许一个template、一个script、一个script setup、多个style、多个自定义块。content是source的一段切片 (parse.ts:324:content: source.slice(loc.start.offset, loc.end.offset)),不是独立字符串。这保证了后续所有 offset 计算都能回到全局source。
9.2.2 parse() 主流程:从 sourceKey 到 descriptor
parse() 的完整流程(parse.ts:107-299):
bash
parse(source, options)
├─ ① 算缓存键 sourceKey = genCacheKey(source, { ...options, compiler: { parse } })
├─ ② 命中 parseCache?→ 直接返回(§9.4)
├─ ③ 解出默认选项:sourceMap=true, filename='anonymous.vue', pad=false, ignoreEmpty=true, compiler=CompilerDOM
├─ ④ 构造空 descriptor(含 shouldForceReload 闭包)
├─ ⑤ compiler.parse(source, { parseMode:'sfc', prefixIdentifiers:true, onError })
├─ ⑥ 遍历 ast.children,按 node.tag 分派到 template/script/style/customBlocks
├─ ⑦ 校验:至少一个 template 或 script;script setup 不能带 src 等
├─ ⑧ pug/jade 模板去缩进 + 记录列偏移
├─ ⑨ 若 sourceMap:为每个块 generateSourceMap
├─ ⑩ cssVars = parseCssVars(descriptor)
├─ ⑪ slotted = 是否有 scoped + :slotted()
└─ ⑫ parseCache.set(sourceKey, result); return result
其中第 ⑥ 步是最核心的分派逻辑(parse.ts:152-225),下面逐 tag 展开。
9.2.3 源码切分算法:如何定位 <template> / <script setup>
分派循环(parse.ts:152-225)先做一次"空节点过滤":
ts
ast.children.forEach(node => {
if (node.type !== NodeTypes.ELEMENT) {
return
}
// we only want to keep the nodes that are not empty
// (when the tag is not a template)
if (
ignoreEmpty &&
node.tag !== 'template' &&
isEmpty(node) &&
!hasSrc(node)
) {
return
}
switch (node.tag) {
...
}
})
这里有个容易忽略的细节:只有"非 <template> 标签"才会被空过滤 (node.tag !== 'template' 是排除条件)。原因是 <template> 块允许为空(甚至 src 外部模板也允许),而一个空的 <style></style> 或 <script></script> 没有任何意义,直接丢弃。isEmpty(parse.ts:420-428)的定义是"所有子节点都是空白文本"。
四个分支:
分支 1:<template>(parse.ts:167-195)。要点:
- 首个
<template>被接受,内联地从node.children直接createRoot()出ast(复用 parser 已经解析好的子节点,不重复解析); - 若
attrs.src存在(外部模板),则不生成ast(因为内容在另一个文件里,本次 parse 拿不到); - 保留 Vue 2 的
<template functional>报错(Vue 3 已移除 functional 的性能优势); - 第二个
<template>→ 重复块错误。 pad传false:模板块的content不做行号对齐填充(仅 script 需要,见 §9.2.5)。
分支 2:<script> / <script setup>(parse.ts:196-208) :以 !!attrs.setup 分派------"有 setup 属性 → 进 scriptSetup 槽位;无 → 进 script 槽位;同槽位第二次出现才报错 "。注意 script 分支传了 pad,因为脚本块需要行号对齐(见下)。
分支 3:<style>(parse.ts:209-220) :createBlock(node, source, pad),若带 vars 属性报废弃提案错误(RFC 231 已被 v-bind() in CSS 取代),否则 descriptor.styles.push(styleBlock)。样式块可多个,全部 push。
分支 4:自定义块(parse.ts:221-223) :default 分支一律 descriptor.customBlocks.push(createBlock(node, source, pad))。任何非 template/script/style 的顶层标签(<i18n>、<docs>、<route> 等)统一进 customBlocks。
9.2.4 createBlock:块属性解析
createBlock(parse.ts:314-351)把元素节点转成 SFCBlock:
ts
const loc = node.innerLoc! // ← innerLoc,不含起止标签本身
const block: SFCBlock = {
type: node.tag,
content: source.slice(loc.start.offset, loc.end.offset),
loc,
attrs,
}
if (pad) block.content = padContent(source, block, pad) + block.content
node.props.forEach(p => { // 仅处理 ATTRIBUTE 型属性
attrs[p.name] = p.value ? p.value.content || true : true
if (p.name === 'lang') block.lang = p.value && p.value.content
else if (p.name === 'src') block.src = p.value && p.value.content
else if (type === 'style') { // scoped / module
} else if (type === 'script' && p.name === 'setup') {
(block as SFCScriptBlock).setup = attrs.setup
}
})
几个关键点:
loc = node.innerLoc:innerLoc是<template>标签内部 的内容范围(即>之后到</template>之前),而非整个元素的loc。这保证了content不含外层标签本身。attrs的值为string | true:布尔属性(如setup、scoped)值为true;有值属性(如lang="ts")为其字符串内容。p.value.content || true处理了lang=""(空字符串 →true)。- 属性特判(按块类型分派):
| 属性 | 适用块 | 落到哪个字段 | 语义 |
|---|---|---|---|
lang |
所有块 | block.lang |
子语言,如 ts/tsx/jsx/scss/pug |
src |
所有块 | block.src |
外部文件路径(内容不在本 SFC 里) |
scoped |
<style> |
SFCStyleBlock.scoped |
作用域样式 |
module |
<style> |
SFCStyleBlock.module |
CSS Modules,值可能是字符串(自定义类名)或 true |
setup |
<script> |
SFCScriptBlock.setup |
<script setup> 标志 |
易错点 :
<script setup>的判定完全依赖attrs.setup(parse.ts:198)。写成<script setup="false">依然是<script setup>,因为只要有setup属性就是true。这符合 HTML 布尔属性语义。
9.2.5 pad 与 padContent:为什么 script 块要"补行"
pad 选项(parse.ts:28:pad?: boolean | 'line' | 'space')的目的是让块的 content 行号与原始 .vue 文件的行号对齐,从而让 source map、报错行号都指向原文件。
padContent(parse.ts:392-405):
ts
function padContent(content: string, block: SFCBlock, pad): string {
content = content.slice(0, block.loc.start.offset) // 取"块开始之前"的全部原文
if (pad === 'space') {
return content.replace(replaceRE, ' ') // 每个字符换成空格(列也对齐)
} else {
const offset = content.split(splitRE).length // 前面有多少行 + 1
const padChar = block.type === 'script' && !block.lang ? '//\n' : '\n'
return Array(offset).join(padChar) // 用 (offset-1) 个换行补齐
}
}
它的做法非常朴素:取块开始位置之前的全部源码,数出有多少行,然后在块内容前面塞同样数量的换行符 。于是 content 的第 5 行就对应原文件第 5 行。
其中 pad='line'(等价于 true)时,padChar 有一个特判:没有 lang 的 <script> 块用 '//\n'(注释换行),其余用 '\n' 。原因是纯 JS 的 <script> 内容会被直接当 JS 解析,前面补一堆裸换行没问题,但如果是脚本块又要拼接 __default__ 相关代码时,用 // 注释能避免"两行 JS 拼接后语义变化"(例如上一行以 return 结尾)的极端情况。
实际调用中,
plugin-vue/vue-loader通常传pad: 'line',并且compileScript里的generateSourceMap(parse.ts:273)会据此计算lineOffset:
tsblock.map = generateSourceMap( filename, source, block.content, sourceRoot, !pad || block.type === 'template' ? block.loc.start.line - 1 : 0, columnOffset, )若用了
pad,则 script/style 块的行偏移为0(因为内容已经被补齐到与原文行号一致);若没用pad,则行偏移为block.loc.start.line - 1(手动加偏移把生成行映射回原文行)。模板块永远用后者,因为模板块不 pad。
9.2.6 resolveParserPlugins:按 lang 选 Babel 插件
Babel 解析 <script> 时用哪些插件,由 resolveParserPlugins(script/context.ts:167-207)决定:默认追加 importAttributes;lang ∈ {jsx,tsx,mtsx} 时加 jsx 并从用户插件中剔除同名项(避免与泛型/类型断言冲突);lang 为 TS 系(ts/mts/tsx/cts/mtsx)时加 ['typescript', { dts }] 与 explicitResourceManagement,用户未指定 decorators 时再补 decorators-legacy;最后追加用户 userPlugins。规则总结:
| 条件 | 加入的插件 |
|---|---|
| 用户未显式指定 importAttributes | 'importAttributes' |
lang ∈ {jsx, tsx, mtsx} |
'jsx'(同时从用户插件里剔除 'jsx' 避免重复) |
lang ∈ {ts, mts, tsx, cts, mtsx} |
['typescript', { dts }]、'explicitResourceManagement' |
同上且用户未指定 decorators |
'decorators-legacy' |
| 其他 | 追加用户 userPlugins |
设计动机值得强调:
jsx与 TS 的互斥处理 :JSX 在.vue里只在lang="jsx"/"tsx"时启用。若用户 plugins 里带了jsx但lang不是 JSX 系,主动剔除,否则 JS/TS 的泛型/类型断言会和 JSX 语法冲突。decorators-legacy兜底 :TS 场景默认开 legacy 装饰器(@Component风格),但如果用户显式给了decorators就尊重用户。explicitResourceManagement:支持using/await using语法(TS 5.2+)。
这个函数在两处被调用:
ScriptCompileContext构造函数里解析脚本(context.ts:103-106);rewriteDefault里解析脚本(rewriteDefault.ts:14)------虽然那里固定用resolveParserPlugins('js', parserPlugins),因为rewriteDefault只处理已编译的输出。
9.2.7 parse() 的后置校验
分派完成后,parse 做几项校验:
至少一个 template 或 script(parse.ts:226-232) :若 !descriptor.template && !descriptor.script && !descriptor.scriptSetup,则 push 一条 SyntaxError(At least one <template> or <script> is required in a single file component.)。注意只有 <style> 或其他自定义块的 SFC 是非法的。
src 与 <script setup> 的互斥(parse.ts:233-252) :若 <script setup> 带 src → 报错并把 descriptor.scriptSetup 置 null;若同时存在 <script setup> 而普通 <script> 带 src → 也报错并把 descriptor.script 置 null。即:<script setup src="./x.js"> 直接非法;若同时有 <script setup>,则普通 <script src> 也非法(因为它们需要一起处理,而外部文件无法保证这一点)。注意非法时把对应块置 null,这样后续阶段看到的是"没有该块",避免级联错误。
pug/jade 去缩进(parse.ts:254-263) :当模板 lang 为 pug/jade 时执行 [descriptor.template.content, templateColumnOffset] = dedent(descriptor.template.content)。dedent(parse.ts:465-485)计算所有非空行的最小缩进并统一去掉,返回 [新内容, 被去掉的缩进量]。被去掉的缩进量作为 columnOffset 传给模板的 source map 生成(parse.ts:278),保证 map 仍然对得上原文件。
CSS 变量与 :slotted(parse.ts:284-291) :descriptor.cssVars = parseCssVars(descriptor);descriptor.slotted = descriptor.styles.some(s => s.scoped && /(?:::v-|:)slotted\(/.test(s.content))。parseCssVars 从所有 <style> 里正则抓出 v-bind(...) 的变量路径(详见 style/cssVars.ts);slotted 检查每个 scoped 样式里是否用了 :slotted(),作为"是否需要深比较"的编译优化提示。
9.2.8 重复块错误与 createDuplicateBlockError
createDuplicateBlockError(parse.ts:301-312):
ts
function createDuplicateBlockError(node: ElementNode, isScriptSetup = false): CompilerError {
const err = new SyntaxError(
`Single file component can contain only one <${node.tag}${isScriptSetup ? ` setup` : ``}> element`
) as CompilerError
err.loc = node.loc
return err
}
注意它给 err.loc 赋的是整个元素的 loc(不是 innerLoc),这样报错能高亮整段重复标签。
9.3 源码 map 与 offset:块边界如何映射回原文
9.3.1 SourceLocation 的 start/end 与 offset
compiler-core 的 parser 给每个节点产出 SourceLocation(第 05 章讲过结构),核心字段是:
ts
interface SourceLocation {
start: Position // { offset, line, column }
end: Position
source: string
}
对 .vue 而言,offset 是相对整个 .vue 文件的绝对字符偏移 。createBlock 取的是 node.innerLoc(parse.ts:320),所以:
swift
source: "<template>\n <div/>\n</template>\n<script setup>\nconst a=1\n</script>"
↑ innerLoc.start.offset = 11 ↑ innerLoc.end.offset = 22
descriptor.template.loc.start.offset = 11
descriptor.template.loc.end.offset = 22
descriptor.template.content = " <div/>\n" // source.slice(11, 22)
于是 block.content.length === loc.end.offset - loc.start.offset 恒成立(除 pad 与 pug dedent 两个例外,它们会改写 content)。
9.3.2 startOffset / endOffset:compileScript 改写时的基准
compileScript 全程使用 MagicString 对整份 source 做就地改写。为了保证从 Babel AST 拿到的相对偏移能映射回整份 source,它引入了两个基准量(compileScript.ts:227-230):
ts
const startOffset = ctx.startOffset! // = scriptSetup.loc.start.offset
const endOffset = ctx.endOffset! // = scriptSetup.loc.end.offset
const scriptStartOffset = script && script.loc.start.offset
const scriptEndOffset = script && script.loc.end.offset
(context.ts:25-27 即:startOffset/endOffset 分别取 descriptor.scriptSetup.loc.start.offset 与 loc.end.offset。)
关键理解:Babel 解析的是 <script setup> 块的 content 字符串 (context.ts:130-132:parse(descriptor.scriptSetup!.content, this.startOffset!))。所以 Babel AST 里的 node.start 是相对 content 的偏移 (从 0 开始),要加 startOffset 才能变成相对整份 source 的偏移。
因此 compileScript 里到处是这种换算:
ts
ctx.s.overwrite(node.start! + startOffset, node.end! + startOffset, `...`) // compileScript.ts:532
ctx.s.remove(node.start! + startOffset, node.end! + startOffset) // compileScript.ts:390
而处理普通 <script> 时用 scriptStartOffset:
ts
const start = node.start! + scriptStartOffset! // compileScript.ts:445
设计动机 :为什么不干脆让 Babel 解析整份 source?因为
<script setup>里不能出现 HTML 标签,但如果解析整份.vue,Babel 会在<template>处语法爆炸。用"块内容 + offset 平移"是成本最低的做法,也顺带让getString(context.ts:135-140)能直接从 block.content 切片得到节点源码。
9.3.3 getString:从块内容切片取源码
context.ts:135-140:
ts
getString(node: Node, scriptSetup = true): string {
const block = scriptSetup ? this.descriptor.scriptSetup! : this.descriptor.script!
return block.content.slice(node.start!, node.end!)
}
用途:genRuntimeProps 里把 defineProps({...}) 的第一个参数原样字符串 取出来(defineProps.ts:145),extractRuntimeProps 里取默认值对象(defineProps.ts:205),defineModel 里取 options(defineModel.ts:49)等。这些地方不需要重新生成代码,只需要"复述用户写的字面量"------直接切片最省事、也最不容易失真。
9.3.4 generateSourceMap:逐字符生成 + 只映射非空白
parse.ts:357-390:
ts
const map = new SourceMapGenerator({ file, sourceRoot }) as unknown as CodegenSourceMapGenerator
map.setSourceContent(filename, source)
map._sources.add(filename) // 直接操作内部集合(性能取巧)
generated.split(splitRE).forEach((line, index) => {
if (emptyRE.test(line)) return // emptyRE = /^(?:\/\/)?\s*$/,跳过空行与 `//` 填充行
const originalLine = index + 1 + lineOffset
const generatedLine = index + 1
for (let i = 0; i < line.length; i++) {
if (/\s/.test(line[i])) continue // 只对非空白字符加 mapping(省体积)
map._mappings.add({
originalLine, generatedLine,
originalColumn: i + columnOffset, generatedColumn: i,
source: filename, name: null,
})
}
})
return map.toJSON()
算法要点:
emptyRE = /^(?:\/\/)?\s*$/:忽略"空行"和"只有//的行"------这正是pad补出来的//\n填充行,它们本就不对应原文的任何 token。- 逐字符遍历,只对非空白字符加 mapping:白空格不产生映射(省体积)。
originalLine = index + 1 + lineOffset:把生成行(相对块内容)平移为原文行;lineOffset按 §9.2.5 的规则取值。originalColumn = i + columnOffset:列偏移用于 pug dedent 的补偿。- 直接操作
map._mappings与map._sources:这是对source-map-js的性能取巧------绕过其addMapping的封装,直接写内部集合。因此类型上要as unknown as CodegenSourceMapGenerator(compiler-core里定义了一个内部结构类型)。
这个 map 会被挂到 block.map 上,供 compileScript / compileTemplate 做"再映射"(§9.11、§9.12)。
9.3.5 mergeSourceMaps:inline 模式下两张 map 的合成
compileScript 在 inline 模式下既要映射"脚本改写",又要映射"内联进来的 render 函数"。mergeSourceMaps(compileScript.ts:1380-1417):
ts
export function mergeSourceMaps(scriptMap, templateMap, templateLineOffset): RawSourceMap {
const generator = new SourceMapGenerator()
const addMapping = (map: RawSourceMap, lineOffset = 0) => {
const consumer = new SourceMapConsumer(map)
;(consumer as any).sources.forEach((f: string) => { // 迁移 sources 与源码内容
;(generator as any)._sources.add(f)
const c = consumer.sourceContentFor(f)
if (c != null) generator.setSourceContent(f, c)
})
consumer.eachMapping(m => {
if (m.originalLine == null) return
generator.addMapping({
generated: { line: m.generatedLine + lineOffset, column: m.generatedColumn },
original: { line: m.originalLine, column: m.originalColumn! },
source: m.source, name: m.name,
})
})
}
addMapping(scriptMap)
addMapping(templateMap, templateLineOffset) // 模板 map 的生成行整体下移
;(generator as any)._sourceRoot = scriptMap.sourceRoot
;(generator as any)._file = scriptMap.file
return (generator as any).toJSON()
}
调用点(compileScript.ts:1110-1115):
ts
if (templateMap && map) {
const offset = content.indexOf(returned)
const templateLineOffset = content.slice(0, offset).split(/\r?\n/).length - 1
map = mergeSourceMaps(map, templateMap, templateLineOffset)
}
它先定位内联 render 代码在最终 content 里的起始位置,算出它前面有多少行(templateLineOffset),再把模板 map 的所有生成行整体下移 这个行数,与脚本 map 合并。这样浏览器 devtools 里点到 render 代码,依然能跳回原 .vue 的模板部分。
9.4 cache.ts:为什么 parse 结果要缓存
9.4.1 createCache 的实现:LRU vs 普通 Map
cache.ts 全文只有 11 行:
ts
import { LRUCache } from 'lru-cache'
export function createCache<T extends {}>(max = 500): Map<string, T> | LRUCache<string, T> {
/* v8 ignore next 3 */
if (__GLOBAL__ || __ESM_BROWSER__) {
return new Map<string, T>()
}
return new LRUCache({ max })
}
逻辑极其简单,但每个分支都有理由:
__GLOBAL__ || __ESM_BROWSER__(浏览器/UMD 构建):返回普通Map。因为浏览器里没有长期驻留进程的概念,也不希望引入lru-cache的体积和"淘汰"复杂度;内存上限交给浏览器本身。- Node/构建态 :返回
LRUCache({ max: 500 })。默认最多缓存 500 条,超过就按 LRU 淘汰。
lru-cache 是一个"纯 JS、无依赖"的实现,被 Vue 用于多处缓存(也用于 resolveType 的类型解析缓存)。
9.4.2 缓存键:genCacheKey
parse.ts:103-118:
ts
export const parseCache: Map<string, SFCParseResult> | LRUCache<string, SFCParseResult> =
createCache<SFCParseResult>()
export function parse(source: string, options: SFCParseOptions = {}): SFCParseResult {
const sourceKey = genCacheKey(source, {
...options,
compiler: { parse: options.compiler?.parse },
})
const cache = parseCache.get(sourceKey)
if (cache) {
return cache
}
// ... 实际解析 ...
parseCache.set(sourceKey, result)
return result
}
genCacheKey 来自 @vue/shared,是一个"哈希去重 "函数------不返回哈希值,而是返回输入对象的 interned 字符串 (相同内容返回同一个对象引用,本质上是一个基于字符串的驻留/去重)。它的作用是:只要 (source, options) 的组合一致,sourceKey 就相等,从而命中缓存。
注意这里把 options.compiler 归一化成了 { parse: options.compiler?.parse } :因为 compiler 里可能有一堆函数(nodeTransforms 等),直接序列化不稳定;只保留 parse 这一个函数引用作为"编译器身份"。也就是说,同一个 source 若换了一个不同实现的 parser,缓存键会不同(函数引用不同 → interned key 不同)。
9.4.3 为什么首次解析昂贵
parse 的成本主要在 compiler.parse(source, { parseMode: 'sfc' })------也就是跑一遍完整的模板 parser (第 05 章讲过的 tokenizer + AST 构造,含源码位置、属性解析、指令解析的完整流程)。对一个几千行的 .vue 来说,这一步是毫秒级,但在一次构建里,同一份 SFC 可能被反复 parse:
- 开发态 HMR:每次文件变化都 parse,但未变化的依赖 (例如
.vue里 import 的另一个.vue)由缓存兜住; plugin-vue/vue-loader在一次构建里可能对同一模块调用多次 parse(例如resolveId/load/transform阶段各自需要 descriptor);@vue/compiler-sfc自身在解析类型 import 时也会递归调用parse(resolveType.ts:40:import { parse } from '../parse'),用于读取被 import 的.vue文件的类型。
因此 parse 缓存的收益不是"解析更快",而是"避免重复解析同一文件"。
9.4.4 缓存失效与 deps
parse 缓存的键里包含完整的 source 字符串,所以只要文件内容变了,键就变了,自然失效------不需要任何显式 invalidation 逻辑。这比"按文件路径缓存 + 时间戳判断"更安全(不会出现"内容变了但没检测到")。
但对类型解析 (§9.10)而言,情况更复杂:defineProps<T>() 的 T 可能来自另一文件 import type { Props } from './types'。当被 import 的文件内容变化时,当前 SFC 的 compileScript 结果应当失效。compiler-sfc 用 deps 来解决:
SFCScriptBlock.deps(parse.ts:58-63)注释说明其用途: Fully resolved dependency file paths (unix slashes) with imported types used in macros, used for HMR cache busting in @vitejs/plugin-vue and vue-loader.resolveType在解析跨文件类型时,通过recordScopeDep(resolveType.ts:145-152)把被依赖文件的路径加入ctx.deps:
ts
function recordScopeDep(ctx: TypeResolveContext, scope: TypeScope | undefined): void {
if (scope && scope.filename !== ctx.filename) {
;(ctx.deps || (ctx.deps = new Set())).add(scope.filename)
}
}
- 最终
compileScript把ctx.deps透传到SFCScriptBlock.deps(compileScript.ts:1124:deps: ctx.deps ? [...ctx.deps] : undefined)。 plugin-vue拿到deps后,把这些文件加进 Vite 的moduleGraph/watch列表,从而在被依赖文件变化时触发当前 SFC 重新编译。
此外,resolveType 内部还有一层模块解析缓存 (createCache 创建的 ts 相关缓存、以及 invalidateTypeCache------index.ts:43 导出)。当配置文件(tsconfig.json)变化时,plugin-vue 会调用 invalidateTypeCache 清空它。
易错点 :
parseCache是全局共享 的。若在同一个进程里先后用不同的SFCParseOptions(如pad不同)解析同样 source,会得到两份不同缓存(键不同),但若你自己持有旧的descriptor引用并修改它,会污染别人拿到的同一份缓存对象。所以descriptor应被视为只读。
9.5 compileScript 总览:ScriptCompileContext 全字段与十一步流程
9.5.1 ScriptCompileContext:所有中间状态的容器
script/context.ts:14-81 定义了 ScriptCompileContext------它比 SFCDescriptor 多了"编译中间态"。逐组罗列:
ts
export class ScriptCompileContext {
isJS: boolean; isTS: boolean; isCE = false
scriptAst: Program | null // 普通 <script> 的 AST
scriptSetupAst: Program | null // <script setup> 的 AST
s: MagicString = new MagicString(this.source)
startOffset = this.descriptor.scriptSetup?.loc.start.offset
endOffset = this.descriptor.scriptSetup?.loc.end.offset
scope?: TypeScope // 本地类型作用域
globalScopes?: TypeScope[] // 全局类型文件作用域
userImports: Record<string, ImportBinding> = Object.create(null)
hasDefinePropsCall = false // ↓ 8 个宏存在性标记,用于"重复调用"检测
hasDefineEmitCall = false; hasDefineExposeCall = false
hasDefaultExportName = false; hasDefaultExportRender = false
hasDefineOptionsCall = false; hasDefineSlotsCall = false; hasDefineModelCall = false
// defineProps:见 §9.6
propsCall / propsDecl / propsRuntimeDecl / propsTypeDecl
propsDestructureDecl / propsDestructuredBindings
propsDestructureRestId / propsRuntimeDefaults
// defineEmits:见 §9.7
emitsRuntimeDecl / emitsTypeDecl / emitDecl
// defineModel / defineOptions:见 §9.8 / §9.9
modelDecls: Record<string, ModelDecl> = Object.create(null)
optionsRuntimeDecl
// codegen
bindingMetadata: BindingMetadata = {}
helperImports: Set<string> = new Set()
helper(key: string): string { this.helperImports.add(key); return `_${key}` }
deps?: Set<string> // 类型依赖文件集合
fs?: NonNullable<SFCScriptCompileOptions['fs']>
}
分组解读:
| 组 | 字段 | 作用 |
|---|---|---|
| 语言判定 | isJS / isTS / isCE |
isJS=有无 js/jsx;isTS=有无 ts/tsx;isCE=是否按自定义元素编译(影响 defineComponent 与 props 生成) |
| AST | scriptAst / scriptSetupAst |
普通脚本与 setup 脚本的 Babel Program |
| 改写载体 | s / startOffset / endOffset |
MagicString 实例 + 两个偏移基准 |
| 类型 | scope / globalScopes |
TypeScope(本地类型作用域)+ 全局类型文件作用域 |
| import | userImports |
所有 import 绑定(含来自普通 script 的) |
| 宏存在性 | hasDefine*Call 系列 |
用于"重复调用"检测与是否生成对应运行时选项 |
| defineProps | propsCall/propsDecl/propsRuntimeDecl/propsTypeDecl/propsDestructure*/propsRuntimeDefaults |
见 §9.6 |
| defineEmits | emitsRuntimeDecl/emitsTypeDecl/emitDecl |
见 §9.7 |
| defineModel | modelDecls |
见 §9.8 |
| defineOptions | optionsRuntimeDecl |
见 §9.9 |
| codegen | bindingMetadata / helperImports / helper() |
绑定元数据 + 需要注入的 Vue helper(_mergeDefaults 等) |
| 输出 | deps / fs |
类型依赖文件集合 / 虚拟文件系统 |
helper() 是一个小巧但重要的方法:调用 ctx.helper('mergeDefaults') 会把 'mergeDefaults' 记进 helperImports,并返回 '_mergeDefaults' 。这样所有生成的代码里对 helper 的引用都是带下划线别名的(避免与用户变量冲突),最后在第 11 步统一 import { mergeDefaults as _mergeDefaults, ... } from 'vue'(compileScript.ts:1086-1098)。
9.5.2 构造阶段:解析脚本
构造函数(context.ts:83-133)做三件事:
- 判定语言 (
context.ts:91-92):
ts
this.isJS = isJS(scriptLang, scriptSetupLang)
this.isTS = isTS(scriptLang, scriptSetupLang)
其中 isJS/isTS(utils.ts:141-144)是"任一 lang 命中"的 some 判定。
-
判定自定义元素 (
context.ts:94-101):customElement可为boolean或(filename) => boolean。 -
解析两个脚本块 (
context.ts:102-132):先算 plugins,再定义局部parse(input, offset),分别解析script.content与scriptSetup.content。
局部 parse 有一个贴心的错误包装(context.ts:108-124):
ts
function parse(input: string, offset: number): Program {
try {
return babelParse(input, { plugins, sourceType: 'module' }).program
} catch (e: any) {
e.message = `[vue/compiler-sfc] ${e.message}\n\n${descriptor.filename}\n${generateCodeFrame(
descriptor.source, e.pos + offset, e.pos + offset + 1,
)}`
throw e
}
}
它把 Babel 的报错位置 e.pos 加上块偏移 ,用 generateCodeFrame 生成指向原始 .vue 文件 的代码框。这就是为什么 <script setup lang="ts"> 里写错语法时,报错能直接指向 .vue 的行。
9.5.3 compileScript 的十一步流程
compileScript(compileScript.ts:168-1126)是本章的"主干"。它用数字注释把流程切成 11 段(含子步骤)。完整清单:
| 步骤 | 行号 | 内容 |
|---|---|---|
| 前置 | 180-214 | 取块、算 hoistStatic/scopeId,校验同一 lang,处理"只有普通 script"的情形 |
| 1.1 | 302-322 | 遍历普通 <script> 的 import,登记 userImports |
| 1.2 | 324-393 | 遍历 <script setup> 的 import:提升到顶部、去重、剔除宏 import |
| 1.3 | 395-400 | 收集 vue 的 import 别名(ref/reactive 被 alias 成什么) |
| 2.1 | 402-519 | 处理普通 <script> 体:export default → const __default__ = ...;记录 scriptBindings |
| 2.2 | 521-708 | 处理 <script setup> 体:宏调用收集、defineModel、绑定记录、顶层 await、TS 提升 |
| 3 | 710-713 | props 解构转换(transformDestructuredProps) |
| 4 | 715-726 | 校验宏参数不引用 setup 作用域变量 |
| 5 | 728-745 | 删除非脚本内容(把 source 里除脚本外的部分 remove 掉) |
| 6 | 747-820 | 分析 bindingMetadata;6.1 针对 v-model 把 reactive const 降级为 let |
| 7 | 822-839 | 注入 useCssVars 调用(处理 v-bind() in CSS) |
| 8 | 841-890 | 定稿 setup(__props, { expose, emit }) 签名;注入 props 解构 rest proxy |
| 9 | 892-1011 | 生成 return 语句(非 inline 返回绑定对象;inline 返回 render) |
| 10 | 1013-1084 | 定稿默认导出(export default { ... } / __default__ 合并 / defineComponent 包装) |
| 11 | 1086-1098 | 注入 Vue helper import;生成 source map 并返回 |
下面几节会把 2.2(宏)、3、6、8、9、10 逐步展开。
9.5.4 MACROS 常量与"宏不再需要 import"
compileScript.ts:153-161:
ts
const MACROS = [
DEFINE_PROPS, // 'defineProps'
DEFINE_EMITS, // 'defineEmits'
DEFINE_EXPOSE, // 'defineExpose'
DEFINE_OPTIONS, // 'defineOptions'
DEFINE_SLOTS, // 'defineSlots'
DEFINE_MODEL, // 'defineModel'
WITH_DEFAULTS, // 'withDefaults'
]
这个清单在两个地方使用:
- 剔除 import(
compileScript.ts:353-365) :当<script setup>里写import { defineProps } from 'vue'时,编译器会把该 specifier 删掉:
ts
if (source === 'vue' && MACROS.includes(imported)) {
if (local === imported) {
warnOnce(`\`${imported}\` is a compiler macro and no longer needs to be imported.`)
} else {
ctx.error(`\`${imported}\` is a compiler macro and cannot be aliased to a different name.`, specifier)
}
removeSpecifier(i)
}
注意:宏可以 import,但不必 import ;可以写
import { defineProps } from 'vue'(会被删),但不能 alias (import { defineProps as dp }报错)。Vue 3.3 之前允许 alias,3.3 起收紧。
- 宏调用检测 :
processDefineProps等通过isCallOf(node, DEFINE_PROPS)判断------只认裸标识符调用 。也就是说const { defineProps } = foo后调用defineProps()不会被识别为宏(因为宏识别发生在 AST 层,不依赖运行时值)。这是宏实现的一个根本约束。
9.5.5 isTS / defineCall 检测与 checkInvalidScopeReference
isTS 的作用 贯穿始终:决定是否用 defineComponent(保留类型)、是否把 TS 类型声明提升到 setup 外、是否在注入临时变量时加 : any 等。
宏调用检测 (compileScript.ts:522-543)------遍历 <script setup> 顶层语句:
ts
for (const node of scriptSetupAst.body) {
if (node.type === 'ExpressionStatement') {
const expr = unwrapTSNode(node.expression)
if (
processDefineProps(ctx, expr) ||
processDefineEmits(ctx, expr) ||
processDefineOptions(ctx, expr) ||
processDefineSlots(ctx, expr)
) {
ctx.s.remove(node.start! + startOffset, node.end! + startOffset)
} else if (processDefineExpose(ctx, expr)) {
const callee = (expr as CallExpression).callee
ctx.s.overwrite(callee.start! + startOffset, callee.end! + startOffset, '__expose')
} else {
processDefineModel(ctx, expr)
}
}
...
}
分派的语义:
defineProps/defineEmits/defineOptions/defineSlots作为独立语句 时:整条语句被remove(它们的值会通过ctx.propsRuntimeDecl等在后面生成)。defineExpose({...}):只把 callee 替换成__expose(defineExpose({})→__expose({})),因为__expose是注入的 setup 参数函数。- 否则尝试
defineModel(它是赋值形式,后面处理)。
另一处是变量声明形式 (compileScript.ts:546-609)------const props = defineProps(...):
ts
if (node.type === 'VariableDeclaration' && !node.declare) {
...
const init = decl.init && unwrapTSNode(decl.init)
if (init) {
if (processDefineOptions(ctx, init)) {
ctx.error(`${DEFINE_OPTIONS}() has no returning value, it cannot be assigned.`, node)
}
const isDefineProps = processDefineProps(ctx, init, decl.id as LVal)
if (ctx.propsDestructureRestId) {
setupBindings[ctx.propsDestructureRestId] = BindingTypes.SETUP_REACTIVE_CONST
}
const isDefineEmits = !isDefineProps && processDefineEmits(ctx, init, decl.id as LVal)
!isDefineEmits && (processDefineSlots(ctx, init, decl.id as LVal) ||
processDefineModel(ctx, init, decl.id as LVal))
...
}
}
checkInvalidScopeReference (compileScript.ts:282-297):
ts
function checkInvalidScopeReference(node: Node | undefined, method: string) {
if (!node) return
walkIdentifiers(node, id => {
const binding = setupBindings[id.name]
if (binding && binding !== BindingTypes.LITERAL_CONST) {
ctx.error(
`\`${method}()\` in <script setup> cannot reference locally ` +
`declared variables because it will be hoisted outside of the ` +
`setup() function. ...`,
id,
)
}
})
}
在第 4 步(compileScript.ts:715-726)对所有宏参数统一调用。原因:宏(如 defineProps)的参数会被提升到 setup() 外部 (因为 runtime props 要成为组件选项),而 setup 内声明的变量在外部不可见。唯一例外是 LITERAL_CONST ------纯字面量常量(如 const A = 'str')会被 hoistStatic 提升到模块作用域,因此可以引用(第 6 步的 LITERAL_CONST 标记就是为此)。
9.6 defineProps 编译:两条路径与解构转换
9.6.1 processDefineProps:三个关键分岔
defineProps.ts:47-93。四条规则,逐一拆解:
规则 1:重复调用报错。 hasDefinePropsCall 只允许一次(一个组件只能有一处 defineProps)。
规则 2:propsRuntimeDecl = node.arguments[0]。 这就是"runtime 声明路径"的入口------若有第一个参数(对象或数组字面量),它就是运行时 props 声明;同时遍历其键,为每个键写 bindingMetadata[key] = BindingTypes.PROPS(注意与已存在的绑定不冲突,!(key in ...) 才写)。
为什么这里也要登记 props 键? 因为模板编译时要把
{{ foo }}里的foo识别为 props(走$props.foo而非$setup.foo)。runtime 声明的键在 AST 上就能看到,所以直接登记。
规则 3:有 typeParameters 就是"类型声明路径"。 node.typeParameters 即 defineProps<Props>() 里的 <Props>,取第一个类型参数存进 propsTypeDecl。若同时有 runtime 参数和类型参数 → 报错 (二者不可同时给)。注意:typeParameters 只在 TS 里存在。
规则 4:有 declId 且是 ObjectPattern(解构)→ processPropsDestructure。 即 const { foo } = defineProps() 的形态。注意 isWithDefaults 为真时跳过错 (withDefaults 下的解构由 defineProps 自己处理,见规则 5)。
三条路径总结:
scss
defineProps(['a', 'b']) → 数组 runtime 路径
defineProps({ a: String }) → 对象 runtime 路径
defineProps<{ a: string }>() → 类型路径(需 TS/Babel typeParameters)
withDefaults(defineProps<...>(), {}) → 类型路径 + 默认值
const { a } = defineProps(...) → 解构路径(可叠加于上述任一)
9.6.2 processWithDefaults
defineProps.ts:95-139。要点:
withDefaults的第一个参数必须是defineProps调用(递归调processDefineProps,isWithDefaults=true防止解构处理);- 只能搭配类型声明(runtime 声明带默认值直接写在对象里即可);
- 与解构同用时给 warning(推荐用解构默认值
const { foo = 1 } = defineProps(...)); - 第二个参数是默认值对象,存进
propsRuntimeDefaults。
withDefaults与"解构默认值"是两条平行的默认值机制 :hasStaticWithDefaults(defineProps.ts:320-330)判断默认值对象是否"纯静态属性、无 spread、无 computed",若是则把默认值逐键内联进 props 声明 (更优);否则回退到运行时mergeDefaults。
9.6.3 runtime 路径:genRuntimeProps
genRuntimeProps(defineProps.ts:141-177)是 props 生成的统一入口:
ts
export function genRuntimeProps(ctx: ScriptCompileContext): string | undefined {
let propsDecls: undefined | string
if (ctx.propsRuntimeDecl) {
propsDecls = ctx.getString(ctx.propsRuntimeDecl).trim()
if (ctx.propsDestructureDecl) {
const defaults: string[] = []
for (const key in ctx.propsDestructuredBindings) {
const d = genDestructuredDefaultValue(ctx, key)
const finalKey = getEscapedPropName(key)
if (d)
defaults.push(
`${finalKey}: ${d.valueString}${d.needSkipFactory ? `, __skip_${finalKey}: true` : ``}`
)
}
if (defaults.length) {
propsDecls = `/*@__PURE__*/${ctx.helper(`mergeDefaults`)}(${propsDecls}, {\n ${defaults.join(',\n ')}\n})`
}
}
} else if (ctx.propsTypeDecl) {
propsDecls = extractRuntimeProps(ctx)
}
const modelsDecls = genModelProps(ctx)
if (propsDecls && modelsDecls) {
return `/*@__PURE__*/${ctx.helper('mergeModels')}(${propsDecls}, ${modelsDecls})`
} else {
return modelsDecls || propsDecls
}
}
三条路径:
- runtime 声明存在 (
propsRuntimeDecl):直接把用户写的字面量原样切片 (ctx.getString(...).trim())------不重新生成。若同时有解构默认值,则用mergeDefaults包装(把默认值并进去)。 - 类型声明存在 (
propsTypeDecl):走extractRuntimeProps(§9.10)从 TS 类型推导出运行时声明。 - 无 props :
propsDecls为undefined。
最后若存在 defineModel(modelsDecls),则用 mergeModels 把模型的 props 合并进来(§9.8)。产物写法很讲究:
js
/*@__PURE__*/_mergeDefaults(_mergeModels(/*@__PURE__*/_mergeModels({ ... }, {...}), {...}), {...})
/*@__PURE__*/ 注解是压缩器(terser/esbuild)的"无副作用"提示 ,使得 mergeModels / mergeDefaults / defineComponent 这类调用在被丢弃时不会留下副作用残留。
9.6.4 getEscapedPropName 与符号键
utils.ts:130-139:
ts
export const propNameEscapeSymbolsRE: RegExp =
/[ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~\-]/
export function getEscapedPropName(key: string): string {
return propNameEscapeSymbolsRE.test(key) ? JSON.stringify(key) : key
}
props 键可能含 : - 等符号(如 onUpdate:modelValue、aria-label)。含符号时必须 JSON.stringify 成字符串字面量("onUpdate:modelValue"),否则作为对象键非法。这个函数在 defineProps 与 defineModel 里都会用到。
9.6.5 解构路径之一:processPropsDestructure(注册解构绑定)
definePropsDestructure.ts:28-91。要点:
- 配置三态 (
ctx.options.propsDestructure):'error'→ 直接报错;false→ 不做解构转换(退回 3.5 前行为);true/未设 → 启用。默认启用(SFCScriptCompileOptions注释:@default true)。 - 只支持一层解构 :
const { a } = defineProps()或const { a = 1 } = ...;嵌套{ a: { b } }报错;computed 键报错。 - 别名映射 :
{ foo: bar }(local !== key)会写bindingMetadata[bar] = PROPS_ALIASED并记录__propsAliases[bar] = 'foo',供模板编译时把bar映射回foo。 - rest 元素 :
const { a, ...rest } = defineProps()记录propsDestructureRestId = 'rest',并标记为SETUP_REACTIVE_CONST。后续第 8 步会用createPropsRestProxy(__props, [...])生成一个 Proxy。
9.6.6 解构路径之二:transformDestructuredProps(重写引用)
这是本章最复杂的算法之一(369 行)。核心目标:把对解构 prop 的每一次引用(foo)改写成 __props.foo,从而保持"响应式" (因为局部变量 foo 已经不再是响应式的了)。
先看背景。3.5 之前,const { foo } = defineProps() 的 foo 是一个普通常量 ,模板里 {{ foo }} 只会读一次、不会随 props 更新。3.5 的"响应式解构"提案(Reactive Props Destructure)改变了这一点:foo 每次使用时都被重写成 __props.foo,因此它天然跟随 props 变化。
transformDestructuredProps(definePropsDestructure.ts:99-401)的实现是一套手写的作用域模拟器 。它维护:作用域栈(rootScope/scopeStack/functionScopeStack)、excludedIds(排除局部声明的 id 与 defineProps 解构出的 id)、parentStack(父链)、propsLocalToPublicMap(local → public 键映射),以及 modelOptionNodes/currentModelOption(defineModel runtime options 的特殊处理)。
流程:
① 初始化 rootScope (definePropsDestructure.ts:117-127):遍历 ctx.propsDestructuredBindings,把每个 local 记入 rootScope[local] = true 并建立 propsLocalToPublicMap[local] = key。Scope 是 Record<string, boolean>(definePropsDestructure.ts:93-97):true → prop binding,false → local binding。
② 收集 defineModel 的 runtime option 节点 (:123-127)------这些节点内部禁止引用解构 prop ,因为模型选项会被提升到 setup 外部(rewriteId 里会优先检查 currentModelOption,:238-257)。
③ 作用域栈操作 (:129-155):pushScope / popScope(区分 function scope 与 block scope);registerLocalBinding 把局部变量记进当前 scope 并加进 excludedIds。
④ 先扫一遍声明 (:302-305):对 ctx.scriptSetupAst 依次调用 walkFunctionScopeVarDeclarations(ast, true) 与 walkScope(ast, true)。
walkFunctionScopeVarDeclarations(:207-235)处理var(函数级提升)------需要先整体扫描,因为var声明在函数任何位置都有效;walkScope(:157-180)处理顶层let/const/函数/类声明,把它们的名字登记为"局部绑定"(false),从而不会被误当成 prop 引用。
关键细节 :
walkVariableDeclaration(:182-205)对"根作用域 +init是defineProps"的声明,把解构出的 id 加进excludedIds(不登记为局部绑定)------因为它们已经通过propsDestructuredBindings登记为 prop 绑定了。
⑤ 主遍历 (:306-400):用 estree-walker 遍历整个 AST,维护 scope 与 parentStack。对每个节点:
- 跳过
TS类型节点(parent.type.startsWith('TS') && !TS_NODE_TYPES.includes(parent.type)); checkUsage:检测watch(foo)/toRef(foo)这类"直接传 prop 给函数"的用法,报错并建议() => foo;- 进入函数 →
pushScope(true)+ 登记函数参数 → 预扫var→ 扫局部声明; CatchClause/ForOf/ForIn/For→ 推入块作用域;- 普通
BlockStatement(非函数体)→ 推入块作用域; - 遇到
Identifier(:371-380):当isReferencedIdentifier(node, parent!, parentStack)为真、node不在excludedIds中、且currentScope[node.name]为真时调用rewriteId(node, parent!, parentStack)------ 即"是引用标识符 + 未被排除 + 当前作用域里该名字是 prop 绑定"→ 重写。
⑥ rewriteId (:237-287)两种改写:
- 属性简写 (
isStaticProperty(parent) && parent.shorthand):{ foo }→{ foo: __props.foo },用ctx.s.appendLeft(id.end! + ctx.startOffset!, \: ${genPropsAccessExp(propsLocalToPublicMapid.name)}`)` 插入; - 普通引用 :
foo→__props.foo,用ctx.s.overwrite(id.start! + ctx.startOffset!, id.end! + ctx.startOffset!, genPropsAccessExp(propsLocalToPublicMap[id.name]))覆写。
genPropsAccessExp 来自 @vue/shared,对普通键生成 __props.foo,对含符号的键生成 __props["a-b"](与 getEscapedPropName 同源逻辑)。
并且 rewriteId 先做两个错误检查:
- 在
currentModelOption内 → 报错(模型选项不能引用解构 prop); - 赋值/自增(
AssignmentExpression左值 或UpdateExpression)→ 报错"不能给解构 prop 赋值"(因为 props 只读)。
为什么这个作用域模拟是必要的? 因为改写必须精确:如果函数内部有
const foo = 1,那里的foo绝不能改成__props.foo。手写 scope 追踪虽然繁琐,但比"用 Babel scope 分析再做字符串替换"更可控、也更快------毕竟这段代码工作在编译期热路径上。
9.6.7 解构默认值的产物
genDestructuredDefaultValue(defineProps.ts:332-376)决定每个解构默认值如何生成:
ts
function genDestructuredDefaultValue(ctx, key, inferredType?) {
const destructured = ctx.propsDestructuredBindings[key]
const defaultVal = destructured && destructured.default
if (defaultVal) {
const value = ctx.getString(defaultVal)
const unwrapped = unwrapTSNode(defaultVal)
if (inferredType && inferredType.length && !inferredType.includes('null')) {
const valueType = inferValueType(unwrapped)
if (valueType && !inferredType.includes(valueType)) {
ctx.error(`Default value of prop "${key}" does not match declared type.`, unwrapped)
}
}
const needSkipFactory =
!inferredType && (isFunctionType(unwrapped) || unwrapped.type === 'Identifier')
const needFactoryWrap =
!needSkipFactory && !isLiteralNode(unwrapped) && !inferredType?.includes('Function')
return {
valueString: needFactoryWrap ? `() => (${value})` : value,
needSkipFactory,
}
}
}
规则:
| 默认值形态 | 处理 | 原因 |
|---|---|---|
字面量(= 1、= 'a') |
原样 | 无副作用 |
| 函数 / 标识符(无类型信息时) | 原样 + needSkipFactory |
无法判断是否该包工厂 |
其他表达式(如 = { a: 1 }) |
() => ({ a: 1 }) |
对象/数组必须包工厂,避免多实例共享 |
声明类型含 Function |
原样 | 函数本身就是默认值 |
inferValueType(defineProps.ts:381-396)做"尽力而为"的默认值类型推断,用于默认值与声明类型是否匹配的校验(仅在类型声明 + 解构默认值同用时)。注意它的范围很有限:只认字符串/数字/布尔/对象/数组/函数字面量。
管道的最终产物(runtime 声明 + 解构默认值):
ts
const props = defineProps({ count: Number })
const { count = 0 } = props
↓ 编译为
js
props: /*@__PURE__*/_mergeDefaults({ count: Number }, {
count: 0
})
(count: 0 是字面量,不需要工厂包裹。)
9.6.8 props 三条路径对照表
| 用户写法 | propsRuntimeDecl |
propsTypeDecl |
生成产物 |
|---|---|---|---|
defineProps(['a','b']) |
['a','b'] |
--- | props: ['a','b'] |
defineProps({ a: String }) |
{ a: String } |
--- | props: { a: String } |
defineProps<{ a: string }>() |
--- | { a: string } |
props: { a: { type: String, required: true } }(非 prod) |
withDefaults(defineProps<...>(), { a: 1 }) |
--- | 类型节点 | 静态默认值时逐键内联;否则 mergeDefaults(extracted, { a: 1 }) |
const { a } = defineProps({ a: String }) |
{ a: String } |
--- | props 同上 + 所有 a 引用改成 __props.a |
const { a = 1 } = defineProps<{ a?: number }>() |
--- | { a?: number } |
mergeDefaults({ a: { type: Number } }, { a: 1 }) |
9.7 defineEmits 编译:三种写法
9.7.1 processDefineEmits
defineEmits.ts:19-46:
ts
export function processDefineEmits(ctx, node, declId?): boolean {
if (!isCallOf(node, DEFINE_EMITS)) {
return false
}
if (ctx.hasDefineEmitCall) {
ctx.error(`duplicate ${DEFINE_EMITS}() call`, node)
}
ctx.hasDefineEmitCall = true
ctx.emitsRuntimeDecl = node.arguments[0]
if (node.typeParameters) {
if (ctx.emitsRuntimeDecl) {
ctx.error(
`${DEFINE_EMITS}() cannot accept both type and non-type arguments ` +
`at the same time. Use one or the other.`,
node,
)
}
ctx.emitsTypeDecl = node.typeParameters.params[0]
}
ctx.emitDecl = declId
return true
}
- runtime 参数 →
emitsRuntimeDecl(数组或对象字面量); - 类型参数 →
emitsTypeDecl(defineEmits<{ change: [id: number] }>()); - 二者不可同时给;
emitDecl记录赋值的变量名(const emit = defineEmits(...)),因为它要被重写成__emit。
对照 defineProps:emits 没有"解构"概念(emit 是函数),所以处理更简单。
9.7.2 三种写法与产物
| 写法 | 路径 | 产物 |
|---|---|---|
defineEmits(['change', 'update']) |
runtime(数组) | emits: ['change', 'update'] |
defineEmits({ change: (id) => true }) |
runtime(对象) | emits: { change: (id) => true } |
defineEmits<{ change: [id: number] }>() |
类型 | emits: ['change'] |
genRuntimeEmits(defineEmits.ts:48-71):
ts
export function genRuntimeEmits(ctx: ScriptCompileContext): string | undefined {
let emitsDecl = ''
if (ctx.emitsRuntimeDecl) {
emitsDecl = ctx.getString(ctx.emitsRuntimeDecl).trim()
} else if (ctx.emitsTypeDecl) {
const typeDeclaredEmits = extractRuntimeEmits(ctx)
emitsDecl = typeDeclaredEmits.size
? `[${Array.from(typeDeclaredEmits).map(k => JSON.stringify(k)).join(', ')}]`
: ``
}
if (ctx.hasDefineModelCall) {
let modelEmitsDecl = `[${Object.keys(ctx.modelDecls).map(n => JSON.stringify(`update:${n}`)).join(', ')}]`
emitsDecl = emitsDecl
? `/*@__PURE__*/${ctx.helper('mergeModels')}(${emitsDecl}, ${modelEmitsDecl})`
: modelEmitsDecl
}
return emitsDecl
}
要点:
- runtime 数组/对象原样切片;
- 类型声明 →
extractRuntimeEmits提取事件名,生成字符串数组(注意只生成名字,不生成校验函数------类型声明路径下没有运行时校验); - 若有
defineModel,自动追加update:xxx事件,并用mergeModels合并。
9.7.3 extractRuntimeEmits:从类型提取事件名
defineEmits.ts:73-127。两种类型形态:
- 函数类型 :
defineEmits<(e: 'change') => void>()------直接取第一个参数e的字面量联合。 - 类型字面量 (推荐):
defineEmits<{ change: [id: number] }>()------它的键就是事件名;而 call signature 形态({ (e: 'change'): void })则由calls承载。
属性语法与 call 语法不能混用 (hasProperty && calls → 报错)。
extractEventNames(defineEmits.ts:105-127)从参数类型里挖字面量:取参数 e 的类型,经 resolveUnionType 展开联合类型,只收集字符串/数字字面量 (即 TSLiteralType,但排除 UnaryExpression(负数)与 TemplateLiteral),逐个 emits.add(String(type.literal.value))。
9.7.4 emit 校验函数:只在 runtime 对象写法下存在
一个容易被误解的点:Vue 并没有为类型声明的 emits 生成"校验函数" 。emits 选项的运行时校验只在对象写法 下存在------用户自己写 (id) => typeof id === 'number',编译器原样保留。数组/类型写法都只提供"事件名清单"(运行时用于 attrs 分流,不做参数校验)。
这与 props 不同:props 的类型声明会被完整转成运行时 type: [...] 校验(§9.10),而 emits 的类型声明只提取名字。
9.7.5 emit 调用的重写
const emit = defineEmits(...) 的变量声明形式会被改写成 const emit = __emit吗?不是------看 compileScript.ts:598-603:
ts
} else if (isDefineEmits) {
ctx.s.overwrite(
startOffset + init.start!,
startOffset + init.end!,
'__emit',
)
}
即把 defineEmits(...) 调用表达式 (init)整体替换为 __emit,于是 const emit = defineEmits(['change']) 变成 const emit = __emit。这样 emit('change') 在 setup 里正常工作,而 __emit 是注入的 setup 参数(§9.8)。
命名约束:编译器生成的 emit 函数参数就叫
__emit。如果用户自己起名const emit = ...没问题;但若用户写了const __emit = ...会冲突------不过__前缀本就是"编译器保留命名空间"的约定。
9.8 defineModel 编译(Vue 3.4+ 新宏)
9.8.1 为什么需要 defineModel
在 3.4 之前,实现一个支持 v-model 的自定义组件,需要在 <script setup> 里手写:
ts
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
然后在模板里手动 emit('update:modelValue', $event)。defineModel(RFC 关键词 useModel)把这套样板自动化:
ts
const model = defineModel<string>() // → v-model 绑定 modelValue
const model = defineModel<string>('foo') // → v-model:foo 绑定 foo
const model = defineModel<string>({ required: true })
它同时做三件事:声明一个 prop(默认名 modelValue)、声明一个对应 emit(update:modelValue)、把局部变量变成一个可读写的 ref。
9.8.2 processDefineModel:参数解析与改写
defineModel.ts:16-119:
ts
export function processDefineModel(ctx, node, declId?): boolean {
if (!isCallOf(node, DEFINE_MODEL)) {
return false
}
ctx.hasDefineModelCall = true
const type = (node.typeParameters && node.typeParameters.params[0]) || undefined
let modelName: string
let options: Node | undefined
const arg0 = node.arguments[0] && unwrapTSNode(node.arguments[0])
const hasName =
arg0 && (arg0.type === 'StringLiteral' ||
(arg0.type === 'TemplateLiteral' && arg0.expressions.length === 0))
if (hasName) {
modelName = arg0.type === 'StringLiteral' ? arg0.value : arg0.quasis[0].value.cooked!
options = node.arguments[1]
} else {
modelName = 'modelValue'
options = arg0
}
if (ctx.modelDecls[modelName]) {
ctx.error(`duplicate model name ${JSON.stringify(modelName)}`, node)
}
let optionsString = options && ctx.getString(options)
let optionsRemoved = !options
const runtimeOptionNodes: Node[] = []
...
参数解析规则:
| 调用形态 | modelName |
options |
|---|---|---|
defineModel() |
'modelValue' |
无 |
defineModel({ ... }) |
'modelValue' |
第一个参数(对象) |
defineModel('foo') |
'foo' |
无 |
defineModel('foo', { ... }) |
'foo' |
第二个参数 |
判定"有无名字"的方法是看 arg0 是否是字符串/无插值模板字面量 。若第一个参数不是字符串,就把它当成 options(且名字默认 modelValue)。
重复名字检查 (ctx.modelDecls[modelName] 已存在 → 报错)。
9.8.3 options 的"props 选项 vs 运行时选项"分离
defineModel 的 options 对象里,键有两种用途:
ts
const model = defineModel({
get(v) { return v.toUpperCase() }, // ← 运行时选项(get/set/applyModifiers 等)
set(v) { emit('update:modelValue', v.toLowerCase()) },
required: true, // ← prop 选项(成为 props 声明)
default: 'x', // ← prop 选项
})
processDefineModel 把它们分开 (defineModel.ts:53-90):若 options 是纯 ObjectExpression(无 SpreadElement、无 computed 键),则倒序遍历 (i--,以便按 offset 安全删除)其属性,end 取"下一个属性起点"或"对象末尾 -1"(覆盖属性后的逗号)------get/set 仅从注入 setup 的 optionsString 中用切片删掉(保留给运行时 useModel),其余键(required/default/validator/type)用 ctx.s.remove 从 setup 源码删掉并 push 进 runtimeOptionNodes;若全部属性都是 prop 选项(removed === options.properties.length),说明 options 对象完全没有运行时选项 ,则整段 options 从 setup 删除并置 optionsRemoved。
runtimeOptionNodes的用途 :这些节点的表达式会被提升进 props 声明(在setup()外),所以第 4 步(compileScript.ts:722-726)会检查它们不能引用解构 prop:
tsfor (const { runtimeOptionNodes } of Object.values(ctx.modelDecls)) { for (const node of runtimeOptionNodes) { checkInvalidScopeReference(node, DEFINE_MODEL) } }
9.8.4 记录与改写:defineModel → useModel
defineModel.ts:92-118:
ts
ctx.modelDecls[modelName] = {
type,
options: optionsString,
runtimeOptionNodes,
identifier: declId && declId.type === 'Identifier' ? declId.name : undefined,
}
ctx.bindingMetadata[modelName] = BindingTypes.PROPS
ctx.s.overwrite(
ctx.startOffset! + node.callee.start!,
ctx.startOffset! + node.callee.end!,
ctx.helper('useModel'),
)
ctx.s.appendLeft(
ctx.startOffset! + (node.arguments.length ? node.arguments[0].start! : node.end! - 1),
`__props, ` + (hasName ? `` : `${JSON.stringify(modelName)}${optionsRemoved ? `` : `, `}`),
)
三处改写:
defineModel标识符 →_useModel(仅替换 callee,不删整句);- 在第一个参数前注入
__props,------因为useModel(props, name, options, modifiers)的第一个参数是 props 对象; - 若无名字,注入
"modelValue",------补齐第二个参数(若 options 还在,后补逗号)。
产物示例:
ts
const model = defineModel<string>({ required: true })
↓ 经过改写与 props/emits 生成后(setup 内):
js
const model = _useModel(__props, "modelValue")
({ required: true } 被从 setup 里删掉,进了 props 声明。)
注意 :
ctx.bindingMetadata[modelName] = BindingTypes.PROPS登记的是模型名 (如"modelValue"),而不是用户变量名model。变量model本身在walkDeclaration里因为init是defineModel调用而被标记为SETUP_REF(compileScript.ts:1191-1202的DEFINE_MODEL分支),因为useModel返回一个 ref。
9.8.5 genModelProps:props 与修饰符 prop 生成
defineModel.ts:121-180:
ts
export function genModelProps(ctx: ScriptCompileContext): string | undefined {
if (!ctx.hasDefineModelCall) return
const isProd = !!ctx.options.isProd
let modelPropsDecl = ''
for (const [name, { type, options: runtimeOptions }] of Object.entries(ctx.modelDecls)) {
let runtimeTypes = type && inferRuntimeType(ctx, type)
// unknown 处理与 prod 丢弃规则见下方要点 2/3,得到 codegenOptions
let decl: string
if (codegenOptions && runtimeOptions) decl = ctx.isTS
? `{ ${codegenOptions}, ...${runtimeOptions} }`
: `Object.assign({ ${codegenOptions} }, ${runtimeOptions})`
else decl = codegenOptions ? `{ ${codegenOptions} }` : runtimeOptions || `{}`
modelPropsDecl += `\n ${JSON.stringify(name)}: ${decl},`
// 额外生成修饰符 prop
const modifierPropName = JSON.stringify(name === 'modelValue' ? `modelModifiers` : `${name}Modifiers`)
modelPropsDecl += `\n ${modifierPropName}: {},`
}
return `{${modelPropsDecl}\n }`
}
要点:
- 类型 → 运行时类型 (
inferRuntimeType),与defineProps的类型路径同源(§9.10)。 - UNKNOWN_TYPE 处理与 props 相同 :含 Boolean/Function 则过滤掉 unknown 并
skipCheck;否则退化为['null']。 - 生产模式的类型丢弃规则 :非 prod 保留完整
type;prod 只在"含 Boolean"或"有运行时 options 且含 Function"时保留type,否则丢弃(与genRuntimePropFromType的策略一致)。 - TS/JS 合并 options 的方式不同 :TS 用对象展开
{ type, ...runtimeOptions }(保留类型),JS 用Object.assign(...)(因为 JS 不支持对象展开的类型友好写法 / 历史原因)。 - 每个模型额外生成一个修饰符 prop :
modelModifiers(默认名)或${name}Modifiers,值固定{}------用于接收v-model的.trim/.lazy等修饰符。
产物示例:
ts
const model = defineModel<string>({ required: true })
↓ props 生成(非 prod):
js
props: {
modelValue: { type: String, ...{ required: true } },
modelModifiers: {},
}
9.8.6 defineModel 的演进与 ModelDecl
defineModel 在 3.3 是实验特性(需 experimental.defineModel 显式开启),3.4 起正式稳定并支持 get/set 自定义与 required/default 等 prop 选项(即 §9.8.3 的分离逻辑),本仓库 3.5.44 即此状态。
ModelDecl 接口(defineModel.ts:9-14)记录每个模型的分析结果:
ts
export interface ModelDecl {
type: TSType | undefined // 类型参数(用于 inferRuntimeType)
options: string | undefined // get/set 等运行时 options 源码
identifier: string | undefined// 用户变量名
runtimeOptionNodes: Node[] // 被移入 props 的选项节点(供引用检查)
}
综上,defineModel 一个宏同时改了 props、emits、bindingMetadata 和 setup 代码 四处(并额外生成 xxxModifiers prop),是本组宏里"横切面最广"的一个。
9.9 defineExpose / defineOptions / defineSlots 编译
9.9.1 defineExpose:只标记 + 替换 callee
defineExpose.ts 全文:
ts
export function processDefineExpose(ctx: ScriptCompileContext, node: Node): boolean {
if (isCallOf(node, DEFINE_EXPOSE)) {
if (ctx.hasDefineExposeCall) {
ctx.error(`duplicate ${DEFINE_EXPOSE}() call`, node)
}
ctx.hasDefineExposeCall = true
return true
}
return false
}
它只做两件事:重复检测 + 标记 hasDefineExposeCall 。调用点的改写由 compileScript.ts:533-540 完成:
ts
} else if (processDefineExpose(ctx, expr)) {
const callee = (expr as CallExpression).callee
ctx.s.overwrite(
callee.start! + startOffset,
callee.end! + startOffset,
'__expose',
)
}
即 defineExpose({ foo }) → __expose({ foo })。而 __expose 是注入的 setup 参数(当 hasDefineExposeCall || !inlineTemplate 时,见 §9.11)。
为什么"没调用 defineExpose 也要 expose"? 因为 <script setup> 组件默认是封闭的 (实例不暴露任何东西)。compileScript.ts:1041-1044 的注释与代码如下:
ts
// <script setup> components are closed by default. If the user did not
// explicitly call `defineExpose`, call expose() with no args.
const exposeCall = ctx.hasDefineExposeCall || options.inlineTemplate ? `` : ` __expose();\n`
即在 non-inline 模式下,若用户没 defineExpose,就注入一句 __expose();(空 expose)。这保证了 ref 拿到的组件实例通过 $ref 访问时是空的------除非用户显式 expose。
9.9.2 defineOptions:合并进默认导出
defineOptions.ts 的 processDefineOptions(defineOptions.ts:12-87):
规则:
- 非
DEFINE_OPTIONS调用直接return false;重复调用报duplicate ... call;带类型参数报错(defineOptions<T>()不允许); - 无参数时返回
true但什么都不做(宏被识别并删掉,无副作用); - 有参数时把
unwrapTSNode(node.arguments[0])存进ctx.optionsRuntimeDecl。
随后它还会检查 options 里不允许出现 props/emits/expose/slots (defineOptions.ts:34-84):遍历属性,命中 props/emits/expose/slots 之一就记下对应节点,最后 ctx.error(...)(如 ${DEFINE_OPTIONS}() cannot be used to declare props. Use ${DEFINE_PROPS}() instead.)。
即"能用 defineOptions 声明任意组件选项,但 props/emits/expose/slots 必须用专门宏"。这是为了避免出现两套声明机制而语义不清。
产物 :optionsRuntimeDecl 的内容会被切片,然后在第 10 步(§9.12)合并进默认导出。compileScript.ts:1034-1039:若 ctx.optionsRuntimeDecl 存在,则 definedOptions = scriptSetup.content.slice(start!, end!).trim(),否则为空串。
随后在 TS 分支里拼进默认导出(JS 分支用 Object.assign):
ts
const def =
(defaultExport ? `\n ...${normalScriptDefaultVar},` : ``) +
(definedOptions ? `\n ...${definedOptions},` : '')
JS 分支用 Object.assign。也就是说 defineOptions({ name: 'Foo' }) 最终进入组件定义对象。
defineOptionsvs 普通<script>:两者都能提供组件选项。区别是<script>提供的是export default,而defineOptions是<script setup>内的补充。当二者同时存在时,TS 分支会...__default__+...defineOptions内容合并(后者覆盖前者);JS 分支用Object.assign(__default__, defineOptions内容, 组件主体)。
9.9.3 defineSlots:无参数,替换为 useSlots()
defineSlots.ts 的 processDefineSlots:非 DEFINE_SLOTS 调用返回 false;重复调用报 duplicate ... call;带运行时参数(node.arguments.length > 0)报 cannot accept arguments;若 declId 存在,用 ctx.s.overwrite(node.start!..node.end!, ${ctx.helper('useSlots')}()) 把整个调用替换成 _useSlots()。
要点:
defineSlots<T>()只能带类型参数(描述每个具名 slot 的 props 类型),不能带运行时参数;- 若有
declId(const slots = defineSlots<...>()),把整个调用 替换成_useSlots(); - 若没有赋值(
defineSlots<...>()独立语句),由compileScript.ts:531-532整句remove。
也就是说 defineSlots 的运行时产物是 _useSlots(),与普通 useSlots() 完全一样------类型参数只在编译期做校验,不产生运行时开销。
9.9.4 三个宏对照表
| 宏 | 参数 | 运行时产物 | 备注 |
|---|---|---|---|
defineExpose(obj) |
对象 | __expose(obj) |
无 defineExpose 时注入 __expose() |
defineOptions(obj) |
对象(无类型参数) | 合并进组件定义对象 | 禁止含 props/emits/expose/slots |
defineSlots<T>() |
仅类型参数 | _useSlots() |
无运行时校验 |
9.10 类型解析 resolveType.ts:从 TS 类型推导运行时 props
9.10.1 为什么需要它:类型声明如何变成运行时校验
defineProps<{ count: number }>() 只有类型信息。但 Vue 运行时需要的是"每个 prop 的运行时类型"(用于开发期的 validateProp 警告)。这个"类型 → 运行时类型"的转换就是 resolveType.ts 的职责,也是整个 compiler-sfc 最复杂的模块(2164 行)。
它的核心导出(index.ts:9,44-45):
ts
export { resolveTypeElements, inferRuntimeType } from './script/resolveType'
export { extractRuntimeProps } from './script/defineProps'
调用链:
scss
extractRuntimeProps(ctx) // defineProps.ts:179
└─ resolveRuntimePropsFromType(ctx, node) // defineProps.ts:211
├─ resolveTypeElements(ctx, node) // resolveType.ts:167 ← 把类型拆成"成员集合"
└─ inferRuntimeType(ctx, e) // resolveType.ts:1667 ← 每个成员 → 运行时类型名
9.10.2 TypeScope:类型的作用域模型
resolveType.ts:115-128:
ts
export class TypeScope {
constructor(
public filename: string,
public source: string,
public offset: number = 0,
public imports: Record<string, Import> = Object.create(null),
public types: Record<string, ScopeTypeNode> = Object.create(null),
public declares: Record<string, ScopeTypeNode> = Object.create(null),
) {}
isGenericScope = false
resolvedImportSources: Record<string, string> = Object.create(null)
exportedTypes: Record<string, ScopeTypeNode> = Object.create(null)
exportedDeclares: Record<string, ScopeTypeNode> = Object.create(null)
}
| 字段 | 含义 |
|---|---|
filename / source / offset |
本作用域对应的文件与偏移(用于跨文件解析与报错定位) |
imports |
本作用域的 import 表(local → { source, imported }) |
types |
本地定义的类型声明(interface / type / enum / class...) |
declares |
declare 的运行时值(declare const x: Foo)------typeof x 用 |
exportedTypes / exportedDeclares |
用于跨文件:被 export 的类型 |
resolvedImportSources |
import source → 解析出的文件路径缓存 |
isGenericScope |
是否泛型实参作用域 |
ctxToScope(resolveType.ts:1337-1359)从一个编译上下文构造 TypeScope:
ts
function ctxToScope(ctx: TypeResolveContext): TypeScope {
if (ctx.scope) {
return ctx.scope
}
const body =
'ast' in ctx
? ctx.ast
: ctx.scriptAst
? [...ctx.scriptAst.body, ...ctx.scriptSetupAst!.body]
: ctx.scriptSetupAst!.body
const scope = new TypeScope(
ctx.filename,
ctx.source,
'startOffset' in ctx ? ctx.startOffset! : 0,
'userImports' in ctx ? Object.create(ctx.userImports) : recordImports(body),
)
recordTypes(ctx, body, scope)
return (ctx.scope = scope)
}
注意:普通 <script> 与 <script setup> 的 AST body 被合并 ([...ctx.scriptAst.body, ...ctx.scriptSetupAst!.body]),因为它们属于同一组件。recordTypes(:1397-1517)扫描所有类型声明、declare、re-export、declare global 等。
9.10.3 resolveTypeElements:把类型拆成 props 成员
resolveType.ts:167-185:
ts
export function resolveTypeElements(
ctx: TypeResolveContext,
node: Node & MaybeWithScope & { _resolvedElements?: ResolvedElements },
scope?: TypeScope,
typeParameters?: Record<string, Node>,
): ResolvedElements {
const canCache = !typeParameters
if (canCache && node._resolvedElements) {
recordResolvedElementDeps(ctx, node._resolvedElements)
return node._resolvedElements
}
const resolved = innerResolveTypeElements(
ctx, node, node._ownerScope || scope || ctxToScope(ctx), typeParameters,
)
return canCache ? (node._resolvedElements = resolved) : resolved
}
缓存策略 :结果直接挂在类型节点上(node._resolvedElements)。有 typeParameters(泛型实参)时不能缓存(因为同一节点在不同实参下结果不同)。命中缓存时还要重放依赖记录(recordResolvedElementDeps → _ownerScope.filename 加进 deps)。
ResolvedElements(:134-143):
ts
interface ResolvedElements {
props: Record<string, (TSPropertySignature | TSMethodSignature) & { _ownerScope: TypeScope }>
calls?: (TSCallSignatureDeclaration | TSFunctionType)[]
}
props 是"属性签名"集合(每个键对应一个 prop),calls 是"调用签名"(给 emits 用) 。对 props 而言只有 props 有意义,对 emits 而言 calls 用于提取事件名。
innerResolveTypeElements(:187-...)的 switch(node.type) 覆盖:
| 类型节点 | 处理 |
|---|---|
TSTypeLiteral |
直接取 members |
TSInterfaceDeclaration |
resolveInterfaceMembers(含 extends) |
TSTypeAliasDeclaration / TSTypeAnnotation / TSParenthesizedType |
递归到 typeAnnotation |
TSFunctionType |
{ props: {}, calls: [node] } |
TSUnionType / TSIntersectionType |
mergeElements(合并各成员) |
TSMappedType |
resolveMappedType(有限支持 { [K in keyof T]: ... }) |
TSIndexedAccessType |
resolveIndexType 后合并 |
TSTypeReference / TSExpressionWithTypeArguments |
resolveTypeReference 解析引用;特殊处理 ExtractPropTypes |
TSImportType |
import('./x').Foo 形态 |
| ... | ... |
@vue-ignore 注释 (:193-211):若类型节点带 @vue-ignore 注释,则"跳过"该类型的解析(返回空 props 或忽略该联合成员)。这是给用户"某些复杂类型无法推导,请编译器跳过"的逃逸口。
9.10.4 inferRuntimeType:核心分支走读
inferRuntimeType(resolveType.ts:1667-2090)返回一个运行时类型名数组 (如 ['String', 'Number'],对应 [String, Number])。开头处理 @vue-ignore(返回 [UNKNOWN_TYPE]),并把 silentOnExtendsFailure 置真(吞掉 extends 解析错误)。
逐分支(switch (node.type)):
基础关键字(:1687-1696):
| TS 节点 | 返回 |
|---|---|
TSStringKeyword |
['String'] |
TSNumberKeyword |
['Number'] |
TSBooleanKeyword |
['Boolean'] |
TSObjectKeyword |
['Object'] |
TSNullKeyword |
['null'] |
TSSymbolKeyword |
['Symbol'] |
对象/接口(:1697-1738) :TSTypeLiteral / TSInterfaceDeclaration 遍历成员:
- call/construct signature →
Function; - 其他成员 →
Object。 - 若空成员:
isKeyOf ? UNKNOWN_TYPE : 'Object'。 isKeyOf模式下额外特判:数字键 →Number,索引签名 → 递归其类型,其他 →String。isKeyOf用于Record/keyof场景 :此时我们要推断的是"这个对象可能有哪些键",所以是 String/Number 而非 Object。
属性/方法(:1739-1754):
TSPropertySignature→ 递归其typeAnnotation;TSMethodSignature/TSFunctionType→['Function'];TSArrayType/TSTupleType→['Array']。
字面量类型(:1756-1767) :TSLiteralType 按字面量种类映射------StringLiteral → String,BooleanLiteral → Boolean,NumericLiteral/BigIntLiteral → Number,其他 → UNKNOWN_TYPE。
类型引用(:1769-1969)------这是最长的一个分支:
- 先尝试
resolveTypeReference(有 try/catch):- 若是函数类型别名(
type Fn = () => void)→['Function'](#13240 特判); - 若有类型实参且解析对象有类型形参 → 递归替换实参后再推断;
- 否则递归推断解析出的声明。
- 若是函数类型别名(
- 解析失败时按名字兜底 (
node.typeName是 Identifier):- 若是泛型实参名(
typeParameters[name])→ 递归该实参; - 非
isKeyOf模式的名字表 (:1868-1934):Array→Array、Function→Function、Object→Object、Set/Map/WeakSet/WeakMap/Date/Promise/Error→同名、Partial/Required/Readonly/Record/Pick/Omit/InstanceType→Object、Uppercase/Lowercase/Capitalize/Uncapitalize→String、Parameters/ConstructorParameters/ReadonlyArray→Array、ReadonlyMap→Map、ReadonlySet→Set;Vue 的Ref/ShallowRef/ComputedRef/WritableComputedRef→Object (#14729,即便 vue 类型解析不到也能兜底);MaybeRef→Object(+ 实参类型),MaybeRefOrGetter→Object+Function(+ 实参);NonNullable→过滤掉 null;Extract→取第 2 实参;Exclude/OmitThisParameter→递归第 1 实参。 isKeyOf模式的名字表 (:1815-1866):String/Array/ArrayLike/Parameters/ConstructorParameters/ReadonlyArray→['String','Number'](键可能是字符或数字下标);Record/Partial/Required/Readonly→递归第 1 实参(isKeyOf);Pick/Extract→递归第 2 实参;其余内置对象/工具类型→String(键是字符串)。
- 若是泛型实参名(
Parenthesized / 联合 / 交叉(:1971-1984) :TSParenthesizedType 递归内层;TSUnionType 调 flattenTypes 合并;TSIntersectionType 同样调 flattenTypes 后再 .filter(t => t !== UNKNOWN_TYPE)。
联合类型 :把每个成员的类型 flattenTypes 合并去重 → string | number → ['String','Number']。 交叉类型 :与联合相同,但过滤掉 UNKNOWN_TYPE(因为交叉的成员通常可推断,unknown 是噪音)。
flattenTypes(:2092-2123):单成员直接递归;多成员用 Set 去重合并([...new Set(concat(...))])。
映射类型(:1985-2025) :只支持 { [K in keyof T]: T[K] } 这一种,找到对应实参再推断;否则 [UNKNOWN_TYPE]。
枚举(:2027-2028 → inferEnumType:2125-2140) :遍历成员的 initializer------StringLiteral → String,NumericLiteral → Number,用 Set 收集;若集合为空(无初始化器,默认数字递增)→ ['Number']。
即:字符串枚举 → String,数字枚举 → Number,无初始化器(默认数字递增)→ Number。
索引访问 / class / import type / typeof / readonly / any(:2033-2082):
TSIndexedAccessType(T['key'])→ 解析索引后合并;ClassDeclaration→['Object'];TSImportType(import('./x').Foo)→ 解析到源作用域后再推断;TSTypeQuery(typeof x)→ 在scope.declares里找x;TSTypeOperator(readonly/keyof)→ 递归其typeAnnotation,keyof时把isKeyOf置真;TSAnyKeyword→keyof any时['String','Number','Symbol'],否则落到兜底。
兜底与软失败(:2084-2089) :整个 switch 包在 try/catch/finally 里------catch 静默(注释 "always soft fail on failed runtime type inference"),finally 恢复 silentOnExtendsFailure;无法推断则落到 return [UNKNOWN_TYPE]。
所有无法推断的复杂类型 → [UNKNOWN_TYPE](即 'Unknown',utils.ts:15)。整个过程 soft fail:推断失败绝不抛出,只是返回 unknown,保证编译不中断。
9.10.5 UNKNOWN_TYPE 与产物降级
resolveRuntimePropsFromType(defineProps.ts:211-238):
ts
const props: PropTypeData[] = []
const elements = resolveTypeElements(ctx, node)
for (const key in elements.props) {
const e = elements.props[key]
let type = inferRuntimeType(ctx, e)
let skipCheck = false
if (type.includes(UNKNOWN_TYPE)) {
if (type.includes('Boolean') || type.includes('Function')) {
type = type.filter(t => t !== UNKNOWN_TYPE)
skipCheck = true
} else {
type = ['null']
}
}
props.push({ key, required: !e.optional, type: type || [`null`], skipCheck })
}
UNKNOWN 的降级策略:
| 类型数组含 unknown 时 | 处理 | 理由 |
|---|---|---|
同时含 Boolean 或 Function |
移除 unknown,skipCheck: true |
Boolean 有特殊"缺省即 false"语义;Function 类型无法在运行时区分,故保留 skipCheck 让运行时跳过 |
| 只含 unknown | 置为 ['null'] |
null 在 type 校验里表示"不做类型校验" |
type: ['null'] 是一个巧妙的约定:Vue 的 validateProp 遇到 null 会跳过类型检查,因此"完全无法推断的类型"编译成 type: null 而不是报错或丢失 prop。
9.10.6 genRuntimePropFromType:单个 prop 的代码生成
defineProps.ts:240-313(前面已部分引用)。完整形态:
非 prod:
js
key: { type: <T>, required: <bool>, skipCheck: true?, default: <d> }
prod (isProd 真)的三种形态:
ts
} else if (type.some(el => el === 'Boolean' || ((!hasStaticDefaults || defaultString) && el === 'Function'))) {
// #4783 boolean 保留 type;#7111 function 有默认值或非静态时保留 type
return `${finalKey}: { ${concatStrings([`type: ${toRuntimeTypeString(type)}`, defaultString])} }`
} else {
if (ctx.isCE) {
// #8989 自定义元素保留 type;defaultString 时写 { default, type }
...
}
// prod: 校验无用,只保留 default(若有)
return `${finalKey}: ${defaultString ? `{ ${defaultString} }` : `{}`}`
}
生产模式丢弃无用校验(性能优化),但保守地保留:
- Boolean(需要
type: Boolean做"缺省 false"转换); - 有默认值的 Function(需要识别工厂函数);
- 自定义元素(
isCE)下的所有类型。
注意
ctx.isCE:自定义元素(Custom Element,defineCustomElement)的 props 通过 attribute 传入,运行时无法像 SFC 那样靠模板静态分析,所以必须保留type以便运行时正确转换 attribute 字符串 → 实际类型。
9.10.7 复杂类型解析与跨文件 deps
resolveType.ts 明确不支持的类型(退化为 unknown 或报错):
- 外部库的泛型工具类型 :名字不在内置表里且解析不到时,
resolveTypeElements直接ctx.error('Unresolvable type reference or unsupported built-in utility type'); - 映射类型的任意形态 :只支持
{ [K in keyof T]: T[K] }; - 条件类型(Conditional Type):无分支处理,落 unknown;
- 模板字面量类型 :
TSLiteralType的TemplateLiteral分支落到default(unknown)------emits 里也明确排除; - 负数/UnaryExpression 字面量:unknown。
跨文件解析则经 importSourceToScope(resolveType.ts:956-...):相对/.. 路径直接按路径 resolveExt(fast path,不需要 TypeScript);模块/别名路径需要 TypeScript 的 resolveModuleName(:991-1011),并在浏览器构建下报错。每次跨文件解析都 recordScopeDep 把文件路径记进 ctx.deps(§9.4.4 的 HMR 失效机制)。globalTypeFiles(compileScript.ts:92-95)则可预先把一组 .d.ts 解析为全局作用域(ctx.globalScopes)。
9.10.8 类型推导支持矩阵(速查)
| TS 类型 | 运行时类型 |
|---|---|
string / String |
String |
number / Number |
Number |
boolean / Boolean |
Boolean |
symbol |
Symbol |
null |
null |
object |
Object |
| `string | number` |
string & { __brand } |
[String](过滤 unknown) |
| `'a' | 'b'` |
| `1 | 2` |
true |
[Boolean] |
T[] / [a, b] |
Array |
(() => void) / 方法签名 |
Function |
{ a: string } / interface |
Object |
Record<K, V> / Partial<T> / Pick<T,K> |
Object |
Uppercase<S> 等 |
String |
Promise<T> / Date / Map / Set |
同名 |
Ref<T> / ShallowRef<T> |
Object |
MaybeRefOrGetter<T> |
[Object, Function] |
S extends string ? 1 : 2(条件类型) |
Unknown → null |
| 枚举(字符串) | String |
| 枚举(数字/无初始化) | Number |
any(非 keyof) |
Unknown |
泛型实参 T(已绑定) |
递归实参类型 |
| 无法解析 | Unknown → 降级 |
9.11 setup 代码重写:绑定元数据与函数包裹
9.11.1 bindingMetadata:模板编译的"导航图"
bindingMetadata 是 compileScript 交给 compileTemplate 的关键桥梁 。它告诉模板编译器:{{ foo }} 里的每个标识符到底是 props、setup 变量、还是 import。
BindingTypes 枚举(定义在 compiler-core)在 3.5 中共有这些值:
| 标记 | 含义 | 模板里的读取方式(inline 模式) |
|---|---|---|
SETUP_CONST |
setup 里的不可变常量(且肯定不是 ref) | foo(直接读) |
SETUP_LET |
setup 里的 let(可变),需 getter/setter |
foo |
SETUP_REACTIVE_CONST |
const foo = reactive(...) |
foo(不 unref) |
SETUP_REF |
确知是 ref(ref()/computed()/shallowRef()/defineModel) |
foo.value(自动补 .value) |
SETUP_MAYBE_REF |
可能是 ref(无法确定) | unref(foo) |
PROPS |
props | __props.foo |
PROPS_ALIASED |
解构别名 prop | __props.<publicKey>(靠 __propsAliases 映射) |
LITERAL_CONST |
提升到模块作用域的纯字面量常量 | foo |
OPTIONS |
普通 <script> 的 options(computed/methods/inject) |
$options.foo |
DATA |
普通 <script> 的 data |
$data.foo |
SETUP_LET 之上的 __isScriptSetup |
非枚举标记 | 决定 proxy 是否放行 $/_ 开头的属性 |
第 6 步(compileScript.ts:747-768)组装它:
ts
if (scriptAst) {
Object.assign(ctx.bindingMetadata, analyzeScriptBindings(scriptAst.body))
}
for (const [key, { isType, imported, source }] of Object.entries(ctx.userImports)) {
if (isType) continue
ctx.bindingMetadata[key] =
imported === '*' ||
(imported === 'default' && source.endsWith('.vue')) ||
source === 'vue'
? BindingTypes.SETUP_CONST
: BindingTypes.SETUP_MAYBE_REF
}
for (const key in scriptBindings) {
ctx.bindingMetadata[key] = scriptBindings[key]
}
for (const key in setupBindings) {
ctx.bindingMetadata[key] = setupBindings[key]
}
顺序很关键:import 先标 SETUP_MAYBE_REF(保守),然后 scriptBindings、setupBindings 覆盖------因为本地声明比 import 更精确。
import 的判定:
import * as ns(imported === '*')→SETUP_CONST;import Foo from './Foo.vue'(default +.vue)→SETUP_CONST(组件对象不会变);import { ref } from 'vue'→SETUP_CONST(Vue API 常量);- 其他 →
SETUP_MAYBE_REF(可能是 ref,也可能是普通值)。
9.11.2 walkDeclaration:绑定类型判定
walkDeclaration(compileScript.ts:1136-1239)是"给变量分配 BindingTypes"的核心。对 VariableDeclaration:
ts
const isConst = node.kind === 'const'
isAllLiteral = isConst && node.declarations.every(decl => decl.id.type === 'Identifier' && isStaticNode(decl.init!))
if (id.type === 'Identifier') {
let bindingType
const userReactiveBinding = userImportAliases['reactive']
if ((hoistStatic || from === 'script') && (isAllLiteral || (isConst && isStaticNode(init!)))) {
bindingType = BindingTypes.LITERAL_CONST
} else if (isCallOf(init, userReactiveBinding)) {
bindingType = isConst ? BindingTypes.SETUP_REACTIVE_CONST : BindingTypes.SETUP_LET
} else if (isConstMacroCall || (isConst && canNeverBeRef(init!, userReactiveBinding))) {
bindingType = isCallOf(init, DEFINE_PROPS) ? BindingTypes.SETUP_REACTIVE_CONST : BindingTypes.SETUP_CONST
} else if (isConst) {
// 若 init 是 ref/computed/shallowRef/... 类 API 调用 → SETUP_REF,否则 SETUP_MAYBE_REF
bindingType = isCallOf(init, refLikeApi) ? BindingTypes.SETUP_REF : BindingTypes.SETUP_MAYBE_REF
} else {
bindingType = BindingTypes.SETUP_LET
}
registerBinding(bindings, id, bindingType)
}
判定优先级(自上而下):
LITERAL_CONST:const+isStaticNode(纯静态表达式,如1 + 2、'a'、[1,2,3]里的字面量)。isStaticNode(:1343-1378)递归判定 Unary/Binary/Conditional/Sequence/Template(要求插值也是静态)及五种字面量。reactive()调用 :const x = reactive({})→SETUP_REACTIVE_CONST(标记为"可变但非 ref");let x = reactive({})→SETUP_LET。canNeverBeRef(:1315-1341):const x = 1 + 2、const x = arr.map(...)这类绝不可能返回 ref 的表达式 →SETUP_CONST(省掉模板里的unref)。其中宏调用(defineProps/defineEmits/withDefaults/defineSlots)是isConstMacroCall→ 直接SETUP_CONST;defineProps特判为SETUP_REACTIVE_CONST。const+ ref 类 API (ref/computed/shallowRef/customRef/toRef/useTemplateRef/defineModel)→SETUP_REF。- 其他
const→SETUP_MAYBE_REF。 let/var→SETUP_LET。
userImportAliases是必要的 :若用户写import { ref as myRef } from 'vue',那myRef()也要被识别为 ref。第 1.3 步(compileScript.ts:396-400)收集了vueImportAliases(imported → local),walkDeclaration用它判断。
解构赋值(ObjectPattern/ArrayPattern)走 walkObjectPattern/walkArrayPattern/walkPattern(:1241-1313):const { a } = ... 时 a → SETUP_MAYBE_REF(保守,因为不知道解构出的值是不是 ref);var/let → SETUP_LET;宏调用解构 → SETUP_CONST。
TSEnumDeclaration(:1222-1228):全静态 → LITERAL_CONST,否则 SETUP_CONST。 FunctionDeclaration/ClassDeclaration(:1229-1236):SETUP_CONST。
9.11.3 顶层代码如何移入 setup()
<script setup> 的顶层代码本来就在函数体外 (模块作用域)。compileScript 不改它们的相对内容,而是用"包裹式改写 ":在第 10 步 prependLeft(startOffset, ...) 注入 setup(...) { 的开头,在 appendRight(endOffset, ...) 注入 } 收尾。于是原本的顶层代码就被"套"进了 setup() 函数体。
同时第 5 步删掉块外的 HTML (compileScript.ts:728-745):
ts
if (script) {
if (startOffset < scriptStartOffset!) {
// <script setup> before <script>
ctx.s.remove(0, startOffset)
ctx.s.remove(endOffset, scriptStartOffset!)
ctx.s.remove(scriptEndOffset!, source.length)
} else {
// <script> before <script setup>
ctx.s.remove(0, scriptStartOffset!)
ctx.s.remove(scriptEndOffset!, startOffset)
ctx.s.remove(endOffset, source.length)
}
} else {
// only <script setup>
ctx.s.remove(0, startOffset)
ctx.s.remove(endOffset, source.length)
}
而顶部 import 与 TS 类型声明被提升 :hoistNode(:232-249)用 ctx.s.move(start, end, 0) 把 import 语句、TS 类型声明、var 静态常量搬到文件最前(index 0),使它们留在模块作用域(在 setup() 外)。这就是为什么 const A = 'foo' 能被宏参数引用(§9.5.5 的 LITERAL_CONST 例外)。
9.11.4 returned:非 inline 的绑定对象
第 9 步非 inline 分支(compileScript.ts:901-935)生成 return { ... }:
ts
const allBindings = { ...scriptBindings, ...setupBindings }
for (const key in ctx.userImports) {
if (!ctx.userImports[key].isType && ctx.userImports[key].isUsedInTemplate) {
allBindings[key] = true
}
}
returned = `{ `
for (const key in allBindings) {
if (allBindings[key] === true && ctx.userImports[key].source !== 'vue' &&
!ctx.userImports[key].source.endsWith('.vue')) {
// 为 import 绑定生成 getter(跳过 vue 与 .vue import)
returned += `get ${key}() { return ${key} }, `
} else if (ctx.bindingMetadata[key] === BindingTypes.SETUP_LET) {
// 局部 let 绑定:同时生成 getter + setter
const setArg = key === 'v' ? `_v` : `v`
returned += `get ${key}() { return ${key} }, ` + `set ${key}(${setArg}) { ${key} = ${setArg} }, `
} else {
returned += `${key}, `
}
}
returned = returned.replace(/, $/, '') + ` }`
三条规则:
- 非 vue/非
.vue的 import 绑定 → getter :因为被 import 的值的引用可能变化(HMR/循环依赖),用get key() { return key }保持活引用; SETUP_LET绑定 → getter + setter :因为模板里v-model可能要写回let变量,需要 setter 才能同步;- 其他 → 直接属性简写。
key === 'v'特判 :setter 参数名默认v,但若变量名本身就是v,参数也叫v会遮蔽,故改用_v。
随后注入 __isScriptSetup 标记(:999-1008):
ts
ctx.s.appendRight(endOffset,
`\nconst __returned__ = ${returned}\n` +
`Object.defineProperty(__returned__, '__isScriptSetup', { enumerable: false, value: true })\n` +
`return __returned__\n}\n\n`)
这个标记让 componentPublicInstance 的 proxy 放行 $/_ 开头的属性访问。
9.11.5 inline 模式:把 render 内联进 setup
inline 分支(:936-997):直接调 compileTemplate,并传 compilerOptions: { inline: true, isTS: ctx.isTS, bindingMetadata: ctx.bindingMetadata }。模板编译器在 inline 模式下会用 bindingMetadata 决定每个标识符的读取形式(foo.value / unref(foo) / __props.foo),生成的 render 函数直接使用 setup 作用域里的变量 (而非 $setup)。
returned = code(即 render 函数源码),最终 ctx.s.appendRight(endOffset, \nreturn ${returned}\n}\n\n)------直接把 render 函数 return 出去 ,省掉"绑定对象 + __isScriptSetup"这一层。这就是 inline 模式更快的原因:没有 proxy、没有中间对象。
inlineTemplate 与 ssr(:938-961)结合时会置 hasInlinedSsrRenderFn,并在第 10 步注入 __ssrInlineRender: true。
9.11.6 v-model 与 reactive const 降级(#11265)
第 6.1 步(:770-820)处理一个边界 case:const foo = reactive({}) 用作 v-model 目标时,模板会生成 $setup.foo = $event 或 foo = $event,而 const 不能被赋值。于是编译器把该 const 降级为 let:
ts
const toDemote = new Set<string>()
for (const id of vModelIds) {
if (setupBindings[id] === BindingTypes.SETUP_REACTIVE_CONST) toDemote.add(id)
}
...
ctx.s.overwrite(node.start! + startOffset, node.start! + startOffset + 'const'.length, 'let')
for (const id of demotedInDecl) {
setupBindings[id] = BindingTypes.SETUP_LET
ctx.bindingMetadata[id] = BindingTypes.SETUP_LET
warnOnce(`\`v-model\` cannot update a \`const\` reactive binding \`${id}\`. ...`)
}
vModelIds 由 resolveTemplateVModelIdentifiers(importUsageCheck.ts)从模板里扫出所有 v-model 的目标标识符。降级会 warnOnce 提示用户。
9.12 产物形态:rewriteDefault 与最终导出
9.12.1 rewriteDefault:export default → 变量
rewriteDefault.ts:7-21:
ts
export function rewriteDefault(input: string, as: string, parserPlugins?: ParserPlugin[]): string {
const ast = parse(input, { sourceType: 'module', plugins: resolveParserPlugins('js', parserPlugins) }).program.body
const s = new MagicString(input)
rewriteDefaultAST(ast, s, as)
return s.toString()
}
用途:当需要把组件定义放进模块主体 (而非作为 default export)时(例如 plugin-vue 要注入 HMR 代码),就把 export default {...} 改写成 const __sfc__ = {...}。
rewriteDefaultAST(:27-90)处理三种情形:
export default <expr>→const ${as} = <expr>(用s.overwrite(node.start, node.declaration.start, 'const __sfc__ = '))。export default class Foo(具名类)→ 改写为class Foo {}+const __sfc__ = Foo(s.overwrite(start, node.declaration.id.start, ' class ')+s.append('const __sfc__ = Foo'))。export { x as default }/export { default } from './x'→ 重写为import+const __sfc__ = ...(specifierEnd处理逗号边界)。- 无 default export →
s.append('\nconst __sfc__ = {}')。
hasDefaultExport(:92-106)先探测是否存在 default export,避免无谓改动。
9.12.2 第 10 步:最终默认导出的三种形态
compileScript.ts:1013-1084 把组件定稿:
ts
const genDefaultAs = options.genDefaultAs ? `const ${options.genDefaultAs} =` : `export default`
let runtimeOptions = ``
if (!ctx.hasDefaultExportName && filename && filename !== DEFAULT_FILENAME) {
const match = filename.match(/([^/\\]+)\.\w+$/)
if (match) runtimeOptions += `\n __name: '${match[1]}',`
}
if (hasInlinedSsrRenderFn) runtimeOptions += `\n __ssrInlineRender: true,`
if (propsDecl) runtimeOptions += `\n props: ${propsDecl},`
const emitsDecl = genRuntimeEmits(ctx)
if (emitsDecl) runtimeOptions += `\n emits: ${emitsDecl},`
三种导出形态:
① TS 分支(ctx.isTS) :用 defineComponent 包裹,通过对象展开合并 __default__(普通 script)与 defineOptions:
ts
ctx.s.prependLeft(startOffset,
`\n${genDefaultAs} /*@__PURE__*/${ctx.helper('defineComponent')}({${def}${runtimeOptions}\n ${hasAwait ? 'async ' : ''}setup(${args}) {\n${exposeCall}`)
ctx.s.appendRight(endOffset, `})`)
产物:
js
export default /*@__PURE__*/_defineComponent({
...__default__,
...{/* defineOptions 内容 */},
__name: 'Foo',
props: {...},
emits: [...],
setup(__props, { expose, emit }) {
...
}
})
为什么只有 TS 用 defineComponent? 因为 TS 下需要保留组件选项对象的类型信息(defineComponent 有重载);JS 不需要。
② JS 分支 + 有 __default__ 或 defineOptions :用 Object.assign(JS 无法安全用对象展开做类型合并,且 Object.assign 语义等价):
js
export default /*@__PURE__*/Object.assign(__default__, {/* defineOptions */}, {
__name: 'Foo',
props: {...},
setup(__props, { expose, emit }) { ... }
})
③ JS 分支 + 无任何额外选项:直接对象字面量:
js
export default {
__name: 'Foo',
props: {...},
setup(__props, { expose, emit }) { ... }
}
genDefaultAs(compileScript.ts:104-109)是给 plugin-vue 用的:传 '__sfc__' 就会生成 const __sfc__ = {...} 而非 export default,方便插件把定义放进模块主体再做 HMR。
9.12.3 setup 签名与 helper import
setup 参数(第 8 步 :841-890):
ts
let args = `__props`
if (ctx.propsTypeDecl) args += `: any` // TS 类型声明时 props 标 any
if (ctx.propsDecl) {
if (ctx.propsDestructureRestId) {
ctx.s.overwrite(startOffset + ctx.propsCall!.start!, startOffset + ctx.propsCall!.end!,
`${ctx.helper('createPropsRestProxy')}(__props, ${JSON.stringify(Object.keys(ctx.propsDestructuredBindings))})`)
ctx.s.overwrite(startOffset + ctx.propsDestructureDecl!.start!, startOffset + ctx.propsDestructureDecl!.end!,
ctx.propsDestructureRestId)
} else if (!ctx.propsDestructureDecl) {
ctx.s.overwrite(startOffset + ctx.propsCall!.start!, startOffset + ctx.propsCall!.end!, '__props')
}
}
- 无类型 →
setup(__props);有类型声明 →setup(__props: any); const props = defineProps(...)→defineProps(...)替换成__props(即const props = __props);- 有解构 rest →
const { a, ...rest } = defineProps(...)变成const { a, ...rest } = _createPropsRestProxy(__props, ["a"])。
解构参数(:881-890):destructureElements = ctx.hasDefineExposeCall || !options.inlineTemplate ? ['expose: __expose'] : [],再按需加 emit: __emit。于是 setup(__props, { expose: __expose, emit: __emit })。
第 11 步注入 helper import(:1086-1098):
ts
if (ctx.helperImports.size > 0) {
const runtimeModuleName = options.templateOptions?.compilerOptions?.runtimeModuleName
const importSrc = runtimeModuleName ? JSON.stringify(runtimeModuleName) : `'vue'`
ctx.s.prepend(
`import { ${[...ctx.helperImports].map(h => `${h} as _${h}`).join(', ')} } from ${importSrc}\n`
)
}
即 import { useModel as _useModel, mergeModels as _mergeModels, ... } from 'vue'。runtimeModuleName 允许把 vue 换成自定义运行时模块名。
9.12.4 TS/JSX 输出差异总览
| 维度 | TS(lang="ts"/"tsx") |
JS(无 lang 或 js/jsx) |
|---|---|---|
| 默认导出 | defineComponent({...}) |
对象字面量 / Object.assign(...) |
合并 __default__/defineOptions |
对象展开 ... |
Object.assign |
setup 参数 |
setup(__props: any, ...)(有类型声明时) |
setup(__props, ...) |
| 顶层 await | async setup(...) + 注入 let __temp, __restore |
同 |
| 类型声明 | 全部提升到 setup 外 | 无 |
| defineModel options 合并 | { type, ...options } |
Object.assign({ type }, options) |
| 未使用 import 裁剪 | 是(TS 特性) | 否 |
9.12.5 顶层 await 的处理
<script setup> 里用 await 时,hasAwait 置真,setup 变 async setup(...)(compileScript.ts:1052-1081)。并且注入两个临时变量(:876-879):
ts
if (hasAwait) {
const any = ctx.isTS ? `: any` : ``
ctx.s.prependLeft(startOffset, `\nlet __temp${any}, __restore${any}\n`)
}
processAwait(script/topLevelAwait.ts)会把每个顶层 await 改写成"保存响应式上下文 → await → 恢复"的三段式,__temp/__restore 就是这两个临时变量,用于跨 await 边界保留当前组件实例的响应式上下文(currentInstance)。
9.13 实战:一个综合 <script setup> 的完整产物
9.13.1 输入
vue
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
title: string
count?: number
}>()
const { count = 0 } = props
const emit = defineEmits<{
change: [value: number]
}>()
const model = defineModel<string>({ required: true })
const doubled = ref(count * 2)
function increment() {
emit('change', count + 1)
}
</script>
9.13.2 宏处理顺序(编译期)
defineProps收集:propsTypeDecl = { title: string; count?: number };propsDecl=props;无 runtime 声明。const { count = 0 } = props→propsDestructureDecl,propsDestructuredBindings = { count: { local: 'count', default: 0 } }。defineEmits收集:emitsTypeDecl;emitDecl = 'emit'。defineModel收集:modelDecls.modelValue = { type: string, options: '{ required: true }', identifier: 'model' },且改写为_useModel(__props, "modelValue")。transformDestructuredProps:把count * 2、count + 1里的count改写成__props.count。checkInvalidScopeReference:{ required: true }无 setup 引用,通过。walkDeclaration:doubled→SETUP_REF(ref()调用);model→SETUP_REF(defineModel);increment→SETUP_CONST;count是解构 prop(标记在propsDestructuredBindings)。
9.13.3 生成产物(非 prod、非 inline)
js
import { useModel as _useModel, mergeModels as _mergeModels } from 'vue'
const __default__ = {} // 无普通 <script>,实际不生成;此处示意
export default /*@__PURE__*/_defineComponent({
__name: 'Demo',
props: /*@__PURE__*/_mergeModels({
title: { type: String, required: true },
count: { type: Number, required: false, default: 0 }
}, {
"modelValue": { type: String, ...{ required: true } },
"modelModifiers": {},
}),
emits: ["change", /*@__PURE__*/_mergeModels([], ["update:modelValue"])],
setup(__props: any, { expose: __expose, emit: __emit }) {
__expose();
const props = __props
const model = _useModel(__props, "modelValue")
const doubled = ref(__props.count * 2)
function increment() {
__emit('change', __props.count + 1)
}
const __returned__ = { props, model, doubled, increment, get count() { return __props.count } }
Object.defineProperty(__returned__, '__isScriptSetup', { enumerable: false, value: true })
return __returned__
}
})
9.13.4 逐段解读
import { useModel as _useModel, mergeModels as _mergeModels }:helperImports收集的结果,别名带_前缀防冲突。props:类型路径 →extractRuntimeProps推断出title: String(必填)、count: Number(可选,默认值0来自解构);再与defineModel的 props 通过mergeModels合并。emits:类型路径提取出"change";defineModel追加"update:modelValue";两者用mergeModels拼装。setup(__props: any, { expose: __expose, emit: __emit }):因有类型声明,props 标any(类型只在编译期校验);注入expose与emit。__expose():用户没调defineExpose,自动注入空 expose(组件默认封闭)。const props = __props:defineProps(...)调用被替换成__props。const model = _useModel(__props, "modelValue"):options 被移入 props,调用改写为useModel。ref(__props.count * 2):解构 propcount的所有引用被改写为__props.count,保持响应式。__emit('change', __props.count + 1):emit(...)被替换为__emit。return { props, model, doubled, increment, get count() {...} }:非 inline 绑定对象。count是解构 prop,故补get count();doubled/model是 ref,模板端会unref/.value。
若去掉 lang="ts"(改 runtime 声明),则默认导出变为裸对象字面量 、setup(__props) 无 : any、无 TS 类型提升------其余宏改写逻辑不变。
9.14 本章要点回顾
9.14.1 十二个核心结论
parse复用模板 parser (parseMode: 'sfc'),把顶层标签切成SFCDescriptor;template/script/scriptSetup单值、styles/customBlocks数组。- 块边界靠
node.innerLoc,content = source.slice(innerLoc.start.offset, innerLoc.end.offset);pad让 script/style 内容行号与原文件对齐,pug让模板块去缩进并记录列偏移。 compileScript全程用MagicString对整份 source 做就地改写 ,Babel 相对偏移 +startOffset/scriptStartOffset是全部 offset 换算的基础。parseCache用genCacheKey(source, normalizedOptions)做键 ,内容变则键变,天然失效;跨文件类型依赖通过deps+invalidateTypeCache做 HMR 失效。ScriptCompileContext承载全部中间态 ,helper()把运行时 helper 收进helperImports并返回_前缀别名,最后统一 import。- 宏识别发生在 AST 层 (
isCallOf裸标识符调用),不依赖运行时值;MACROS清单既用于剔除 import,也用于识别"宏不需 import"。 defineProps有 runtime / type / 解构三条路径 :runtime 原样切片,type 走resolveType,解构走手写作用域模拟器把foo改写成__props.foo。defineEmits类型路径只提取事件名,不生成校验函数;runtime 对象写法才带参数校验。defineModel是最"横切"的宏 :同时改 props、emits、bindingMetadata 与 setup 代码,并额外生成xxxModifiersprop。defineExpose/defineSlots只做标记与替换 (__expose/_useSlots()),defineOptions合并进组件定义。resolveType.inferRuntimeTypesoft fail :无法推断的类型返回UNKNOWN_TYPE,再由resolveRuntimePropsFromType降级为type: null(或skipCheck),保证编译不中断。- 非 inline 返回绑定对象 +
__isScriptSetup;inline 直接把 render 内联进setup()并 return。
9.14.2 面试深度问答
Q1:为什么宏(defineProps 等)不需要 import 也能用? A:宏的识别完全发生在 compiler-sfc 的编译期,靠 isCallOf(node, 'defineProps') 匹配 AST 上的裸标识符调用,不依赖运行时是否存在该函数。切到浏览器运行时,宏调用已被改写/删除,因此不需要真实实现。MACROS 清单还负责把用户写的 import { defineProps } from 'vue' 剔除。
Q2:defineProps 的 props 键为什么要在 bindingMetadata 里登记两次? A:第一次在 processDefineProps 登记 runtime 声明的键(AST 可见);第二次在 extractRuntimeProps 里登记类型声明的键(需类型解析后才可知)。两处都判断 !(key in bindingMetadata) 以避免覆盖用户变量。
Q3:解构 prop 为什么能保持响应式? A:3.5 的响应式解构(Reactive Props Destructure)在编译期把所有对解构变量的引用 改写为 __props.foo,因此读的是最新 props 值,天然跟随更新。代价是引用不能被赋值(编译期会报错"不能给解构 prop 赋值")。
Q4:为什么复杂类型(如条件类型、跨库泛型工具)会落到 Unknown? A:inferRuntimeType 的 try/catch 保证"推断失败不抛错",兜底返回 [UNKNOWN_TYPE]。因为运行时类型只是开发期的尽力校验,不是正确性的必要条件;宁可少校验,也不能让编译失败。
Q5:inline 与 non-inline 的取舍? A:inline 省掉绑定对象与 __isScriptSetup 代理层,且 render 直接闭包 setup 变量,更快------但模板无法独立于 setup 状态热更新,所以只适合生产。开发态用 non-inline,模板改动可单独 HMR。
Q6:withDefaults 的解构用法为什么被建议弃用? A:withDefaults 与"解构默认值"两套机制会冲突:一旦用 withDefaults,processDefineProps 会跳过 processPropsDestructure(isWithDefaults 为真),解构默认值不再被处理。故编译器会 warning,推荐统一用 const { foo = 1 } = defineProps<...>()。