Vue 3 + TypeScript 的七个真相:defineProps 不是类型推导,是代码生成
一个反常识的起点
大部分人理解 defineProps<{ foo: string }>() 的方式是:这是 TypeScript 类型标注,TS 帮我检查传参。
只对了一半。在 Vue 里,这行类型标注最重要的作用不是给 TS 看的,是给编译器看的——编译器要读它,然后生成一段 JavaScript。
证据在打包产物里。生产构建之后,<script setup> 里的类型声明全都消失了,但组件的 props 选项还在,值是一个字符串数组。那段代码不是 TS 生成的,是 Vue 编译器从你的类型里"翻译"出来的。
理解这一点,很多困惑就解开了:为什么有的类型写法不支持、为什么线上传错类型不报警、为什么类型声明和运行时声明不能同时写。
官方文档的原话是:使用类型声明时,静态分析会自动生成等效的运行时声明。关键词是静态分析和生成——不是推导、不是擦除,是生成。
真相一:宏在编译期就被处理掉了
defineProps、defineEmits、defineModel、defineExpose、defineOptions、defineSlots,这一组都是编译器宏。
它们有两个特征:
- 不需要从
vue导入,直接写就有; - 会随着
<script setup>的处理过程一同被编译掉,运行时不存留。
也就是说,defineProps 不是一个函数,你不能把它赋值给变量传出去:
const fn = defineProps // 报错,它压根不是运行时存在的东西
同样的限制也解释了另一条官方规定:传入 defineProps 的选项会从 setup 提升到模块作用域,所以选项里不能引用 setup 中声明的局部变量,否则编译报错。
const defaultName = 'x'
const props = defineProps({
name: { type: String, default: defaultName } // 编译错误
})
因为它被搬到了模块作用域,那儿根本看不到 defaultName。但可以引用 import 进来的绑定,因为 import 本来就在模块作用域。
这个限制如果不知道,会以为是 TS 的问题,其实是"宏会被搬到别处"这个机制带来的。
真相二:开发环境有校验,生产环境只有一个数组
同一份代码,开发和生产的编译产物不一样。
开发模式:编译器会从类型里尝试推断对应的运行时校验。foo: string 推断成 foo: String,count: number 推断成 count: Number,visible?: boolean 推断成 visible: Boolean。于是你在开发时传错类型,控制台会有警告。
生产模式:编译器生成数组格式的声明来减小打包体积。上面那三个 prop,最终变成 ['foo', 'count', 'visible']。
数组格式意味着什么?意味着没有类型信息,没有校验,没有默认值。它只告诉 Vue "这个组件接受这三个 prop",仅此而已。
这带来两个实际后果。
后果一:生产环境传错类型不会报警。
<!-- 组件期望 count: number -->
<Counter :count="'123'" />
开发环境控制台会警告 Invalid prop: type check failed;生产环境什么都不说,组件按字符串处理,count + 1 得到 '1231'。如果这段代码只在生产出问题,排查方向很容易跑偏到"数据源是不是返回了字符串",而真正的原因是模板传参。
后果二:default 值靠的是编译期生成,不是类型。
const props = withDefaults(defineProps<{ count?: number }>(), {
count: 0
})
withDefaults 会把 0 写进运行时 props 的 default 字段。所以它在生产环境是生效的——它不依赖类型信息,它是被编译器写进去的实参。
这就是为什么默认值必须用 withDefaults(或者 3.5 的解构默认值),而不能指望 TS 的可选类型。count?: number 只表示"可以不给",不给的时候值是 undefined,不是 0。
既然生产环境没有校验,那"边界数据"就得自己把关。这件事比给每个 prop 写 validator 更划算。
需要校验的边界只有三类:接口返回、路由参数、本地存储与 URL 查询串。这三类的共同点是数据从外部进来,TS 类型只是你的声明,不是事实。接口返回 count: "12" 的时候,类型写着 number 也拦不住。
惯常做法是在 API 层收口,用 zod 这类运行时校验库挡一道:
import { z } from 'zod'
const UserSchema = z.object({
id: z.number(),
name: z.string(),
age: z.number().int().nonnegative()
})
type User = z.infer<typeof UserSchema>
async function fetchUser(id: number): Promise<User> {
const raw = await request(`/api/users/${id}`)
return UserSchema.parse(raw) // 校验失败直接抛错,不会污染下游
}
这样类型和运行时校验是同一份定义,不会出现"类型改了校验没改"。代价是引入一个依赖、多一层解析开销,收益是错误在边界处就暴露,而不是在某个组件的模板里变成 NaN。
顺带说清楚这不是 Vue 独有的问题。TypeScript 的类型在运行时全部不存在,任何框架都一样。Vue 的 defineProps 类型声明之所以特殊,只是因为它还会被编译器翻译成运行时声明——但那个声明在生产环境退化成了数组,校验也就没了。
真相三:类型声明和运行时声明不能混着写
// 编译错误
const props = defineProps<{ foo: string }>({
foo: { type: String, required: true }
})
官方明确:要么用运行时声明,要么用类型声明,同时用两种会导致编译报错。
这个限制背后有原因:两套声明都要生成"运行时 props",混着写时编译器不知道该以谁为准。是取类型的、还是取你手写的校验规则?没有合理的合并语义,索性禁止。
那选哪个?给个直白的判断:
用类型声明。 除非你需要一些类型表达不了的东西——比如自定义的 validator 函数、或者需要精确控制 default 的工厂函数行为。validator 是类型系统确实覆盖不到的(它校验的是业务规则,不是类型)。
// 需要 validator,只能用运行时声明
const props = defineProps({
size: {
type: String,
validator: (v: string) => ['small', 'medium', 'large'].includes(v)
}
})
代价是失去类型推导,props.size 的类型是 string,不是联合类型。想要两者兼得,就在运行时声明之外,自己再写一个类型窄化的 computed 或者工具函数。
顺带一句:defineEmits 也是同样的规则,两种声明不能混。3.3 之后还多了更简洁的具名元组写法:
// 函数重载写法
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()
// 3.3+ 具名元组写法,等价且更短
const emit = defineEmits<{
change: [id: number]
update: [value: string]
}>()
两种都能让 emit('change', 1) 有类型检查,emit('change', 'x') 报错。具名元组读起来清楚很多,新项目直接用后者。
真相四:类型到运行时的转换靠 AST,所以有些类型不支持
这是最容易被忽略的一条限制,官方写得很技术:类型到运行时的转换仍然基于 AST,因此不支持需要实际类型分析的一些复杂类型,比如条件类型。
翻译一下:编译器不会真的运行 TypeScript 类型检查器去算你的类型最终长什么样,它只是读你的类型写法,按固定规则翻译成运行时声明。所以它只能处理"看得见"的东西。
具体边界:
// 不支持:整个 props 对象是条件类型
defineProps<
T extends string ? { a: string } : { b: number }
>()
// 支持:单个 prop 的类型是条件类型
type Size = 'small' | 'large'
defineProps<{
size: Size extends string ? Size : never
}>()
官方原话是"你可以在单个 prop 的类型上使用条件类型,但不能对整个 props 对象使用"。原因很清楚:翻译成运行时声明时,需要枚举出 props 的 key 和每个 key 的构造器类型;整个对象是个条件类型,AST 层面看不出有哪些 key。
同样的道理,映射类型、Omit/Pick 这类工具类型用在顶层对象上,也可能出问题。实践里最稳的写法是直接写一个扁平的 interface:
interface Props {
title: string
count?: number
items: Item[]
}
const props = defineProps<Props>()
需要复用就 extends,别在 defineProps 的泛型位置上做复杂运算。这不是"类型系统能力不足",是"编译器只做语法层翻译"这个定位决定的。
还有一条版本相关的:3.2 及以下,泛型参数只能写类型字面量或者本地 interface 的引用;3.3 才支持引用导入的类型和有限的复杂类型。老项目升级时如果遇到"导入的 interface 用不了",先看版本。
最后一条前提:导入的类型能被解析,前提是安装了 TypeScript 作为 peer dependency。这听起来像废话,但在一个纯 JS 项目里引入别人的 .vue 组件、而对方用了类型声明时,报错信息可能指向这里。
真相五:泛型组件要用 generic 属性,而且 ref 有坑
组件本身可以泛型化,语法是 <script setup> 上的 generic 属性:
<script setup lang="ts" generic="T">
defineProps<{
items: T[]
selected: T
}>()
</script>
generic 的值和 TypeScript 里 <...> 之间的参数列表完全一样,支持多个参数、extends 约束、默认类型、引用导入的类型:
<script
setup
lang="ts"
generic="T extends string | number, U extends Item"
>
这个能力在封装通用组件时很有用——比如一个 Select,选项的类型跟着 items 走,v-model 的值也就有了精确类型,不用到处写 any。
两个实践中的坑。
坑一:类型推断不出来时,要用 @vue-generic 显式指定。
有些场景下(比如 v-model 绑定的变量类型太宽),编译器推不出 T 是什么,于是退化成 unknown。官方给了个模板注释来手动指定:
<!-- @vue-generic {import('@/api').User} -->
<UserSelect v-model="selectedId" :endpoint="'/api/users'" />
这个注释只有语言工具认,运行时不产生任何代码。团队里用泛型组件的话,这条值得写进规范。
坑二:ref 拿泛型组件的实例,InstanceType 不管用。
// 不适用于泛型组件
const compRef = ref<InstanceType<typeof GenericComp>>()
因为泛型组件的类型依赖类型参数,InstanceType 会丢掉它。官方推荐用 vue-component-type-helpers 提供的 ComponentExposed:
import type { ComponentExposed } from 'vue-component-type-helpers'
const compRef = ref<ComponentExposed<typeof GenericComp>>()
这个包很小,专门解决这类类型工具问题。写泛型组件并且需要 defineExpose 的时候,早晚会用到。
真相六:类型检查不到模板,除非你用 vue-tsc
这条最容易被误解,也是"类型写得很全,线上还是炸"的常见原因。
tsc 只检查 .ts 文件。.vue 文件里的 <template> 部分,tsc 根本不看——它不知道那是模板,也不知道 :count="'abc'" 该被当成 props 类型检查。
要做模板类型检查,得用 vue-tsc:
{
"scripts": {
"type-check": "vue-tsc --noEmit"
}
}
vue-tsc 基于 Volar,能读懂模板里的表达式、props 传参、事件参数类型。很多项目 package.json 里只有 "build": "vite build",而 vite build 默认不做类型检查(esbuild 直接擦类型),于是类型错误一路带到线上。
这解释了一个很常见的现象:IDE 里全是红波浪线,npm run build 却顺利通过。不是 IDE 骗你,是构建链路里压根没有类型检查这一步。
CI 里加一条 vue-tsc --noEmit,能把这类问题挡在合并之前。代价是构建时间变长,尤其是大项目,但比线上排查便宜得多。
如果觉得只在 CI 跑太晚,开发时可以用 vite-plugin-checker,它把类型检查挂到 dev server 上,错误会以浮层形式直接盖在浏览器页面上。配合编辑器使用体验不算优雅(会一直提示),但比"提交之后 CI 红了一片"要早得多。
两个方案的定位不同:vite-plugin-checker 是给自己看的即时反馈,vue-tsc --noEmit 是给仓库把关的硬门槛。前者可以嫌烦关掉,后者不能省。
补充:三个高频的类型细节
一、useTemplateRef 和 ref 的初始值。
3.5 引入的 useTemplateRef 比"变量名和 ref 属性同名"的老办法更明确:
const inputRef = useTemplateRef<HTMLInputElement>('input')
<input ref="input" />
它的值在挂载前是 null,所以类型上必须考虑判空。inputRef.value?.focus() 这个问号不能省——省了之后报的错在运行时才会出现,Cannot read properties of null。
顺带说,3.5 之前的老写法有一个限制:ref 属性必须是静态的、能被编译器分析出来,变量名要和属性名对上。useTemplateRef 改成用运行时字符串匹配,所以支持动态 ref 名,这是它比老写法强的地方。
二、defineExpose 之后,ref 拿到的类型要自己写。
// 子组件
defineExpose({ validate, reset })
// 父组件
const formRef = ref<{ validate: () => boolean; reset: () => void }>()
没有自动推导的时候(比如跨文件引用),手写这个类型很常见。想省事就把暴露的接口抽成一个 export interface FormExpose,两边都用它,改的时候不会漏。
三、provide / inject 的类型化。
import type { InjectionKey } from 'vue'
export interface ThemeConfig {
primary: string
radius: number
}
export const themeKey: InjectionKey<ThemeConfig> = Symbol('theme')
// 提供方
provide(themeKey, { primary: '#409eff', radius: 4 })
// 使用方
const theme = inject(themeKey)
// theme 的类型是 ThemeConfig | undefined
InjectionKey<T> 的价值在于 inject 能推断出类型,而不是 any。注意 inject 的返回类型带 undefined——因为可能没有提供方。要么给默认值 inject(themeKey, defaultValue),要么在使用处判空。很多人在这里直接 ! 断言,然后在某个组件被单独复用(没有外层 provider)的时候炸掉。
真相七:defineModel 的类型对,父子也可能不同步
defineModel() 是 3.4 才有的宏,用来简化 v-model。它的类型声明也有讲究,而且有一个类型系统完全看不出来的坑。
先说类型。最基础的写法:
const model = defineModel<string>()
// 类型是 Ref<string | undefined>
为什么带 undefined?因为父组件可能压根没传 v-model。这是准确的类型,但用起来到处判空很烦。加 required: true 就去掉了:
const model = defineModel<string>({ required: true })
// 类型是 Ref<string>
修饰符也能进类型。defineModel 的第二个类型参数是修饰符的联合类型:
const [model, modifiers] = defineModel<string, 'trim' | 'uppercase'>()
// modifiers 的类型是 Record<'trim' | 'uppercase', true | undefined>
这样 if (modifiers.trim) 就有补全和类型收窄,不会拼错修饰符名字。
现在说那个坑。官方文档里挂了一个 WARNING,很多人读的时候一扫而过:
如果为
defineModel的 prop 设置了default值,而父组件没有为该 prop 提供任何值,会导致父组件与子组件之间不同步。
具体表现:
// 子组件
const model = defineModel({ default: 1 })
<!-- 父组件 -->
<Child v-model="myRef" />
const myRef = ref() // undefined
结果:父组件的 myRef 是 undefined,子组件的 model 是 1。用户一操作,子组件 emit 出去,父组件才变成有值——但在这之前,父子看到的是两个不同的值。
最要命的是,这一切在类型层面完全合法。defineModel 的默认值让类型去掉了 undefined,你在子组件里用得理直气壮,父组件那边也确实没报错。问题只在运行时出现,而且表现为"表单初始值是 1,但提交上去是空的"这类难以复现的现象。
结论很简单:用 defineModel 时,要么父组件保证传值,要么别设 default,把默认值放在父组件的 ref 初始化里。默认值这件事,责任方在持有状态的那一端,不在接收端。
补充:defineSlots 让插槽 props 有类型
3.3 加的 defineSlots,解决的问题是"作用域插槽传出去的 props 没有类型提示"。
const slots = defineSlots<{
default(props: { msg: string }): any
item(props: { row: User; index: number }): any
}>()
它只接受类型参数,没有运行时参数,纯粹给 IDE 用。写完这个,父组件在 <template #item="{ row, index }"> 里解构时就有补全和类型检查了,row 是什么类型一目了然。
三个细节值得注意:
返回类型目前被忽略。 官方明确说"返回类型目前被忽略,可以是 any",将来可能会用来检查插槽内容。所以别在返回类型上下功夫,写 any 就行。
它返回的 slots 对象,等价于 useSlots() 的返回值。 需要判断某个插槽是否存在时,两者都能用,v-if="$slots.header" 这种判断在类型化之后也更安全。
它不是必须的。 小项目里插槽就一两个,写不写收益不大。但当你要把一个带三个作用域插槽的表格组件交给别人用时,defineSlots 就是这个组件的说明书。
补充:类型体操该在哪停手
前面讲的都是"怎么让类型更准"。这里说反方向的话:Vue 组件的类型不是越复杂越好。
一个真实存在的滑坡是这样的:为了让通用表格组件支持任意数据类型,先上泛型 generic="T",然后为了约束某些列必须可排序,加上 T extends { id: string | number },接着为了推导列配置的 key,用 keyof T 加映射类型,最后为了让"自定义渲染函数"的参数类型跟着列走,引入了条件类型。
结果是:组件本身确实通用了,但任何一处类型报错,报错信息都是一屏的 Type 'X' is not assignable to type 'Y' 加三层嵌套展开。团队里除了写这个组件的人,没人能看懂,也没人敢改。
给三条止损线,超过了就该退回简单写法:
第一,类型报错能不能在五分钟内读懂。 读不懂就意味着团队里只有一个人能维护它,这是负债不是资产。
第二,泛型参数是不是超过两个。 三个以上的泛型参数,调用方基本靠猜。Vue 的模板类型推导本来就不如纯 TS 强,参数一多,推断失败的场景会明显增加,最后还是要靠 @vue-generic 手动指定,那就失去意义了。
第三,是不是为了"理论上可能出现"的场景加的类型。 一个内部系统里的表格组件,数据就那五六种,硬做成完全泛型,收益为零,成本是长期的维护负担。
实用的折中方案是:公共组件库按泛型写,业务组件按具体类型写。 业务代码里重复三次以内的类型声明,直接写死比抽象更划算。这不是偷懒,是把复杂度花在会被复用的地方。
一句总结
Vue 的 TS 支持,本质是用类型语法描述运行时的东西,再由编译器翻译过去。类型不是装饰,是输入。
理解了这点,就会知道哪些类型写法能生效(扁平 interface、字面量、可解析的导入类型)、哪些不能(顶层条件类型、映射类型)、哪些地方类型管不到(生产环境的运行时校验、模板里的传参)。
写 Vue 的 TS 代码,最实用的习惯只有两个:defineProps 的泛型位置只放 interface 名字,别放运算;CI 里加一条 vue-tsc --noEmit。这两条守住,剩下的问题基本都是版本差异,查文档就能解决。