OpenAPI 前端集成
概述
基于 OpenAPI 规范、Orval、Axios 与 TanStack Query 构建类型安全的前端数据请求方案:
- 基于 OpenAPI 规范自动生成 TypeScript 类型定义与 Zod 校验 Schema
- 按业务模块(Tags)自动生成 API 接口函数
- 使用 TanStack Query 进行数据请求与状态管理
- 遵循 RESTful API 规范,保持前后端接口的一致性和类型安全
提示
TanStack Query(原 React Query)是一个用于管理、缓存和更新服务器状态的库:
- 数据获取: 利用 useQuery 和 useMutation 等 Hook 简化 API 请求。
- 缓存机制: 自动管理请求的数据缓存,减少重复请求。
- 错误处理: 结合 axios 拦截器处理全局与局部业务错误。
核心配置与使用方法
1. 安装依赖
pnpm add @tanstack/react-query @tanstack/react-query-devtools axios
pnpm add -D orval msw @faker-js/faker biome
2. 配置 Orval
在项目根目录配置 orval.config.ts,采用 tags-split 模式将 API 按标签拆分为多个文件,并生成 Zod Schema 用于前端数据校验:
import { defineConfig } from 'orval';
export default defineConfig({
api: {
input: {
target: './openapi.yaml', // OpenAPI 规范文件路径
},
output: {
mode: 'tags-split', // 按 Tag 拆分生成文件,适合大型项目
target: 'src/api',
schemas: 'src/api/model', // 生成的 TypeScript 接口类型
client: 'react-query',
httpClient: 'axios',
override: {
mutator: {
path: './src/api/mutator/axios.ts', // 自定义 Axios 实例
name: 'axios',
},
},
formatter: 'biome',
mock: true, // 自动生成 MSW Mock 数据
allParamsOptional: true,
urlEncodeParameters: true,
},
},
apiZod: {
input: {
target: './openapi.yaml',
},
output: {
mode: 'tags-split',
client: 'zod',
target: 'src/api',
fileExtension: '.zod.ts',
formatter: 'biome',
},
},
});
3. 创建 Axios 拦截器与自定义 Mutator
结合 RESTful API 规范创建 src/api/mutator/axios.ts,定义统一错误结构并处理异常:
import Axios from 'axios';
import type { AxiosError, AxiosRequestConfig } from 'axios';
import { toast } from 'sonner';
// 统一错误响应结构
export interface ApiErrorResponse {
code: string;
message: string;
error: string;
}
export const axiosInstance = Axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
adapter: 'fetch', // 使用 fetch adapter(Axios v1.7+)
});
// 请求拦截器:携带 Token
axiosInstance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error),
);
// 响应拦截器:处理全局 HTTP 错误状态码
axiosInstance.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiErrorResponse>) => {
if (error.response) {
const { status, data } = error.response;
switch (status) {
case 401:
localStorage.removeItem('token');
window.location.href = `/login?redirect=${encodeURIComponent(
window.location.pathname,
)}`;
break;
case 403:
toast.error('访问被拒绝', {
description: data.message || '没有操作权限',
});
break;
case 500:
toast.error('服务器内部错误', {
description: data.error || '系统异常',
});
break;
default:
break;
}
}
return Promise.reject(error);
},
);
// Orval 自定义请求函数
export const axios = <T>(
config: AxiosRequestConfig,
options?: AxiosRequestConfig,
): Promise<T> =>
axiosInstance({
...config,
...options,
}).then(({ data }) => data);
// Orval 生成 Hook 的错误类型,关联 ApiErrorResponse
export type ErrorType<Error = ApiErrorResponse> = AxiosError<Error>;
export type BodyType<BodyData> = BodyData;
4. 配置 TanStack Query 全局 Provider
创建 src/providers/QueryProvider.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import type { ReactNode } from 'react';
interface QueryProviderProps {
children: ReactNode;
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1, // 失败重试 1 次
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000, // 数据 5 分钟内视为新鲜
},
},
});
export default function QueryProvider({ children }: QueryProviderProps) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
在 src/App.tsx 中使用:
import QueryProvider from '@/providers/QueryProvider';
function App() {
return (
<QueryProvider>
<div className="App">
{/* 应用内容 */}
</div>
</QueryProvider>
);
}
export default App;
5. 生成代码
在 package.json 中配置脚本并运行:
{
"scripts": {
"orval": "orval"
}
}
业务实战指南
1. 基础查询 (GET)
import { AlertCircle } from 'lucide-react';
import { useGetUserById } from '@/api/user/user';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface UserDetailProps {
userId: string;
}
function UserDetail({ userId }: UserDetailProps) {
const { data: user, isLoading, error } = useGetUserById(userId);
if (isLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-52" />
<Skeleton className="h-24 w-full rounded-xl" />
</div>
);
}
if (error) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>加载失败</AlertTitle>
<AlertDescription>
{error.response?.data?.message || error.message}
</AlertDescription>
</Alert>
);
}
return (
<Card>
<CardHeader>
<CardTitle>用户信息</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">姓名:{user?.name}</p>
</CardContent>
</Card>
);
}
export default UserDetail;
2. 标准分页查询与列表渲染
使用 page(从 0 开始)和 size 请求,并接收 { content, meta } 响应结构:
import { useState } from 'react';
import { keepPreviousData } from '@tanstack/react-query';
import { useGetProducts } from '@/api/product/product';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
function ProductListWithPagination() {
const [page, setPage] = useState(0);
const size = 20;
// keepPreviousData:翻页时保留上一页数据,避免整表闪烁 loading
const { data, isLoading } = useGetProducts(
{ page, size },
{ query: { placeholderData: keepPreviousData } },
);
const handlePrevPage = () => {
setPage((prev) => Math.max(0, prev - 1));
};
const handleNextPage = () => {
setPage((prev) => prev + 1);
};
const totalPages = data?.meta?.pages ?? 1;
const isNextDisabled = page >= totalPages - 1;
if (isLoading) {
return (
<div className="p-4 text-center text-sm text-muted-foreground">
加载列表中...
</div>
);
}
return (
<div className="space-y-4">
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>产品名称</TableHead>
<TableHead className="text-right">库存</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.content?.map((product) => (
<TableRow key={product.id}>
<TableCell className="font-medium">{product.name}</TableCell>
<TableCell className="text-right">{product.stock}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handlePrevPage} disabled={page === 0}>
上一页
</Button>
<span className="text-sm text-muted-foreground">
当前页: {page + 1} / 共 {totalPages} 页
</span>
<Button
variant="outline"
onClick={handleNextPage}
disabled={isNextDisabled}
>
下一页
</Button>
</div>
</div>
);
}
export default ProductListWithPagination;
3. 数据变更 (POST / PUT / DELETE)
import { useQueryClient } from '@tanstack/react-query';
import { useCreateUser } from '@/api/user/user';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
function UserManager() {
const queryClient = useQueryClient();
const createUser = useCreateUser({
mutation: {
onSuccess: () => {
// 创建成功后,失效 user 相关的缓存以重新拉取
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('操作成功', {
description: '新建用户已成功创建。',
});
},
onError: (error) => {
toast.error('操作失败', {
description: error.response?.data?.message || '发生未知错误',
});
},
},
});
const handleCreateSubmit = () => {
createUser.mutate({ data: { username: 'tarzan', password: '***' } });
};
return (
<div className="flex space-x-2">
<Button onClick={handleCreateSubmit} disabled={createUser.isPending}>
{createUser.isPending ? '创建中...' : '新建用户'}
</Button>
</div>
);
}
export default UserManager;
4. 结合 Zod 进行数据转换与防御性编程
利用 Orval 生成的 Zod Schema 校验响应数据:
import { isAfter, subDays } from 'date-fns';
import { getUserByIdResponse } from '@/api/user/user.zod';
import { useGetUserById } from '@/api/user/user';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
// 基于生成的 Schema 追加前端计算属性
const UserWithComputedSchema = getUserByIdResponse.transform((data) => ({
...data,
isActive: data.lastLoginAt
? isAfter(new Date(data.lastLoginAt), subDays(new Date(), 30))
: false,
}));
interface UserCardProps {
userId: string;
}
function UserCard({ userId }: UserCardProps) {
const { data: rawUser, isLoading } = useGetUserById(userId);
if (isLoading) {
return <Skeleton className="h-32 w-full" />;
}
const userResult = UserWithComputedSchema.safeParse(rawUser);
if (!userResult.success) {
return (
<div className="p-4 text-red-500">数据格式异常,无法渲染卡片</div>
);
}
const user = userResult.data;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{user.username}</span>
<Badge variant={user.isActive ? 'default' : 'secondary'}>
{user.isActive ? '活跃' : '不活跃'}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">ID: {user.id}</p>
</CardContent>
</Card>
);
}
export default UserCard;
5. 规范化错误处理
基于 RESTful 规范提取精确的错误代码 (code) 来处理特定业务逻辑:
import { AlertCircle } from 'lucide-react';
import { useGetUserById } from '@/api/user/user';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface UserProfileProps {
userId: string;
}
function UserProfile({ userId }: UserProfileProps) {
const { data, error, isLoading } = useGetUserById(userId, {
query: {
retry: (failureCount, err) => {
if (err.response?.status === 404) {
return false; // 404 不重试
}
return failureCount < 3;
},
},
});
if (isLoading) {
return (
<div className="text-sm text-muted-foreground">加载中...</div>
);
}
if (error) {
const apiCode = error.response?.data?.code;
const apiMessage = error.response?.data?.message || '未知服务错误';
// 基于业务错误码执行特定视图逻辑
if (apiCode === 'A0201') {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>账户异常</AlertTitle>
<AlertDescription>
检测到当前用户账户不存在,请联系管理员。
</AlertDescription>
</Alert>
);
}
return (
<Alert variant="destructive">
<AlertDescription>请求错误: {apiMessage}</AlertDescription>
</Alert>
);
}
return <div>姓名:{data?.name}</div>;
}
export default UserProfile;
推荐的目录架构
基于 tags-split 模式生成后,推荐的项目结构如下:
src/
├── api/
│ ├── mutator/
│ │ └── axios.ts # Axios 实例、ApiErrorResponse 及拦截器配置
│ ├── model/ # Orval 生成的所有 TS Interface
│ │ ├── user.ts
│ │ └── index.ts
│ └── user/ # 按 Tag 生成的接口目录
│ ├── user.ts # Hook 及请求方法
│ └── user.zod.ts # Zod 校验结构
├── providers/
│ └── QueryProvider.tsx # TanStack Query 全局配置
└── pages/