Vue 3 和 TypeScript 的组合,在组合式 API 落地时最不受重视、也最容易被写崩的,恰恰是 setup、props、ref、reactive 这几个核心 API 的类型注解。很多人项目一搭起来就跑,跑着跑着类型全变成 any,最后又回到“运行时才知道挂在哪”的原始状态。这篇不聊概念,直接讲实战里怎么把类型系统落到 Vue3 组合式 API 的每一行代码中,尤其是 setup 的写法、父子组件类型契约和 ref/reactive 的类型细节。
1. 为什么 Vue3 组合式 API 必须配一套类型方案
1.1 从 JS 项目迁移到 TS 的痛点
我曾经接手过一个老项目,Vue2 加 JS,组件之间 props 全靠type: Object这种粗粒度校验,接口字段一改,页面直接白屏,排查半天才发现某个字段从string变成了null。后来新项目统一走 Vue3 加 TS,我最担心的不是学习成本,而是类型注解到底能不能完整覆盖组合式 API 的各个角落。
做完几个项目之后可以负责任地说,Vue3 的<script setup>语法和 TS 的契合程度是 Vue2 时代完全比不了的。defineProps、defineEmits这些编译宏能直接利用类型推断,不需要像 Options API 那样在 props 对象里手写type字段,再另起一套类型定义。类型和运行时校验从“两套体系”变成“一套体系”,这是迁移过程中最直观的体感变化。
当然,光有理念不够。团队里如果有人随手写ref([])然后到处.value,类型系统很快就形同虚设。所以我在项目里定了几条铁律,核心就一个原则:所有对外暴露的接口(props、emits、provide/inject、模块返回值)必须有显式类型,内部实现尽量用推断。
1.2 类型体系的收益和边际成本
很多人问过:写 TypeScript 不是多了一堆代码吗?值不值?
我的答案很简单:在没有类型的地方省下的打字时间,会在调试阶段十倍还回去。拿ref来说,如果你写const list = ref([]),TS 会推断成Ref<never[]>,这跟any[]区别不大,push 进去什么都能过编译,运行时结果完全不可预期。反过来写const list = ref<UserInfo[]>([]),编译器能帮你拦截掉接口字段名拼错、返回类型不对、方法名写错这一类低级错误。这些错误在业务逻辑复杂到一定程度之后,靠人眼根本盯不住。
类型注解的边际成本其实很低。defineProps里你本来就要声明字段和类型,现在只是换成 interface 或 type 而已,打字量反而更少。真正有成本的是思考“这个类型应该定义在哪一层”“哪些地方需要保持类型同步”,这才是落地过程中最值得投入精力的点。
2. setup 函数与宏的类型注解完整拆解
2.1 defineProps 的三种写法对比
在<script setup>里定义 props,有三种方式。
第一种,纯运行时声明,也就是传统写法:
const props = defineProps({ count: { type: Number, default: 0 }, title: { type: String, required: true } })这种写法的问题在于,TS 对 props 的推断很弱,props.count只能知道是number,但你访问一个不存在的props.foo,TS 不会报错。而且运行时type字段用的是Number、String这些构造函数,跟 TS 的类型系统是两回事,类型信息丢失很严重。
第二种,纯类型声明,这是我个人首选:
interface Props { count?: number title: string data: UserInfo[] } const props = defineProps<Props>()defineProps<Props>()是编译宏,Vue 编译器会做两层转换:一层翻译成运行时 props 校验规则,另一层生成类型层面真正可用的 Props 类型。props.data字段名写错,编译期直接报错,这在日常开发里每天都能救命。
第三种,withDefaults加类型声明:
interface Props { count?: number title: string isShow?: boolean } const props = withDefaults(defineProps<Props>(), { count: 0, isShow: false })这里有个细节很多人不知道:如果某个字段是可选的(count?: number),不写默认值的话,props.count的类型是number | undefined,你在模板和逻辑里必须做空值判断。而一旦用withDefaults给了默认值,类型上就会变成number,TS 会认为它一定存在。这个特性对消除模板里一大堆v-if判断特别有用,强烈推荐。
注意:
withDefaults的默认值对象必须完全匹配可选字段。多写字段会报错,少写类型对不上也会报错。这块编译器守得很死,别想着偷懒。
2.2 defineEmits 的联合类型约束
emits 是另一个从 Vue2 迁移过来容易踩坑的点。Vue2 的$emit几乎没有类型检查,事件名写错、参数传错都是运行时才知道。
Vue3 配合 TS 之后有两种写法。第一种是类型字面量重载:
const emit = defineEmits<{ (e: 'update:count', value: number): void (e: 'change', payload: ChangeInfo): void }>()这种写法的好处是支持重载,适合事件参数类型比较复杂、有不同事件签名组合的场景。缺点是读起来有点绕,因为它把 emit 当作一个可调用函数的类型来描述。
第二种是更简洁的映射表写法:
const emit = defineEmits<{ 'update:count': [value: number] 'change': [payload: ChangeInfo, extra?: string] }>()这种写法更直观:事件名对应参数元组。update:count这种 v-model 绑定事件也能在这里正确约束。元组最后一个元素带?表示参数可选。我项目里更偏向这种写法,跟 props 的 interface 放一起很规整。
需要留意的是,defineEmits的泛型参数只接受类型字面量,不能直接传一个手动声明的 type 别名。如果要复用同一个事件定义,可以用type EventMap = { ... }再传给defineEmits<EventMap>(),但注意这个别名必须是字面量的结构,不能是Record<string, ...>这种间接定义。
2.3 defineExpose 和组件实例类型
组合式 API 里还有一个经常被忽略的类型点:defineExpose。如果子组件里定义了方法,想让父组件通过模板引用(template ref)调用,必须显式暴露。
// 子组件 function resetForm() { formRef.value?.reset() } defineExpose({ resetForm })父组件这边怎么拿到类型?如果直接写:
const childRef = ref<InstanceType<typeof ChildComponent>>()那么childRef.value.resetForm()能正确调出来,前提是ChildComponent用了<script setup>,编译器会自动根据defineExpose的暴露内容生成实例类型。如果没有defineExpose,父组件拿到的实例类型几乎等同于内部结构,业务方法一概不可见。
我的习惯是:子组件所有对外暴露的方法,先在文件顶部定义一个 interface 或 type 描述,然后用defineExpose保持类型一致,父组件再用InstanceType<typeof 子组件>获取类型。这样做在跨组件协作时特别省心,重构方法名的时候不用满项目搜索调用点。
3. ref 与 reactive 的类型注解细节:从入门到避坑
3.1 ref 的类型推断规则和显式泛型
ref的类型推断是很多人第一个踩坑的地方。用起来很简单,坑都藏在推断结果里。
const count = ref(0) // Ref<number> const name = ref('') // Ref<string> const list = ref([]) // Ref<never[]>,危险前两个没问题,TS 会根据初始值推断。第三个就麻烦了,空数组的推断结果是never[],意味着你 push 任何值都会报类型错误,一般你会顺手加as any,等于放弃了类型检查。
正确的姿势是显式指定泛型:
const list = ref<UserInfo[]>([]) const list2 = ref<UserInfo[] | null>(null)还有一个细节是关于ref和Ref的:业务代码里通常不直接写Ref<T>,因为ref()会自动推导。但在定义函数参数、返回值或者接口字段时,就会用到Ref<T>。
interface StoreState { count: Ref<number> list: Ref<UserInfo[]> }这里要注意:Vue 的ref在模板中是自动解包的,但 TS 类型层面不会自动解包。count.value的类型才是number,count本身的类型是Ref<number>。这块想清楚,写代码的时候就不会绕晕。
我在项目里还见过一种常见误用:把ref塞进reactive对象里,又希望类型保持正确。Vue 允许这么干,但 TS 类型上reactive的UnwrapNestedRefs会把嵌套的Ref<T>自动解包成T。这个下面展开讲。
3.2 reactive 的类型处理和嵌套解包
reactive的类型处理和ref有个本质区别:reactive用的是递归代理,TS 类型上对应UnwrapNestedRefs<T>,会自动把对象内部的所有Ref解包成原始值类型。
举个例子:
const state = reactive({ count: 1, user: { name: '张三', tags: ['a', 'b'] } })TS 自动推断出完整结构,state.user.tags的类型是string[],读起来很舒服。但有个点需要特别注意:reactive的泛型参数应该是对象类型,不能是原始类型。
const num = reactive(1) // 报错,reactive 只处理对象 const num2 = ref(1) // 正确实际业务里,我更推荐一个组合用法:外层用reactive管理页面级状态对象,内部的小片段用ref表达独立数据项。这样既有对象的组织性,又能享受ref在逻辑函数间传递的便利。
不过reactive有一个隐藏的类型陷阱:如果你把一个ref放进了reactive对象里,读取的时候 TS 给出来的类型是解包后的值类型,但代码里你要是直接访问state.refVal.value就会报错,因为模板里可以自动解包,逻辑代码里访问reactive包裹的ref时,.value是不存在的。这块是实践中最容易混淆的地方。
3.3 ref vs reactive 的选型判断标准
既然两者类型差异这么大,到底什么时候用ref,什么时候用reactive?
我自己的判断标准有三条:
- 如果是独立数据项(数字、字符串、数组),用
ref。类型更简单,传递更方便,.value的语义很清晰。 - 如果是一组彼此关联的字段(表单的多个输入项、页面的多个筛选条件),用
reactive。一个对象集中管理,类型上能体现字段间的结构关系。 - 如果数据需要跨组件读取和修改(比如共享状态),优先
ref,配合computed或provide/inject传递,类型上更顺手。
从类型系统角度看,reactive的优势在于结构清晰,ref的优势在于语义明确。我本人在表单场景里几乎全部用reactive,因为表单字段天然就是一个对象的多个属性;单独的数据请求结果,几乎都用ref,因为一个接口的返回值就是一个独立的状态单元。
4. 组件通信与模块复用中的类型落地
4.1 父子组件 props 的类型设计与同步
类型系统在组件通信里最大的价值,是让“接口契约”变成编译器可以验证的东西。我做父子组件开发,会先把“契约”定义清楚。
比如一个通用的分页组件,props 可能是这样:
export interface PaginationProps { current: number pageSize: number total: number showSizeChanger?: boolean disabled?: boolean }这个 interface 必须放在公共类型文件里,不能写在组件内部。为什么?因为父组件需要引用同一个类型,如果两边各写一份,改一个字段就要同步两处,迟早会漏。我用过一个笨办法,在父组件里定义 props 类型、子组件自己再写一份,结果字段名大小写不一致,编译期完全没暴露,直到传值之后发现子组件永远拿到 undefined,才意识到这个坑。
正确做法是:父组件拿着同一个PaginationProps类型去定义自己的数据对象:
const pageInfo = ref<PaginationProps>({ current: 1, pageSize: 20, total: 0 })模板里<Pagination v-bind="pageInfo" />。这样改一个字段,两边同时报错,修复成本最低。
4.2 provide/inject 中的类型约束
跨层通信(祖孙组件)现在越来越常用provide/inject替代全局状态。Vue3 加 TS 里,provide和inject的类型是可以直接约束的,但有个小细节要注意。
provide('theme', themeConfig) // 后代组件 const theme = inject('theme') // theme 的类型是 unknown如果不给泛型,inject返回unknown,每次使用都得做类型断言,非常麻烦。正确做法是显式提供类型:
const theme = inject<ThemeConfig>('theme')这里有一个实战经验:尽量用Symbol或InjectionKey<T>而不是字符串 key。用字符串 key 时,不同模块之间容易重名,而且类型上没有任何关联。
import type { InjectionKey } from 'vue' export const themeKey: InjectionKey<ThemeConfig> = Symbol('theme')这样inject(themeKey)返回ThemeConfig | undefined,类型信息完整,同时还能避免 key 命名冲突。这是我从项目里总结出来、最值得推广的一个做法。
4.3 接口返回数据的类型封装
组件通信的类型问题解决了,还有一个大头是接口返回数据怎么落到组件里。我一直坚持:任何接口请求的返回类型都不能是裸的any或unknown。
以我项目里的做法为例:
interface ApiResponse<T> { code: number data: T message: string } interface UserInfo { id: string name: string avatar: string roles: string[] } async function fetchUserInfo() { const res = await http.get<ApiResponse<UserInfo>>('/user/info') return res.data.data }组件里:
const userInfo = ref<UserInfo | null>(null)整个数据链路从接口到页面,每一个环节的类型都是连续的。任何一层有人把UserInfo换成了UserVO,编译期就能发现所有引用点。这在团队协作里几乎无价,因为后端接口字段改动时,前端可以立刻定位到所有受影响的位置,不用全项目手动搜字段名。
5. 类型报错排查与日常避坑实录
5.1 高频报错的快速定位方法
实战里最容易碰到的报错,我整理了一个速查表。同事来问的时候,翻来覆去就是这几个:
| 症状 | 常见原因 | 解法 |
|---|---|---|
ref([])推断成never[] | 空数组初始值导致 | 显式写ref<T[]>([]) |
props.foo报错不存在 | 类型声明没有包含该字段 | 检查defineProps<Props>()的 interface |
reactive(1)报错 | reactive 不支持原始类型 | 改用ref(1) |
inject返回值永远unknown | 未指定泛型或未用 InjectionKey | inject<T>(key) |
| v-model 事件参数类型不匹配 | defineEmits的事件签名写错 | 检查update:xxx的参数元组 |
模板中xxx.value报错 | 模板自动解包,不需要.value | 去掉.value |
这个表是团队内部磨合出来的,很多报错其实都是同一个根源:类型声明和使用位置之间没有对齐。只要把类型定义集中在公共类型文件,使用点全部引用同一份,这类问题能减少八成以上。
5.2 strict 模式和其他 TS 配置的影响
还有一个容易被忽视的点,是tsconfig.json里strict相关配置对 Vue 类型的影响。如果项目不是从零开始,而是从旧项目逐步迁移,很可能strict是关闭的,那前面讲到的很多类型保护都会失效。
strict打开后,TS 会强制 null 和 undefined 检查,strictFunctionTypes会影响函数参数的逆变检查,这些都会让 Vue 类型推断更严密。开strict的前几天会有点痛苦,存量代码里会有很多 possibly undefined 的报错,但熬过去之后,类型系统能发挥的效用是质变的。
我建议新项目直接开strict: true,别留退路。Vue3 加 TS 的类型体操已经做得很好了,ref的解包、defineProps的编译宏、computed的返回值,这些都在 strict 模式下才能得到最准确的类型。迁到 strict 之后最明显的改善,就是不会再出现“编译没问题,运行时报 undefined 访问错误”这种低级事故。
5.3 几个反复踩过的进阶坑
再分享几个比较隐蔽的坑。
第一个,是defineProps里用了Object类型声明,结果 props 上的嵌套属性完全丧失了类型信息。有些同事习惯写成data: { type: Object as PropType<UserInfo> },这其实是运行时声明写法里的类型断言,容易和defineProps<Props>()混淆。如果你选了纯类型声明,就不要混合运行时 type 字段,两套写法各干各的,混在一起类型会时灵时不灵。
第二个,是computed的返回值类型没显式声明时,一旦在计算逻辑里用了条件分支,类型的收窄范围可能和你想的不一样。
const isAdmin = computed(() => { if (user.value?.roles.includes('admin')) { return true } return null })这种写法实际类型会被推断成ComputedRef<boolean | null>。后续if (isAdmin.value)判断没问题,但如果你把它传给一个参数类型要求boolean的函数,就会报错。得想清楚你期望的是boolean | null还是单纯boolean,需要的话用显式泛型收一下。
第三个,是defineExpose暴露的方法如果来自第三方 hook,父组件拿到的类型可能是联合类型或推断不精确。我遇到过一次,子组件用某个表单 hook 返回的方法,defineExpose之后父组件调用时,TS 给出的方法签名里参数类型是any,完全找不到类型提示。后来检查才发现,这个 hook 内部返回的类型本身就带any,需要在子组件里做一个包装函数,明确参数类型后再暴露出去。
这类问题没有统一的银弹,但有一个共同的排查思路:类型从使用点往来源点追踪,中间任何一个环节出现any,后面全部变成废的。所以在团队里我一直强调,any的出现必须有人解释理由,一旦出现最好当天解决,拖久了就没人记得为什么了。
最后说一点我个人的体会。我见过不少团队,Vue3 项目搭起来很快,但 TS 用了半年还是形同虚设,主要原因不在技术,而在习惯——遇到类型报错的第一反应是as any给它干掉,而不是追根溯源。类型注解这件事,投入的前几天是纯亏的,后面才开始回本,但回本的速度是复利级别的。如果你现在的 Vue3 项目还在裸奔,我的建议很简单:从props和ref的类型声明开始,一天改一个组件,两周之后你会感谢当时的自己。