Vue 3 核心源码深度剖析系列(v3.5.44)-第 09 章 编译器内幕(五):compiler-sfc的SFC解析与<script setup> 编译

Vue 3 核心源码深度剖析系列(v3.5.44)-第 09 章 编译器内幕(五):compiler-sfc 的 SFC 解析与 <script setup> 编译

本章对应 vuejs/core 3.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-dom DOM 特化层)讲的是"给定一段模板字符串,如何编译成 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 的注释里说得很清楚:

xml 复制代码
It 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。模板不再单独热更新(所以只适合生产)。
flowchart TD A[&#34;.vue 源码字符串&#34;] -->|parse| B[&#34;SFCDescriptor<br/>template/script/scriptSetup/styles/customBlocks&#34;] B -->|&#34;compileScript(sfc, opts)&#34;| C{&#34;inlineTemplate?&#34;} C -->|&#34;false(默认/开发)&#34;| D[&#34;setup() 返回绑定对象<br/>+ __isScriptSetup 标记&#34;] C -->|&#34;true(生产)&#34;| E[&#34;compileTemplate(inline:true)<br/>render 内联进 setup()&#34;] D --> F[&#34;导出 SFCScriptBlock.content<br/>export default { setup, props, emits }&#34;] E --> F B -->|&#34;compileTemplate(bindings)&#34;| G[&#34;独立 render 函数字符串&#34;] B -->|&#34;compileStyle(scoped/module)&#34;| H[&#34;data-v-xxx 作用域 CSS&#34;] F --> I[&#34;plugin-vue / vue-loader 拼装&#34;] G --> I H --> I

9.1.4 为什么 compiler-sfc 要单独成包

一个常见的疑问是:为什么不把这些逻辑塞进 @vue/compiler-dom?答案是依赖方向 。compiler-sfc 需要依赖:

  1. @babel/parser(解析 <script> 里的 JS/TS)------这是 compiler-core 都不需要的重依赖;
  2. typescript 作为 peerDependency(解析 TS 类型、模块解析)------只在 Node 端可用;
  3. magic-string(做"精确到字符的改写"而非重新生成代码);
  4. postcss(compileStyle 处理 CSS);
  5. 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__ 之外的环境直接报错:

python 复制代码
Type 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 的裁剪结果变化

注意三个关键设计:

  1. script 与 scriptSetup 是分开的两个字段 ,而不是一个"脚本列表"。这是为了后续 compileScript 能分别处理"普通脚本提供 export default 选项"与"<script setup> 提供组件主体"两条流水线。
  2. styles 是数组,template/script/scriptSetup 是单值 。SFC 规范允许一个 template、一个 script、一个 script setup、多个 style、多个自定义块。
  3. 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
  }
})

几个关键点:

  1. loc = node.innerLoc :innerLoc 是 <template> 标签内部 的内容范围(即 > 之后到 </template> 之前),而非整个元素的 loc。这保证了 content 不含外层标签本身。
  2. attrs 的值为 string | true :布尔属性(如 setup、scoped)值为 true;有值属性(如 lang="ts")为其字符串内容。p.value.content || true 处理了 lang=""(空字符串 → true)。
  3. 属性特判(按块类型分派):
属性 适用块 落到哪个字段 语义
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:

ts 复制代码
block.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

设计动机值得强调:

  1. jsx 与 TS 的互斥处理 :JSX 在 .vue 里只在 lang="jsx"/"tsx" 时启用。若用户 plugins 里带了 jsx 但 lang 不是 JSX 系,主动剔除,否则 JS/TS 的泛型/类型断言会和 JSX 语法冲突。
  2. decorators-legacy 兜底 :TS 场景默认开 legacy 装饰器(@Component 风格),但如果用户显式给了 decorators 就尊重用户。
  3. 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()

算法要点:

  1. emptyRE = /^(?:\/\/)?\s*$/ :忽略"空行"和"只有 // 的行"------这正是 pad 补出来的 //\n 填充行,它们本就不对应原文的任何 token。
  2. 逐字符遍历,只对非空白字符加 mapping:白空格不产生映射(省体积)。
  3. originalLine = index + 1 + lineOffset :把生成行(相对块内容)平移为原文行;lineOffset 按 §9.2.5 的规则取值。
  4. originalColumn = i + columnOffset:列偏移用于 pug dedent 的补偿。
  5. 直接操作 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)做三件事:

  1. 判定语言 (context.ts:91-92):
ts 复制代码
this.isJS = isJS(scriptLang, scriptSetupLang)
this.isTS = isTS(scriptLang, scriptSetupLang)

其中 isJS/isTS(utils.ts:141-144)是"任一 lang 命中"的 some 判定。

  1. 判定自定义元素 (context.ts:94-101):customElement 可为 boolean 或 (filename) => boolean。

  2. 解析两个脚本块 (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'
]

这个清单在两个地方使用:

  1. 剔除 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 起收紧。

  1. 宏调用检测 :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
  }
}

三条路径:

  1. runtime 声明存在 (propsRuntimeDecl):直接把用户写的字面量原样切片 (ctx.getString(...).trim())------不重新生成。若同时有解构默认值,则用 mergeDefaults 包装(把默认值并进去)。
  2. 类型声明存在 (propsTypeDecl):走 extractRuntimeProps(§9.10)从 TS 类型推导出运行时声明。
  3. 无 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。要点:

  1. 配置三态 (ctx.options.propsDestructure):'error' → 直接报错;false → 不做解构转换(退回 3.5 前行为);true/未设 → 启用。默认启用(SFCScriptCompileOptions 注释:@default true)。
  2. 只支持一层解构 :const { a } = defineProps() 或 const { a = 1 } = ...;嵌套 { a: { b } } 报错;computed 键报错。
  3. 别名映射 :{ foo: bar }(local !== key)会写 bindingMetadata[bar] = PROPS_ALIASED 并记录 __propsAliases[bar] = 'foo',供模板编译时把 bar 映射回 foo。
  4. 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。两种类型形态:

  1. 函数类型 :defineEmits<(e: 'change') => void>()------直接取第一个参数 e 的字面量联合。
  2. 类型字面量 (推荐):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:

ts 复制代码
for (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 ? `` : `, `}`),
)

三处改写:

  1. defineModel 标识符 → _useModel(仅替换 callee,不删整句);
  2. 在第一个参数前注入 __props, ------因为 useModel(props, name, options, modifiers) 的第一个参数是 props 对象;
  3. 若无名字,注入 "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  }`
}

要点:

  1. 类型 → 运行时类型 (inferRuntimeType),与 defineProps 的类型路径同源(§9.10)。
  2. UNKNOWN_TYPE 处理与 props 相同 :含 Boolean/Function 则过滤掉 unknown 并 skipCheck;否则退化为 ['null']。
  3. 生产模式的类型丢弃规则 :非 prod 保留完整 type;prod 只在"含 Boolean"或"有运行时 options 且含 Function"时保留 type,否则丢弃(与 genRuntimePropFromType 的策略一致)。
  4. TS/JS 合并 options 的方式不同 :TS 用对象展开 { type, ...runtimeOptions }(保留类型),JS 用 Object.assign(...)(因为 JS 不支持对象展开的类型友好写法 / 历史原因)。
  5. 每个模型额外生成一个修饰符 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' }) 最终进入组件定义对象。

defineOptions vs 普通 <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)------这是最长的一个分支:

  1. 先尝试 resolveTypeReference (有 try/catch):
    • 若是函数类型别名(type Fn = () => void)→ ['Function'](#13240 特判);
    • 若有类型实参且解析对象有类型形参 → 递归替换实参后再推断;
    • 否则递归推断解析出的声明。
  2. 解析失败时按名字兜底 (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 或报错):

  1. 外部库的泛型工具类型 :名字不在内置表里且解析不到时,resolveTypeElements 直接 ctx.error('Unresolvable type reference or unsupported built-in utility type');
  2. 映射类型的任意形态 :只支持 { [K in keyof T]: T[K] };
  3. 条件类型(Conditional Type):无分支处理,落 unknown;
  4. 模板字面量类型 :TSLiteralType 的 TemplateLiteral 分支落到 default(unknown)------emits 里也明确排除;
  5. 负数/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)
}

判定优先级(自上而下):

  1. LITERAL_CONST :const + isStaticNode(纯静态表达式,如 1 + 2、'a'、[1,2,3] 里的字面量)。isStaticNode (:1343-1378)递归判定 Unary/Binary/Conditional/Sequence/Template(要求插值也是静态)及五种字面量。
  2. reactive() 调用 :const x = reactive({}) → SETUP_REACTIVE_CONST(标记为"可变但非 ref");let x = reactive({}) → SETUP_LET。
  3. 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。
  4. const + ref 类 API (ref/computed/shallowRef/customRef/toRef/useTemplateRef/defineModel)→ SETUP_REF。
  5. 其他 const → SETUP_MAYBE_REF。
  6. 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(/, $/, '') + ` }`

三条规则:

  1. 非 vue/非 .vue 的 import 绑定 → getter :因为被 import 的值的引用可能变化(HMR/循环依赖),用 get key() { return key } 保持活引用;
  2. SETUP_LET 绑定 → getter + setter :因为模板里 v-model 可能要写回 let 变量,需要 setter 才能同步;
  3. 其他 → 直接属性简写。

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)处理三种情形:

  1. export default <expr> → const ${as} = <expr>(用 s.overwrite(node.start, node.declaration.start, 'const __sfc__ = '))。
  2. export default class Foo (具名类)→ 改写为 class Foo {} + const __sfc__ = Foo(s.overwrite(start, node.declaration.id.start, ' class ') + s.append('const __sfc__ = Foo'))。
  3. export { x as default } / export { default } from './x' → 重写为 import + const __sfc__ = ...(specifierEnd 处理逗号边界)。
  4. 无 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 宏处理顺序(编译期)

  1. defineProps 收集:propsTypeDecl = { title: string; count?: number };propsDecl = props;无 runtime 声明。
  2. const { count = 0 } = props → propsDestructureDecl,propsDestructuredBindings = { count: { local: 'count', default: 0 } }。
  3. defineEmits 收集:emitsTypeDecl;emitDecl = 'emit'。
  4. defineModel 收集:modelDecls.modelValue = { type: string, options: '{ required: true }', identifier: 'model' },且改写为 _useModel(__props, "modelValue")。
  5. transformDestructuredProps:把 count * 2、count + 1 里的 count 改写成 __props.count。
  6. checkInvalidScopeReference:{ required: true } 无 setup 引用,通过。
  7. 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 逐段解读

  1. import { useModel as _useModel, mergeModels as _mergeModels } :helperImports 收集的结果,别名带 _ 前缀防冲突。
  2. props :类型路径 → extractRuntimeProps 推断出 title: String(必填)、count: Number(可选,默认值 0 来自解构);再与 defineModel 的 props 通过 mergeModels 合并。
  3. emits :类型路径提取出 "change";defineModel 追加 "update:modelValue";两者用 mergeModels 拼装。
  4. setup(__props: any, { expose: __expose, emit: __emit }) :因有类型声明,props 标 any(类型只在编译期校验);注入 expose 与 emit。
  5. __expose() :用户没调 defineExpose,自动注入空 expose(组件默认封闭)。
  6. const props = __props :defineProps(...) 调用被替换成 __props。
  7. const model = _useModel(__props, "modelValue") :options 被移入 props,调用改写为 useModel。
  8. ref(__props.count * 2) :解构 prop count 的所有引用被改写为 __props.count,保持响应式。
  9. __emit('change', __props.count + 1) :emit(...) 被替换为 __emit。
  10. 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 十二个核心结论

  1. parse 复用模板 parser (parseMode: 'sfc'),把顶层标签切成 SFCDescriptor;template/script/scriptSetup 单值、styles/customBlocks 数组。
  2. 块边界靠 node.innerLoc ,content = source.slice(innerLoc.start.offset, innerLoc.end.offset);pad 让 script/style 内容行号与原文件对齐,pug 让模板块去缩进并记录列偏移。
  3. compileScript 全程用 MagicString 对整份 source 做就地改写 ,Babel 相对偏移 + startOffset/scriptStartOffset 是全部 offset 换算的基础。
  4. parseCache 用 genCacheKey(source, normalizedOptions) 做键 ,内容变则键变,天然失效;跨文件类型依赖通过 deps + invalidateTypeCache 做 HMR 失效。
  5. ScriptCompileContext 承载全部中间态 ,helper() 把运行时 helper 收进 helperImports 并返回 _ 前缀别名,最后统一 import。
  6. 宏识别发生在 AST 层 (isCallOf 裸标识符调用),不依赖运行时值;MACROS 清单既用于剔除 import,也用于识别"宏不需 import"。
  7. defineProps 有 runtime / type / 解构三条路径 :runtime 原样切片,type 走 resolveType,解构走手写作用域模拟器把 foo 改写成 __props.foo。
  8. defineEmits 类型路径只提取事件名,不生成校验函数;runtime 对象写法才带参数校验。
  9. defineModel 是最"横切"的宏 :同时改 props、emits、bindingMetadata 与 setup 代码,并额外生成 xxxModifiers prop。
  10. defineExpose/defineSlots 只做标记与替换 (__expose / _useSlots()),defineOptions 合并进组件定义。
  11. resolveType.inferRuntimeType soft fail :无法推断的类型返回 UNKNOWN_TYPE,再由 resolveRuntimePropsFromType 降级为 type: null(或 skipCheck),保证编译不中断。
  12. 非 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<...>()。

相关推荐
码银1 小时前
【web】基于HTML/CSS,JavaScript + Three.js做3D流程图
前端·css·html
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 17 章 运行时内幕(五):`runtime-dom`、自定义元素与内置组件
前端·vue.js·开源
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 13 章 运行时组件系统:实例、props、emit、slots 与生命周期
前端·vue.js·开源
Highcharts.js1 小时前
Highcharts React v5 正式发布:支持 ESM、更简洁的组件、更出色的开发体验
前端·react.js·前端框架·数据可视化·highcharts·前端可视化·esm
鶴哥只手遮天1 小时前
Vue 3 核心源码深度剖析系列(v3.5.44)-第 10 章 compiler-sfc(二)
前端·vue.js·开源
will_net1 小时前
Vue IIS部署 api 访问当前站点 代理跳转真实API 问题
前端·javascript·vue.js
weixin_449173651 小时前
react 的useEffect用法
前端·javascript·react.js
55873 生态系统1 小时前
九层之台,生态为体 —— 论 “55873 全域文明生态系统“ 与第 1 期 2000 多册开源大典
开源·55873全域文明生态体系·55873操作系统·全域文明生态系统·55873 数据开源大典