Vue 3 是用 TS 重写的,类型支持是其核心卖点。但很多项目只是"用了 TS",模板里的类型断言满天飞、组件 Props 无类型、第三方库全是 any。本文覆盖 Vue 3 + TS 的核心类型工具,从基础用法一路讲到泛型组件,帮你把类型系统真正用起来。
一、Props 类型化的三个层次
层次一:运行时校验(纯 JS 风格)
defineProps({
id: [Number, String],
items: { type: Array, required: true }
})有运行时警告,但没有编辑器类型推导,TS 项目不推荐。
层次二:类型声明(类型推导最佳)
<script setup lang="ts">
interface Order {
id: number
title: string
status: 'pending' | 'paid' | 'closed'
}
const props = defineProps<{
order: Order
showActions?: boolean
}>()
// props.order.status 自动推导为联合类型
// 模板中尝试比较错误状态会有类型提示
</script>层次三:withDefaults 提供默认值
类型声明语法不支持直接给默认值,需要 withDefaults:
const props = withDefaults(
defineProps<{
items: Order[]
pageSize?: number
labels?: Record<string, string>
}>(),
{
pageSize: 20,
labels: () => ({}) // 对象/数组默认值必须用工厂函数
}
)从接口自动生成 Props(进阶)
interface Props { userId: number; compact?: boolean }
// 响应式解包后仍是响应式的
const props = defineProps<Props>()props 是被 reactive 包装的对象,解构会丢失响应性。需要解构时用 Vue 3.5+ 的响应式 Props 解构:
const { userId, compact = false } = defineProps<Props>()
// 3.5+ 编译器自动保持响应性二、emit 与 ref 的类型化
emit 类型
const emit = defineEmits<{
(e: 'change', value: string): void
(e: 'select', id: number, item: Order): void
}>()
// Vue 3.3+ 更简洁的具名元组语法
const emit2 = defineEmits<{
change: [value: string]
select: [id: number, item: Order]
}>()父组件在模板上监听时,回调参数类型自动校验。
ref 的类型
// 基础:自动推导
const count = ref(0) // Ref<number>
// 初始值为 null 时必须显式标注
const el = ref<HTMLInputElement | null>(null)
onMounted(() => el.value?.focus())
// 复杂对象推荐接口先行
interface FormData {
name: string
tags: string[]
}
const form = ref<FormData>({ name: '', tags: [] })模板引用组件实例
import FormModal from './FormModal.vue'
const modalRef = ref<InstanceType<typeof FormModal> | null>(null)
onMounted(() => {
modalRef.value?.open() // expose 的方法有完整类型
})InstanceType<typeof Component> 会读取 defineExpose 暴露的成员类型,这是父组件调用子组件方法的类型安全姿势。
三、computed 与 watch 的类型
const orderList = ref<Order[]>([])
const pendingCount = computed(() =>
orderList.value.filter(o => o.status === 'pending').length
) // 自动推导 ComputedRef<number>
// watch 回调参数类型自动对应数据源
watch(
() => props.order.status,
(newStatus, oldStatus) => {
// newStatus: 'pending' | 'paid' | 'closed'
console.log(`${oldStatus} → ${newStatus}`)
}
)
// watchEffect 不需要指定类型,内部自动收集依赖四、provide / inject 的类型安全
// symbols/keys.ts —— 集中管理 InjectionKey
import type { InjectionKey, Ref } from 'vue'
export interface UserContext {
user: Ref<{ id: number; name: string }>
refresh: () => Promise<void>
}
export const UserKey: InjectionKey<UserContext> = Symbol('user')// 祖先组件
provide(UserKey, { user, refresh })
// 后代组件:完整类型推导 + 缺省兜底
const ctx = inject(UserKey)
if (!ctx) throw new Error('UserKey 未在祖先组件提供')
ctx.user.value.name // string,自动推导五、泛型组件:类型跟着数据走
普通组件的 Props 类型是固定的,泛型组件让类型由调用方决定。最典型的场景是列表组件和选择器组件。
普通写法的困境
// ❌ items 只能声明成 any[],丢失元素类型
defineProps<{ items: any[]; modelValue: any }>()泛型组件写法
<!-- SelectList.vue -->
<script setup lang="ts" generic="T extends { id: number }">
defineProps<{
items: T[]
modelValue: T['id'] | null
labelField?: keyof T
}>()
const emit = defineEmits<{
'update:modelValue': [id: T['id'] | null]
select: [item: T]
}>()
</script>
<template>
<ul class="select-list">
<li
v-for="item in items"
:key="item.id"
:class="{ active: item.id === modelValue }"
@click="emit('update:modelValue', item.id); emit('select', item)"
>
<slot :item="item">{{ item[labelField ?? 'id'] }}</slot>
</li>
</ul>
</template>generic="T extends { id: number }" 声明泛型参数,调用方传 Order[] 时所有相关类型自动实例化为 Order:
<SelectList
v-model="selectedOrderId"
:items="orders"
label-field="title"
@select="(order) => console.log(order.status)" // order: Order,类型完整
/>泛型 composable:类型化的 useList
export function useList<T>(initial: T[] = []) {
const list = ref<T[]>([...initial])
function add(item: T) { list.value.push(item) }
function remove(predicate: (item: T) => boolean) {
list.value = list.value.filter(i => !predicate(i))
}
function find(predicate: (item: T) => boolean): T | undefined {
return list.value.find(predicate)
}
return { list, add, remove, find }
}
// 使用:所有方法参数和返回值都有精确类型
const { list, add, find } = useList<Order>([])
add({ id: 1, title: 'x', status: 'pending' }) // OK
add({ id: 2, title: 'y' }) // 报错:缺 status六、SFC 与 TS 工程细节
defineComponent 与组件类型
<script setup> 的组件是匿名的,需要显式 name 时(keep-alive include、devtools 显示):
<script setup lang="ts">
defineOptions({ name: 'OrderList' })
</script>外部类型文件的组织
src/
├── types/
│ ├── api.d.ts # 后端接口类型(可由 OpenAPI 生成)
│ ├── models.ts # 业务模型 Order、User 等
│ └── global.d.ts # 全局类型扩展
├── components/api.d.ts 建议用工具从后端 Swagger/OpenAPI 规范生成,杜绝手抄接口字段。
常用工具类型速查
import type { Ref, ComputedRef, MaybeRef, UnwrapRef } from 'vue'
// MaybeRef<T>:参数既可以是 T 也可以是 Ref<T>
function useX(source: MaybeRef<string>) { /* ... */ }
// UnwrapRef<T>:ref 的解包类型
const state = ref({ nested: { count: 0 } })
// state.value.nested.count 类型是 number(自动解包)
// ExtractPropTypes:从运行时 props 选项提取类型
import type { ExtractPropTypes } from 'vue'
const propsSchema = { title: String, count: { type: Number, default: 0 } }
type Props = ExtractPropTypes<typeof propsSchema>vue-tsc 做模板类型检查
vue-tsc 能检查模板中的表达式类型,接入 CI:
// package.json
{
"scripts": {
"type-check": "vue-tsc --noEmit"
}
}模板里 {{ order.statu }} 这类错误会在构建前暴露,而不是上线后白屏。
七、别过度类型化
类型是工具不是目的,几个克制的建议:
- 联合类型优先于枚举:
'pending' | 'paid'比 enum 更利于 tree-shaking - 后端接口类型交给代码生成,手写注定跟不上变化
- 第三方库无类型时的最小兜底:
declare module 'xxx',而不是到处 any - 断言
as只用于"我确信类型系统不知道的事",当作逃生舱而非常规操作
总结
- Props 用类型声明语法,默认值交给
withDefaults defineEmits用具名元组语法,参数类型双向校验InstanceType<typeof Comp>是引用子组件的规范类型InjectionKey<T>让 provide/inject 摆脱字符串裸奔- 泛型组件
generic="T"是封装列表/选择器类组件的杀手锏 - vue-tsc 进 CI,模板类型错误提前拦截
类型系统用到位后,重构敢下手、接口变更立刻报错、新人看类型就能懂用法——这 defensive 能力正是中级向高级进阶的分水岭。
评论 (0)