泛型与工具类型在 Vue 中的应用

文章目录

  • 前言
  • [一、Vue 项目为什么需要泛型](#一、Vue 项目为什么需要泛型)
    • [1.1 重复类型的问题](#1.1 重复类型的问题)
    • [1.2 Vue 中的典型场景](#1.2 Vue 中的典型场景)
  • [二、composable 中的泛型](#二、composable 中的泛型)
    • [2.1 泛型函数](#2.1 泛型函数)
    • [2.2 泛型约束 extends](#2.2 泛型约束 extends)
    • [2.3 泛型 interface 描述返回值](#2.3 泛型 interface 描述返回值)
  • [三、API 层类型封装](#三、API 层类型封装)
    • [3.1 统一响应结构](#3.1 统一响应结构)
    • [3.2 接口返回 `{ code, data }` 如何统一 typing?](#3.2 接口返回 { code, data } 如何统一 typing?)
  • [四、工具类型在 Vue 业务中的用法](#四、工具类型在 Vue 业务中的用法)
    • [4.1 Partial:编辑表单](#4.1 Partial:编辑表单)
    • [4.2 Pick / Omit:DTO 与展示字段](#4.2 Pick / Omit:DTO 与展示字段)
    • [4.3 Record:映射与字典](#4.3 Record:映射与字典)
    • [4.4 Required / Readonly](#4.4 Required / Readonly)
    • [4.5 ReturnType:推导 composable / API](#4.5 ReturnType:推导 composable / API)
  • 五、表格与表单中的泛型
    • [5.1 泛型列配置 TableColumn<T>](#5.1 泛型列配置 TableColumn)
    • [5.2 泛型表格 Props](#5.2 泛型表格 Props)
    • [5.3 表单 model 泛型约束](#5.3 表单 model 泛型约束)
  • [六、infer 入门(能读懂即可)](#六、infer 入门(能读懂即可))
  • 七、面试聚焦
    • [7.1 手写 Partial / Pick](#7.1 手写 Partial / Pick)
    • [7.2 泛型 T 何时需要 extends?](#7.2 泛型 T 何时需要 extends?)
    • [7.3 接口 `{ code, data }` 如何统一?](#7.3 接口 { code, data } 如何统一?)
  • 八、易混淆点
  • 九、思考与练习
  • 总结

前言

前两篇已经能写带类型的 Vue 组件,但当项目里出现通用表格、统一接口响应、可复用 composable 时,单靠 interface 往往不够,需要泛型与工具类型把「类型逻辑」抽出来复用。

本篇聚焦 Vue 3 项目里的泛型实战,讲清楚:

  • composable 与 API 层如何用泛型
  • Partial / Pick / Omit 等在业务中的典型用法
  • 表格列、分页、接口响应的类型封装

一、Vue 项目为什么需要泛型

1.1 重复类型的问题

typescript 复制代码
// ❌ 每个接口手写一遍,结构相同只有 data 不同
interface UserListRes {
  code: number
  message: string
  data: User[]
}

interface OrderListRes {
  code: number
  message: string
  data: Order[]
}
typescript 复制代码
// ✅ 泛型统一结构
interface ApiResponse<T> {
  code: number
  message: string
  data: T
}

type UserListRes = ApiResponse<User[]>
type OrderListRes = ApiResponse<Order[]>

1.2 Vue 中的典型场景

场景 泛型作用
接口响应 ApiResponse<T>PageResult<T>
通用表格 TableColumn<Row> 约束 field
composable useFetch<T>() 推导 data 类型
表单 FormModel<T> 编辑时 Partial<T>
组件 Props defineProps<{ list: T[] }>()(配合泛型组件)

二、composable 中的泛型

2.1 泛型函数

typescript 复制代码
// composables/useFetch.ts
import { ref, type Ref } from 'vue'

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

export function useFetch<T>(url: string): UseFetchReturn<T> {
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<Error | null>(null)

  const execute = async () => {
    loading.value = true
    error.value = null
    try {
      const res = await fetch(url)
      data.value = (await res.json()) as T
    } catch (e) {
      error.value = e as Error
    } finally {
      loading.value = false
    }
  }

  return { data, loading, error, execute }
}
vue 复制代码
<script setup lang="ts">
interface User {
  id: number
  name: string
}

const { data, loading, execute } = useFetch<User[]>('/api/users')
// data 类型:Ref<User[] | null>

onMounted(execute)
</script>

2.2 泛型约束 extends

当泛型需要访问特定属性时,用 extends 约束:

typescript 复制代码
interface Identifiable {
  id: number | string
}

// T 必须有 id,才能安全用作 :key
export function useRowSelect<T extends Identifiable>() {
  const selectedIds = ref<Array<T['id']>>([])

  const toggle = (row: T) => {
    const idx = selectedIds.value.indexOf(row.id)
    if (idx > -1) selectedIds.value.splice(idx, 1)
    else selectedIds.value.push(row.id)
  }

  return { selectedIds, toggle }
}

何时用 extends :T 上要用某字段、某方法,或要传入 keyof T 时。

2.3 泛型 interface 描述返回值

typescript 复制代码
interface UsePaginationOptions {
  pageSize?: number
}

interface UsePaginationReturn<T> {
  list: Ref<T[]>
  page: Ref<number>
  total: Ref<number>
  loading: Ref<boolean>
  load: () => Promise<void>
}

export function usePagination<T>(
  fetcher: (page: number, size: number) => Promise<{ list: T[]; total: number }>,
  options: UsePaginationOptions = {}
): UsePaginationReturn<T> {
  const pageSize = options.pageSize ?? 10
  const list = ref<T[]>([]) as Ref<T[]>
  const page = ref(1)
  const total = ref(0)
  const loading = ref(false)

  const load = async () => {
    loading.value = true
    try {
      const res = await fetcher(page.value, pageSize)
      list.value = res.list
      total.value = res.total
    } finally {
      loading.value = false
    }
  }

  return { list, page, total, loading, load }
}

显式声明 UsePaginationReturn<T> 的好处:composable 对外 API 一目了然,IDE 提示完整。


三、API 层类型封装

3.1 统一响应结构

typescript 复制代码
// types/api.ts
export interface ApiResponse<T = unknown> {
  code: number
  message: string
  data: T
}

export interface PageResult<T> {
  list: T[]
  total: number
  page: number
  pageSize: number
}

export type PageResponse<T> = ApiResponse<PageResult<T>>
typescript 复制代码
// api/user.ts
import type { ApiResponse, PageResponse } from '@/types/api'

export interface User {
  id: number
  name: string
  role: string
}

export function getUserList(params: { page: number; pageSize: number }) {
  return request.get<PageResponse<User>>('/users', { params })
}

export function getUserDetail(id: number) {
  return request.get<ApiResponse<User>>(`/users/${id}`)
}
vue 复制代码
<script setup lang="ts">
import { getUserList, type User } from '@/api/user'

const list = ref<User[]>([])

const load = async () => {
  const res = await getUserList({ page: 1, pageSize: 10 })
  if (res.code === 200) {
    list.value = res.data.list  // User[],有完整提示
  }
}
</script>

3.2 接口返回 { code, data } 如何统一 typing?

  1. types/api.ts 定义 ApiResponse<T>PageResult<T>
  2. 每个业务模块只声明实体类型 UserOrder
  3. API 函数返回 Promise<ApiResponse<User>> 或包装后的 axios 类型
  4. 页面/composable 从 res.data 取数,类型自动推导

避免每个 .ts 文件重复写 code/message/data 结构。


四、工具类型在 Vue 业务中的用法

4.1 Partial:编辑表单

typescript 复制代码
interface User {
  id: number
  name: string
  email: string
  role: 'admin' | 'user'
}

// 新增:没有 id
type UserCreate = Omit<User, 'id'>

// 编辑:所有字段可选(或 Pick 部分字段)
type UserUpdate = Partial<User> & { id: number }
vue 复制代码
<script setup lang="ts">
const form = ref<Partial<User>>({ name: '', email: '' })

const openEdit = (row: User) => {
  form.value = { ...row }  // Partial 可接收完整 User
}
</script>

手写 Partial

typescript 复制代码
type MyPartial<T> = {
  [K in keyof T]?: T[K]
}

4.2 Pick / Omit:DTO 与展示字段

typescript 复制代码
// 列表页只展示部分字段
type UserListItem = Pick<User, 'id' | 'name' | 'role'>

// 提交时不传 id
type UserSubmit = Omit<User, 'id' | 'createdAt'>

// 表格列配置只允许 keyof Row
type TableField<Row> = keyof Row

手写 Pick

typescript 复制代码
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P]
}

4.3 Record:映射与字典

typescript 复制代码
// 状态文案映射
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'done'

const statusLabel: Record<OrderStatus, string> = {
  pending: '待支付',
  paid: '已支付',
  shipped: '已发货',
  done: '已完成'
}

// 权限码 → 是否可见
const permissionMap: Record<string, boolean> = {}

模板中配合 formatter:

vue 复制代码
<template>
  <span>{{ statusLabel[row.status] }}</span>
</template>

4.4 Required / Readonly

typescript 复制代码
// 配置项合并后要求全必填
type AppConfig = Required<Partial<AppConfigInput>>

// 常量配置不允许改
const routes: Readonly<RouteRecordRaw[]> = [...]

4.5 ReturnType:推导 composable / API

typescript 复制代码
import { useUserStore } from '@/stores/user'

type UserStore = ReturnType<typeof useUserStore>

// 从函数返回值反推类型,少手写一遍
type FetchUserResult = ReturnType<typeof getUserDetail>

五、表格与表单中的泛型

5.1 泛型列配置 TableColumn

typescript 复制代码
// types/table.ts
export interface TableColumn<Row extends Record<string, unknown>> {
  field: keyof Row & string
  title: string
  width?: number
  formatter?: (row: Row) => string
  sortable?: boolean
}
typescript 复制代码
interface Order {
  id: number
  orderNo: string
  amount: number
  status: OrderStatus
}

const columns: TableColumn<Order>[] = [
  { field: 'orderNo', title: '订单号', width: 160 },
  { field: 'amount', title: '金额', formatter: (row) => `¥${row.amount}` },
  { field: 'status', title: '状态' }
  // field: 'xxx' 写错键名 → TS 报错
]

field: keyof Row 保证列字段名与行数据一致,重构改字段名时 columns 同步报错。

5.2 泛型表格 Props

vue 复制代码
<!-- components/DataTable.vue -->
<script setup lang="ts" generic="Row extends Record<string, unknown>">
import type { TableColumn } from '@/types/table'

defineProps<{
  columns: TableColumn<Row>[]
  data: Row[]
  loading?: boolean
}>()
</script>

Vue 3.3+ 支持 generic 声明组件级泛型参数,父组件传入时自动推导 Row

vue 复制代码
<template>
  <DataTable :columns="orderColumns" :data="orderList" />
</template>

5.3 表单 model 泛型约束

typescript 复制代码
interface FormRules<T> {
  [K in keyof T]?: Array<(value: T[K]) => boolean | string>
}

function useForm<T extends Record<string, unknown>>(initial: T) {
  const model = ref<T>({ ...initial }) as Ref<T>

  const reset = () => {
    model.value = { ...initial }
  }

  return { model, reset }
}

六、infer 入门(能读懂即可)

条件类型 + infer 多用于读库类型或高级工具类型,Vue 业务代码偶尔会遇到:

typescript 复制代码
// 提取 Promise 的 resolve 类型
type Awaited<T> = T extends Promise<infer U> ? U : T

type R = Awaited<Promise<User[]>>  // User[]

// 提取函数返回值
type FnReturn<T> = T extends (...args: unknown[]) => infer R ? R : never

type Data = FnReturn<typeof getUserList>  // Promise<...>

日常开发:会用在 composable 返回值推导即可 ,不必手写复杂 infer。需要时查 TS 内置工具类型源码(如 AwaitedParameters)。


七、面试聚焦

7.1 手写 Partial / Pick

typescript 复制代码
type Partial<T> = { [K in keyof T]?: T[K] }
type Pick<T, K extends keyof T> = { [P in K]: T[P] }

7.2 泛型 T 何时需要 extends?

当使用 T 的某属性、要求 T extends SomeInterface、或 K extends keyof T 时。

7.3 接口 { code, data } 如何统一?

定义 ApiResponse<T>,业务只写实体类型,API 函数泛型返回。


八、易混淆点

  1. 泛型是编译期概念:打包后不存在,不影响运行时。
  2. Partial 不是深Partial:只一层可选,嵌套对象要配合工具或手动定义。
  3. Pick/Omit 的 K 必须是 keyof T:写错键名编译报错。
  4. axios 泛型位置get<T>(url) 的 T 通常是 data 的类型,视封装而定。
  5. 组件 generic 需 Vue 3.3+:老版本可用 props 传类型或多个具体组件。

九、思考与练习

1. 如何用泛型统一列表接口返回类型?

解析:定义 ApiResponse<PageResult<T>>,T 为行实体 User/Order,API 函数指定泛型参数。

2. 编辑表单为什么常用 Partial<User>

解析:编辑时可能只改部分字段;新增用 Omit<User, 'id'> 更合适。

3. TableColumn<Order> 中 field 为什么用 keyof Order

解析:保证列字段与行数据键一致,避免拼写错误,重构可追踪。

4. 手写 Pick 的思路?

解析:映射类型 [P in K]: T[P],K constrained 为 keyof T

5. composable 返回值类型何时显式声明?

解析:对外 API 复杂、多人协作、需导出给别处用时,写 UseXxxReturn<T>;简单时可依赖推导。


总结

  • 泛型:复用结构相同、类型不同的定义(API、表格、composable)
  • extends:约束 T 具备 id、keyof 等,才能安全使用属性
  • 工具类型:Partial 编辑、Pick/Omit 字段裁剪、Record 映射、ReturnType 推导
  • Vue 实战ApiResponse<T>TableColumn<Row>usePagination<T>
  • infer:读懂即可,业务以组合内置工具类型为主