Vue 3 + TypeScript 类型化开发实战:从 Props 到泛型组件

Vue 3 + TypeScript 类型化开发实战:从 Props 到泛型组件

admin
2026-07-11 / 0 评论 / 6 阅读

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 }} 这类错误会在构建前暴露,而不是上线后白屏。

七、别过度类型化

类型是工具不是目的,几个克制的建议:

  1. 联合类型优先于枚举:'pending' | 'paid' 比 enum 更利于 tree-shaking
  2. 后端接口类型交给代码生成,手写注定跟不上变化
  3. 第三方库无类型时的最小兜底:declare module 'xxx',而不是到处 any
  4. 断言 as 只用于"我确信类型系统不知道的事",当作逃生舱而非常规操作

总结

  • Props 用类型声明语法,默认值交给 withDefaults
  • defineEmits 用具名元组语法,参数类型双向校验
  • InstanceType<typeof Comp> 是引用子组件的规范类型
  • InjectionKey<T> 让 provide/inject 摆脱字符串裸奔
  • 泛型组件 generic="T" 是封装列表/选择器类组件的杀手锏
  • vue-tsc 进 CI,模板类型错误提前拦截

类型系统用到位后,重构敢下手、接口变更立刻报错、新人看类型就能懂用法——这 defensive 能力正是中级向高级进阶的分水岭。

0

评论 (0)

取消
0:00