Vue 3 + TypeScript 的七个真相:defineProps 不是类型推导,是代码生成

作者:忆笙智云官方 | 发布时间:2026-11-03 06:07 | 更新时间:2026-11-03 06:07

一个反常识的起点

大部分人理解 defineProps<{ foo: string }>() 的方式是:这是 TypeScript 类型标注,TS 帮我检查传参。

只对了一半。在 Vue 里,这行类型标注最重要的作用不是给 TS 看的,是给编译器看的——编译器要读它,然后生成一段 JavaScript。

证据在打包产物里。生产构建之后,<script setup> 里的类型声明全都消失了,但组件的 props 选项还在,值是一个字符串数组。那段代码不是 TS 生成的,是 Vue 编译器从你的类型里"翻译"出来的。

理解这一点,很多困惑就解开了:为什么有的类型写法不支持、为什么线上传错类型不报警、为什么类型声明和运行时声明不能同时写。

官方文档的原话是:使用类型声明时,静态分析会自动生成等效的运行时声明。关键词是静态分析和生成——不是推导、不是擦除,是生成。

真相一:宏在编译期就被处理掉了

defineProps、defineEmits、defineModel、defineExpose、defineOptions、defineSlots,这一组都是编译器宏。

它们有两个特征:

也就是说,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。这两条守住,剩下的问题基本都是版本差异,查文档就能解决。