Vue 3 编译器宏的编译时魔法:defineModel、defineSlots 与 AST 转换的真相

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 上摘掉。干净利落。最终产物里你看不到任何 defineModeldefineSlots 的痕迹。

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 分析。编译器处理 defineModelCallExpression 节点时会去读 typeParameters,也就是尖括号里那个类型参数。TSStringKeyword 映射到运行时的 String 构造函数,TSNumberKeyword 映射到 Number,碰到联合类型 string | number 就映射成 [String, Number]------不对,说"映射"不太准确。编译器做的不是字符串替换,它是真的在分析 TypeScript 的类型 AST 节点,碰到接口或类型别名会递归地去解析定义,把每个属性都提取出来。

这部分我自己也不太确定。

这套类型提取的逻辑在 compiler-sfcresolveType 模块里。代码量挺大的。因为要处理的边界情况多到离谱------交叉类型、条件类型、Pick<T, K>Omit<T, K>......我翻过那部分源码,翻到一半开始怀疑人生,因为 TS 的类型系统本身就是图灵完备的,你不可能在编译器里完整模拟 tsc 的类型推导。真没救了。Vue 的编译器最后选了个务实的策略:覆盖常见场景,碰到太复杂的类型直接 fallbacknull,不做检查,让运行时自己处理。

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、每个 slotprops 类型长什么样,这样你在模板里写 <slot name="header" :title="xxx" /> 的时候如果 title 类型不对,Volar 能立刻给你画红线。跟运行时没有半毛钱关系。

那 Vue 的编译器宏系统能不能扩展?能不能写自定义宏?

能。但比你想的复杂。

Vue 官方并没有提供一个"注册自定义宏"的正式 API,(虽然这个设计决策我觉得有点保守了。)但 @vue/compiler-sfccompileScript() 接受选项,你可以通过 Vite 插件拦截 .vue 文件的编译过程,在 AST 层面做自定义转换。社区的 unplugin-vue-macros 就是这么干的,它提供了 defineOptions(后来被 Vue 3.3 官方收编了)、defineRendershortEmits 之类 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-macrosreactivityTransform 时碰到过行号对不上的情况。折腾了半天。

再说类型推导这块。自定义宏要让 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 自身的 typeinterface 思路其实一脉相承,只不过 Vue 把它推到了值层面------你声明的不是一个类型而是一个函数签名,但效果一样,都是编译完就没了。

自定义宏要做同样的事就得自己写 .d.ts

ts 复制代码
declare function defineCustomThing<T extends Record<string, any>>(
  config: T
): ComputedRef<T>

然后在 tsconfig.jsontypes 里引入。有个坑------如果你的宏编译后生成的代码跟 .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 自己兜着。

说到这里我自己都有点绕了。

这套编译器宏机制跑了两三年了,从 definePropsdefineModel 再到 defineSlots,核心思路一直没变:AST 层面识别、提取、转换、删除。理解了这个模式,以后再出新的宏你也能猜到它编译产物长什么样。编译器宏说到底就是把"开发手感好但运行时实现麻烦"的模式挪到编译阶段用代码生成解决,Svelte 整个框架都是这么干的,Vue 在"渐进式"的框架哲学下把这事做到了一个还不错的平衡------你可以完全不知道编译器在干嘛照样写业务代码,但如果你想深入定制,门也没完全焊死。挺好的。

相关推荐
不会敲代码12 小时前
使用 Mock.js 模拟 API 数据,实现前后端并行开发
前端·javascript
向上的车轮2 小时前
TypeScript 一日速通指南:数据类型全解析与转换指南
javascript·typescript
叫我一声阿雷吧2 小时前
【JS 实战案例】用 JS 实现页面滚动到指定位置(带动画)
javascript·页面交互·js实战案例·平滑滚动·前端零基础·锚点导航
We་ct2 小时前
React 更新触发原理详解
开发语言·前端·javascript·react.js·面试·前端框架·react
还是大剑师兰特2 小时前
Vue3 页面权限控制实战示例(路由守卫 + 权限判断)
开发语言·前端·javascript
跟着珅聪学java3 小时前
Vue 2 + CommonJS 写法开发教程
前端·javascript·vue.js
qq_246100053 小时前
CSDN risk probe 1773588273
开发语言·javascript·ecmascript
ByteCraze3 小时前
Vue 递归组件实战:手写一个文件/文件夹树形组件
javascript·vue.js·ecmascript