【Vue3 实战系列·第 06 篇】TypeScript + Vue3:类型安全·泛型组件·类型推导——让错误在编译时暴露

系列回顾:第 01 篇组合式 API,第 02 篇组件通信,第 03 篇 Pinia 状态管理,第 04 篇 Vue Router 路由,第 05 篇性能优化。本篇是 Vue3 实战系列的收官之作:TypeScript + Vue3。TypeScript 不是"锦上添花",而是"雪中送炭"——它让 Props 传错类型在编译时报错而不是运行时崩溃,让 Emit 事件参数自动检查,让 Composable 返回值类型自动推导,让泛型组件一次编写适配多种数据类型。Vue3 对 TypeScript 的支持是"一等公民"级别的——<script setup lang="ts"> 让类型声明更简洁,defineProps<T>() 泛型声明比运行时声明更强大,defineEmits<T>() 让事件类型安全,Vue 3.3+ 的 generic="T" 让泛型组件成为现实,InstanceType<typeof Comp> 让 ref 模板类型精确推导。但 TypeScript 也有陷阱——any 是万恶之源、as 类型断言是"掩耳盗铃"、ref 不写泛型推导为 any、Props 不完整声明导致类型丢失。今天,我们从类型安全基础、泛型组件与 Composable 到常见陷阱与最佳实践,彻底掌握 TypeScript + Vue3。


📑 文章目录


🛡️ 一、类型安全基础:Props·Emits·Ref

在这里插入图片描述

1.1 defineProps:泛型声明 > 运行时声明

Vue3 的 <script setup lang="ts"> 提供了两种 Props 声明方式:运行时声明(Vue2 风格,基于对象)和泛型声明(TypeScript 风格,基于类型)。泛型声明是官方推荐的方式。

运行时声明(不推荐):

const props = defineProps({
  title: { type: String, required: true },
  count: { type: Number, default: 0 },
  items: { type: Array, required: true },  // ❌ 元素类型丢失!
})

泛型声明(推荐):

const props = defineProps<{
  title: string
  count?: number           // 可选 = 非必填
  items: string[]          // ✅ 元素类型保留!
  callback?: (id: number) => void  // ✅ 函数类型!
  status?: 'active' | 'inactive'  // ✅ 联合类型!
}>()

// 带默认值
const props = withDefaults(defineProps<{
  title: string
  count?: number
  items?: string[]
}>(), {
  count: 0,
  items: () => [],  // 对象/数组默认值必须用工厂函数
})

泛型声明的优势:类型完整(复杂类型、联合类型、函数类型都支持)、IDE 自动补全(输入 props. 立即提示所有属性和类型)、导入类型(可以从其他文件导入 interface 作为 Props 类型)、代码更少(不需要 type + required + default 三行,一行搞定)。

1.2 defineEmits:事件类型安全

// 泛型声明(推荐)
const emit = defineEmits<{
  (e: 'update', id: number): void
  (e: 'delete', item: Item): void
  (e: 'change', value: string, old: string): void
}>()

// 调用时类型自动检查
emit('update', 42)         // ✅ OK
emit('update', 'hello')    // ❌ TS Error: string 不能赋给 number
emit('change', 'a', 'b')   // ✅ OK
emit('change', 'a')        // ❌ TS Error: 缺少参数

defineEmits 泛型声明让每个事件的参数类型都精确约束——调用 emit 时,参数类型和数量都会自动检查,传错类型或漏传参数都会在编译时报错。

1.3 ref 模板类型:InstanceType

当父组件通过 ref 获取子组件实例时,需要用 InstanceType<typeof Comp> 约束类型,才能获得子组件 expose 的方法提示:

<!-- 父组件 -->
<script setup lang="ts">
import ChildComp from './ChildComp.vue'

// ✅ 精确类型——自动推导 expose 的方法
const childRef = ref<InstanceType<typeof ChildComp>>()

// 调用子组件方法有类型提示
childRef.value?.focus()     // ✅ 有提示
childRef.value?.reset()     // ✅ 有提示
childRef.value?.nonExist()  // ❌ TS Error
</script>

<template>
  <ChildComp ref="childRef" />
</template>
<!-- 子组件 ChildComp.vue -->
<script setup lang="ts">
const focus = () => { /* ... */ }
const reset = () => { /* ... */ }

defineExpose({ focus, reset })
</script>

🧬 二、泛型组件与 Composable:一次编写多种类型

在这里插入图片描述

2.1 泛型组件:generic=“T”(Vue 3.3+)

Vue 3.3 引入了 <script setup lang="ts" generic="T"> 语法,让 SFC 组件支持泛型。这是 Vue3+TS 最重要的特性之一——一个泛型组件可以适配多种数据类型,不需要为 User[]、Product[]、Order[] 各写一个列表组件。

<!-- GenericList.vue -->
<script setup lang="ts" generic="T extends { id: number }">
const props = defineProps<{
  items: T[]
  labelKey: keyof T        // ✅ 只能是 T 的属性名
  valueKey?: keyof T
  selectable?: boolean
}>()

const emit = defineEmits<{
  (e: 'select', item: T): void
  (e: 'delete', id: number): void
}>()

const selectedId = ref<number | null>(null)

const handleSelect = (item: T) => {
  selectedId.value = item.id
  emit('select', item)
}
</script>

<template>
  <div class="generic-list">
    <div
      v-for="item in items"
      :key="item.id"
      @click="handleSelect(item)"
    >
      {{ item[labelKey] }}
    </div>
  </div>
</template>

使用泛型组件——类型自动推导:

<script setup lang="ts">
import GenericList from './GenericList.vue'

interface User { id: number; name: string; email: string }
interface Product { id: number; title: string; price: number }

const users: User[] = [...]
const products: Product[] = [...]

// ✅ T 自动推导为 User
// labelKey 只能是 'id' | 'name' | 'email'
<GenericList :items="users" label-key="name" @select="onUserSelect" />

// ✅ T 自动推导为 Product
// labelKey 只能是 'id' | 'title' | 'price'
<GenericList :items="products" label-key="title" @select="onProductSelect" />

// ❌ TS Error: 'phone' 不是 User 的属性
<GenericList :items="users" label-key="phone" />
</script>

泛型组件的价值:写一个 GenericList 代替 UserList + ProductList + OrderList——DRY 原则的终极实现。keyof T 约束让 labelKey 只能是 T 的属性名,传错编译报错。

2.2 泛型 Composable:function()

泛型 Composable 是类型安全的逻辑复用——useFetch<T>() 自动推导 data 类型,useLocalStorage<T>() 自动推导存储值类型。

// composables/useFetch.ts
import { ref, type Ref } from 'vue'

interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<string | null>
  loading: Ref<boolean>
  refetch: () => Promise<void>
}

export async function useFetch<T>(
  url: string,
  options?: RequestInit
): Promise<UseFetchReturn<T>> {
  const data = ref<T | null>(null) as Ref<T | null>
  const error = ref<string | null>(null)
  const loading = ref(true)

  const fetchData = async () => {
    loading.value = true
    error.value = null
    try {
      const res = await fetch(url, options)
      data.value = await res.json() as T  // ✅ 类型安全
    } catch (e) {
      error.value = (e as Error).message
    } finally {
      loading.value = false
    }
  }

  await fetchData()

  return { data, error, loading, refetch: fetchData }
}

使用泛型 Composable——类型自动推导:

// ✅ data 类型自动推导为 User[]
const { data: users } = await useFetch<User[]>('/api/users')
// users.value[0].name  ✅ 有提示
// users.value[0].age   ✅ 有提示

// ✅ data 类型自动推导为 Product
const { data: product } = await useFetch<Product>('/api/products/1')
// product.value?.title  ✅ 有提示
// product.value?.price  ✅ 有提示
// composables/useLocalStorage.ts
export function useLocalStorage<T>(
  key: string,
  defaultValue: T
): { data: Ref<T> } {
  const data = ref<T>(defaultValue) as Ref<T>

  const stored = localStorage.getItem(key)
  if (stored) {
    data.value = JSON.parse(stored) as T
  }

  watch(data, (val) => {
    localStorage.setItem(key, JSON.stringify(val))
  }, { deep: true })

  return { data }
}

// 使用
const { data: theme } = useLocalStorage<string>('theme', 'light')
const { data: settings } = useLocalStorage<AppSettings>('settings', defaults)

⚠️ 三、常见陷阱与最佳实践:远离 any

在这里插入图片描述

3.1 五大 TypeScript 陷阱

陷阱 1:使用 any——any 是类型安全的"黑洞",一旦引入,所有类型检查都失效。

// ❌ any——类型黑洞
const data: any = await fetch('/api/users')
data.whatever()  // 不报错,但运行时崩溃

// ✅ 用 unknown 代替 any
const data: unknown = await fetch('/api/users')
// data.whatever()  // TS Error: unknown 上没有方法
if (typeof data === 'object' && data !== null) {
  // 类型收窄后安全使用
}

陷阱 2:滥用 as 类型断言——as 是"掩耳盗铃",告诉编译器"我知道类型",但实际可能不是。

// ❌ as 断言——掩耳盗铃
const user = data as User  // 如果 data 不是 User,运行时崩溃

// ✅ 用类型守卫
function isUser(data: unknown): data is User {
  return typeof data === 'object'
    && data !== null
    && 'name' in data
    && 'email' in data
}
if (isUser(data)) {
  // data 安全使用
}

陷阱 3:ref 不写泛型——ref() 不写泛型,推导为 Ref,后续使用没有类型提示。

// ❌ 不写泛型——推导为 any
const count = ref()        // Ref<any>
const user = ref(null)     // Ref<any>

// ✅ 显式泛型
const count = ref<number>(0)           // Ref<number>
const user = ref<User | null>(null)    // Ref<User | null>

陷阱 4:Props 不完整声明——只声明部分 Props,其余变成 any。

// ❌ 不完整声明
defineProps<{
  title: string
  // 忘了声明 items,模板中 items 是 any
}>()

// ✅ 完整声明
defineProps<{
  title: string
  items: string[]
  loading?: boolean
}>()

陷阱 5:reactive 类型丢失——reactive() 的类型推导不如 ref 精确。

// ❌ reactive 类型不精确
const state = reactive({
  list: [],       // never[] 而不是 User[]
  count: 0,
})

// ✅ 用接口约束
interface State {
  list: User[]
  count: number
}
const state: State = reactive({
  list: [],
  count: 0,
})

3.2 TypeScript + Vue3 最佳实践清单

规则 做法 原因
不用 any 用 unknown + 类型守卫 any 是类型黑洞
不滥用 as 用类型守卫/泛型 as 是掩耳盗铃
ref 显式泛型 ref<T>() 否则推导为 any
Props 完整声明 所有 Props 都声明 否则变成 any
reactive 用接口 const state: T = reactive() 类型更精确
Composable 返回类型 显式声明返回类型 IDE 提示更好
导入类型用 type import type { X } 避免运行时引入
事件类型精确 defineEmits 泛型 参数自动检查
泛型组件约束 T extends { id } 约束泛型范围
tsconfig 严格 strict: true 开启所有检查

Vue3 实战系列完结

篇号 主题 核心内容 状态
01 组合式API ref/reactive/computed/watch/Composables
02 组件通信 props/emit/provide-inject/v-model
03 Pinia状态管理 Store/Action/持久化/模块化
04 Router路由 动态路由/守卫/懒加载
05 性能优化 虚拟列表/异步组件/SSR
06 TypeScript+Vue3(本文) 类型安全/泛型组件/类型推导

一句话总结

TypeScript + Vue3 三大维度:类型安全基础(defineProps泛型声明>运行时声明——类型即文档IDE自动补全复杂类型联合类型函数类型都支持/defineEmits泛型声明——事件类型安全参数自动检查传错类型编译报错/ref模板类型InstanceType——获取子组件实例类型调用expose方法有提示。Vue3+TS的核心=用类型约束Props/Emit/Ref让错误在编译时暴露而非运行时)、泛型组件与Composable(泛型组件generic=“T” Vue 3.3+——一个List组件适配User[]/Product[]/Order[]/keyof T约束属性名传错编译报错/T extends {id:number}约束泛型范围。泛型Composable function()——useFetch<User[]>自动推导data类型/useLocalStorage自动推导存储值类型。泛型的价值=写一次类型安全的代码适配所有数据类型DRY原则的终极实现)、常见陷阱与最佳实践(五大陷阱——any类型黑洞用unknown+类型守卫/as掩耳盗铃用类型守卫或泛型/ref不写泛型推导为any/Props不完整声明变成any/reactive类型丢失用接口约束。最佳实践清单——不用any/不滥用as/ref显式泛型/Props完整声明/reactive用接口/Composable返回类型/import type/事件类型精确/泛型组件约束/tsconfig strict:true。Vue3+TS的终极目标=让错误在编译时暴露让IDE成为最好的搭档)。


参考链接

更多推荐