Vue 3 编译器宏的编译时魔法:defineModel、defineSlots 与 AST 转换的真相
这玩意儿不是函数
你第一次写 defineModel() 的时候,有没有好奇过------这个函数从哪 import 的?
vue
<script setup lang="ts">
// 没有 import,直接用,IDE 不报错,运行也没问题
const modelValue = defineModel<string>()
</script>
不需要 import。你翻遍 node_modules/vue 的运行时代码也找不到 defineModel 这个导出,defineProps 找不到,defineEmits 找不到,defineSlots 还是找不到。运行时压根不存在这些东西。
那它怎么跑起来的?
没跑过。
拿 C 语言打个比方吧,#define 展开之后预处理器指令本身就没了对吧,Vue 的编译器宏思路差不多,只不过操作对象从文本替换变成了 AST 节点转换。(说实话这个类比也不完全准确,毕竟 C 的宏是纯文本层面的,Vue 的宏操作的是语法树,精确度差了好几个量级。)
这部分我自己也不太确定。
踩过坑。我之前在一个项目里试图在普通 .ts 文件里调用 defineProps------不在 .vue 的 <script setup> 里面------结果运行时直接炸了,控制台甩了个 defineProps is not defined,当时愣了好几秒才反应过来:这些宏只在 SFC 编译流程里才会被处理,脱离了 @vue/compiler-sfc,它们就是 undefined 变量。就这么简单。
Vue 的 SFC 编译管线长这样:
scss
.vue 文件
↓
@vue/compiler-sfc(parse)
↓
SFCDescriptor { template, script, scriptSetup, styles }
↓
compileScript() ← 编译器宏在这一步被处理
↓
├── 识别 defineProps / defineEmits / defineModel / defineSlots 调用
├── 提取参数(含泛型类型参数)
├── 生成对应的运行时代码(props 声明、emit 声明等)
└── 从 AST 中删除原始调用节点
↓
编译后的 <script> 代码(纯 JS/TS,不含任何宏调用)
↓
compileTemplate() → render 函数
↓
最终组件对象
关键在 compileScript() 这一步。它拿到 <script setup> 的源码之后,先用 @babel/parser(TS 文件会带上 typescript 插件)把源码转成 AST,然后遍历整棵树找宏调用,逐个处理,处理完就把原始节点从 AST 上摘掉。干净利落。最终产物里你看不到任何 defineModel 或 defineSlots 的痕迹。
defineModel 的转换:一个宏干了三件事
defineModel 是 3.4 才稳定下来的,之前挂着 experimental 的标签晃了好久。它要解决的问题很具体------v-model 双向绑定在子组件里写起来实在太啰嗦了(你没看错)。
嗯,继续。
没有 defineModel 的时候你得这么写,props 接一个 modelValue,再手动 emit('update:modelValue', val) 把值抛回去:
ts
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
function updateValue(val: string) {
emit('update:modelValue', val) // 每个 v-model 都要来这么一遍
}
烦不烦?烦。
有了 defineModel 之后一行搞定,返回一个 Ref,读写直接操作,修改时自动触发 update:modelValue 事件:
ts
const modelValue = defineModel<string>()
modelValue.value = 'new value' // 自动 emit
但这个"一行搞定"背后编译器干了不少活。我当时以为它就是个薄薄的语法糖------这个等下再说,看了编译产物才发现生成的东西比想象中多得多。
defineModel<string>() 编译后大概变成这样:
ts
import { useModel as _useModel } from 'vue'
const __props = defineProps({
modelValue: { type: String }, // 从泛型参数推断出来的
})
const emit = defineEmits(['update:modelValue']) // 自动生成
const modelValue = _useModel(__props, 'modelValue')
一个宏调用,编译器干了三件事:生成 props 声明、生成 emit 声明、把原始调用替换成 useModel()。这里面最有意思的是第一件------它怎么从泛型参数 string 推断出 { type: String } 的?
这部分我自己也不太确定。
AST 分析。编译器处理 defineModel 的 CallExpression 节点时会去读 typeParameters,也就是尖括号里那个类型参数。TSStringKeyword 映射到运行时的 String 构造函数,TSNumberKeyword 映射到 Number,碰到联合类型 string | number 就映射成 [String, Number]------不对,说"映射"不太准确。编译器做的不是字符串替换,它是真的在分析 TypeScript 的类型 AST 节点,碰到接口或类型别名会递归地去解析定义,把每个属性都提取出来。
这部分我自己也不太确定。
这套类型提取的逻辑在 compiler-sfc 的 resolveType 模块里。代码量挺大的。因为要处理的边界情况多到离谱------交叉类型、条件类型、Pick<T, K>、Omit<T, K>......我翻过那部分源码,翻到一半开始怀疑人生,因为 TS 的类型系统本身就是图灵完备的,你不可能在编译器里完整模拟 tsc 的类型推导。真没救了。Vue 的编译器最后选了个务实的策略:覆盖常见场景,碰到太复杂的类型直接 fallback 到 null,不做检查,让运行时自己处理。
defineModel 还支持命名模型:
ts
// 对应父组件的 v-model:title
const title = defineModel<string>('title', {
required: true,
default: 'untitled',
})
// 编译后 → props 多一个 title 字段,emit 多一个 update:title
这里有个细节容易踩坑。defineModel 的第一个参数如果是字符串就当 prop 名,如果是对象就当选项,两个都传的话第一个是名字第二个是选项。编译器在 AST 层面靠 arguments 的节点类型来区分------StringLiteral 就是名字,ObjectExpression 就是选项。粗暴但有效。
defineSlots 和自定义宏:编译器的可扩展性
defineSlots 嘛,说白了就是个纯类型层面的工具,运行时几乎什么都不做。
ts
const slots = defineSlots<{
default(props: { item: string }): any
header(props: { title: string }): any
}>()
编译后就剩这么一行:
ts
const slots = __setupGetSlots() // 等价于 useSlots()
完了。泛型参数 <{ default(...): any; header(...): any }> 被编译器吃掉了,但不是扔掉了------它被提取出来写入了组件的类型声明。defineSlots 的价值全在类型系统这边,它告诉 TS 和 IDE 这个组件接受哪些 slot、每个 slot 的 props 类型长什么样,这样你在模板里写 <slot name="header" :title="xxx" /> 的时候如果 title 类型不对,Volar 能立刻给你画红线。跟运行时没有半毛钱关系。
那 Vue 的编译器宏系统能不能扩展?能不能写自定义宏?
能。但比你想的复杂。
Vue 官方并没有提供一个"注册自定义宏"的正式 API,(虽然这个设计决策我觉得有点保守了。)但 @vue/compiler-sfc 的 compileScript() 接受选项,你可以通过 Vite 插件拦截 .vue 文件的编译过程,在 AST 层面做自定义转换。社区的 unplugin-vue-macros 就是这么干的,它提供了 defineOptions(后来被 Vue 3.3 官方收编了)、defineRender、shortEmits 之类 Vue 核心没有的宏。
核心实现思路大概是这样的:
ts
// 自定义宏插件核心思路(极简版)
import { parse } from '@vue/compiler-sfc'
import MagicString from 'magic-string'
function transformCustomMacro(code: string, id: string) {
if (!id.endsWith('.vue')) return
const { descriptor } = parse(code)
const scriptSetup = descriptor.scriptSetup
if (!scriptSetup) return
const s = new MagicString(code)
const ast = babelParse(scriptSetup.content, {
plugins: ['typescript'],
sourceType: 'module',
})
walk(ast, {
enter(node) {
if (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === 'defineCustomThing'
) {
const arg = node.arguments[0]
const replacement = generateRuntimeCode(arg)
s.overwrite(
node.start + scriptSetup.loc.start.offset,
node.end + scriptSetup.loc.start.offset,
replacement
)
}
},
})
return s.toString()
}
为什么用 MagicString 而不是操作完 AST 再序列化回代码?因为 AST → 代码的 codegen 会丢格式信息,注释没了,空行没了,sourcemap 也不好生成。MagicString 是在原始源码上做精确的位置替换,能自动产出正确的 sourcemap(当然这是理想情况)。Vue 官方编译器内部也大量依赖这个库。
自定义宏的 AST 转换流程:
java
Vite transform 钩子拦截 .vue 文件
↓
@vue/compiler-sfc parse() → SFCDescriptor
↓
提取 <script setup> 的 content
↓
@babel/parser → AST
↓
遍历 AST,找到自定义宏的 CallExpression
↓
├── 提取参数(字面量、对象、泛型类型参数)
├── 根据参数生成运行时代码
└── 用 MagicString 替换原始代码位置
↓
返回修改后的源码 + sourcemap
↓
Vue 官方编译器继续处理(compileScript、compileTemplate)
这里有个关键问题:自定义宏的转换该在 Vue 官方编译器之前跑还是之后跑?
vue-macros 选的是之前。道理很简单------你的自定义宏生成的代码里可能包含 defineProps 之类的官方宏调用,得让官方编译器在后面再处理一轮。反过来的话,官方编译器先跑完了,你的自定义宏还杵在代码里没被处理,到运行时就炸。也行。(而且报错信息会非常迷惑,根本看不出来是宏没展开导致的。嗯......也不完全是,错信息会非常迷惑,根本看不出来是宏没展开导致的。)
但这也带来麻烦:你的插件修改了 <script setup> 的内容,改完之后源码行号偏移了,官方编译器还在用原始的 loc 信息定位,sourcemap 就可能错位,debugger 断点打不准。MagicString 能追踪偏移量所以一定程度上缓解了这个问题,但如果你插入了好几十行代码,准确性还是会下降。我用 vue-macros 的 reactivityTransform 时碰到过行号对不上的情况。折腾了半天。
再说类型推导这块。自定义宏要让 TS 和 IDE 认识你的宏------先别急着反驳,得提供类型声明文件。还行。哦不,准确说是要让 TS 和 IDE 认识你的宏,得提供类型声明文件。官方宏的类型声明在 vue/macros-global.d.ts 里:
ts
// vue/macros-global.d.ts(简化)
declare function defineModel<T>(
name?: string,
options?: { required?: boolean; default?: T }
): import('vue').Ref<T>
declare function defineSlots<
S extends Record<string, (...args: any[]) => any>
>(): S
// 这些声明没有实现体
// 纯粹给 TS 看的,运行时不存在
这套机制本质上就是在骗 TypeScript。TS 看到全局 declare function,以为函数真的存在,于是愉快地做类型检查和推导,编译器在编译阶段把调用全替换掉了,运行时根本不会执行到这些"函数"。这种"编译时存在、运行时消失"的模式跟 TS 自身的 type 和 interface 思路其实一脉相承,只不过 Vue 把它推到了值层面------你声明的不是一个类型而是一个函数签名,但效果一样,都是编译完就没了。
自定义宏要做同样的事就得自己写 .d.ts:
ts
declare function defineCustomThing<T extends Record<string, any>>(
config: T
): ComputedRef<T>
然后在 tsconfig.json 的 types 里引入。有个坑------如果你的宏编译后生成的代码跟 .d.ts 里声明的返回类型对不上,TS 不会报错(它只看声明文件),但运行时行为就跟类型标注脱节了。声明文件和编译转换之间的一致性全靠你自己保证,没有任何自动化校验(虽然官方文档不是这么说的)。手动维护。
这也是 Vue 官方对新增编译器宏非常谨慎的原因。
关于可扩展性多说一嘴。Vue 的编译器宏系统虽然没有正式的插件 API,但架构上是有扩展空间的。compileScript() 内部对宏的处理是分步的------先扫描所有宏调用收集信息,再统一生成代码。如果未来要做正式的宏插件系统,接口大概长这样:
ts
// 假想中的宏插件 API(纯猜测,Vue 目前没有)
interface MacroPlugin {
name: string
match: (node: CallExpression) => boolean
extract: (node: CallExpression, ctx: MacroContext) => MacroResult
generate: (result: MacroResult) => string
typeDeclaration?: () => string
}
但我觉得短期内不太可能。宏这种东西一旦开放,社区会造出一堆奇奇怪怪的用法,维护成本和兼容性问题会指数级增长。Vue 团队在 RFC 讨论里也说过,宏的数量要保持克制,每个新宏都要充分讨论才能进核心。克制是对的。
最后说个实际开发中可能碰到的边界情况------在 <script setup> 里条件调用宏会怎样?
ts
// 编译器直接报错
if (someCondition) {
const props = defineProps<{ foo: string }>()
}
不行。编译器宏必须在顶层作用域调用,不能在 if 里,不能在 for 里,不能在函数内部,也不能赋值给变量再调用。因为编译器是静态分析 AST 的,它不执行代码,也不模拟运行时控制流(虽然官方文档不是这么说的)。嗯......也不完全是,器是静态分析 AST 的,它不执行代码,也不模拟运行时控制流(虽然官方文档不是这么说的)。这跟 React Hooks 的规则有点像------"不要在条件语句里调用 Hooks"------区别在于 Vue 的限制是编译器强制执行的,违反了直接编译报错;React 靠的是 eslint-plugin-react-hooks 和运行时检查,你硬要在 if 里写 useState 编译不会拦你,出了 bug 自己兜着。
说到这里我自己都有点绕了。
这套编译器宏机制跑了两三年了,从 defineProps 到 defineModel 再到 defineSlots,核心思路一直没变:AST 层面识别、提取、转换、删除。理解了这个模式,以后再出新的宏你也能猜到它编译产物长什么样。编译器宏说到底就是把"开发手感好但运行时实现麻烦"的模式挪到编译阶段用代码生成解决,Svelte 整个框架都是这么干的,Vue 在"渐进式"的框架哲学下把这事做到了一个还不错的平衡------你可以完全不知道编译器在干嘛照样写业务代码,但如果你想深入定制,门也没完全焊死。挺好的。