文章目录
- 前言
- [一、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 泛型约束)
- [5.1 泛型列配置 TableColumn<T>](#5.1 泛型列配置 TableColumn
- [六、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?
- 在
types/api.ts定义ApiResponse<T>、PageResult<T> - 每个业务模块只声明实体类型
User、Order - API 函数返回
Promise<ApiResponse<User>>或包装后的 axios 类型 - 页面/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 内置工具类型源码(如 Awaited、Parameters)。
七、面试聚焦
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 函数泛型返回。
八、易混淆点
- 泛型是编译期概念:打包后不存在,不影响运行时。
- Partial 不是深Partial:只一层可选,嵌套对象要配合工具或手动定义。
- Pick/Omit 的 K 必须是 keyof T:写错键名编译报错。
- axios 泛型位置 :
get<T>(url)的 T 通常是data的类型,视封装而定。 - 组件 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:读懂即可,业务以组合内置工具类型为主