API Client 技能
概览
使用 TanStack Query/Axios 实现 API 客户端的专业指导,包括通过拦截器附加 JWT 令牌,全局错误处理与 toast 通知,使用 Zod 进行类型安全响应解析,以及离线检测以实现健壮的数据获取。
适用场景
此技能在用户请求以下内容时触发:
- API 设置: “设置 API 客户端”, “配置 TanStack Query”, “Axios 实例”
- 数据获取: “获取学生数据”, “获取出勤”, “API 调用”
- JWT/令牌: “附加 JWT 令牌”, “Bearer 令牌头”, “令牌刷新”
- 错误处理: “API 错误 toast”, “处理 401”, “重试失败请求”
- 响应解析: “类型安全响应”, “Zod 验证”, “解析 API 数据”
- 分页: “分页列表”, “无限查询”, “加载更多数据”
核心规则
1. 设置: TanStack Query 配置
// lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 分钟
gcTime: 10 * 60 * 1000, // 10 分钟
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
},
mutations: {
retry: 1,
},
},
});
// app/layout.tsx 或 app/providers.tsx
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from '@/lib/queryClient';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
要求:
- 使用 TanStack Query v5 进行数据获取
- 配置适当的 staleTime 和 gcTime
- 设置带有指数退避的重试策略
- 用 QueryClientProvider 包装应用
- 对于复杂场景使用 Axios 作为后备
2. JWT: 拦截器自动附加
// lib/apiClient.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { useAuthStore } from '@/lib/auth-store';
class ApiClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api',
timeout: 10000, // 10 秒
});
this.setupInterceptors();
}
private setupInterceptors() {
// 请求拦截器 - 附加 JWT 令牌
this.client.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const { session } = useAuthStore.getState();
if (session?.token && config.headers) {
config.headers.Authorization = `Bearer ${session.token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截器 - 处理错误和 401
this.client.interceptors.response.use(
(response: AxiosResponse) => response,
async (error) => {
if (error.response?.status === 401) {
const { refresh } = useAuthStore.getState();
try {
const newToken = await refresh();
if (newToken) {
error.config!.headers!.Authorization = `Bearer ${newToken}`;
return this.client(error.config!);
}
} catch (refreshError) {
useAuthStore.getState().signOut();
window.location.href = '/auth/login';
}
}
return Promise.reject(error);
}
);
}
get<T>(url: string, config?: AxiosRequestConfig) {
return this.client.get<T>(url, config);
}
post<T>(url: string, data?: any, config?: AxiosRequestConfig) {
return this.client.post<T>(url, data, config);
}
put<T>(url: string, data?: any, config?: AxiosRequestConfig) {
return this.client.put<T>(url, data, config);
}
delete<T>(url: string, config?: AxiosRequestConfig) {
return this.client.delete<T>(url, config);
}
}
export const apiClient = new ApiClient();
要求:
- 创建带有 baseURL 和 timeout 的 Axios 实例
- 请求拦截器附加来自认证存储的 JWT
- 响应拦截器处理 401 和令牌刷新
- 刷新失败时自动重定向到登录
- 使用 TypeScript 泛型进行类型安全方法
3. 错误:全局处理器
// lib/errorHandler.ts
import axios from 'axios';
import { toast } from 'sonner';
export const handleApiError = (error: any) => {
if (axios.isAxiosError(error)) {
const message = error.response?.data?.message || error.message;
switch (error.response?.status) {
case 400:
toast.error('Bad Request', { description: message });
break;
case 401:
toast.error('Unauthorized', { description: 'Please log in again' });
break;
case 403:
toast.error('Forbidden', { description: 'You do not have permission' });
break;
case 404:
toast.error('Not Found', { description: message });
break;
case 429:
toast.error('Too Many Requests', { description: 'Please try again later' });
break;
case 500:
toast.error('Server Error', { description: message });
break;
default:
toast.error('Error', { description: message || 'Something went wrong' });
}
} else {
toast.error('Network Error', { description: error.message || 'Something went wrong' });
}
};
// hooks/useApi.ts
import { useQuery, useMutation, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query';
import { apiClient } from '@/lib/apiClient';
import { handleApiError } from '@/lib/errorHandler';
import { z } from 'zod';
export function useApi<T>(
queryKey: any[],
url: string,
options?: Omit<UseQueryOptions<T>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey,
queryFn: async () => {
const response = await apiClient.get<T>(url);
return response.data;
},
...options,
});
}
export function useApiMutation<T, V = any>(
url: string,
options?: Omit<UseMutationOptions<T, V, void>, 'mutationFn'>,
schema?: z.ZodSchema<T>
) {
return useMutation({
mutationFn: async (variables: V) => {
const response = await apiClient.post<T>(url, variables);
// 如果提供 Zod 验证响应
if (schema) {
try {
const parsed = schema.parse(response.data);
return parsed;
} catch (error) {
if (error instanceof z.ZodError) {
toast.error('Validation Error', { description: error.errors[0].message });
throw new Error(`Response validation failed: ${error.errors[0].message}`);
}
}
}
return response.data;
},
onError: (error) => {
options?.onError?.(error);
handleApiError(error);
},
onSuccess: (data, variables) => {
options?.onSuccess?.(data, variables);
if (options?.context?.successMessage) {
toast.success('Success', { description: options.context.successMessage });
}
},
});
}
要求:
- 全局错误处理器与 toast 通知
- 适当处理所有 HTTP 状态代码
- Zod 模式验证响应解析
- toast 中自动显示错误
- 突变的成功消息处理
4. 解析:类型化响应,乐观更新
// lib/api/types.ts
import { z } from 'zod';
// 学生类型与 Zod 模式
export const StudentSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
role: z.enum(['student', 'teacher', 'admin']),
classId: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
});
export type Student = z.infer<typeof StudentSchema>;
// 出勤类型
export const AttendanceSchema = z.object({
id: z.string(),
studentId: z.string(),
date: z.string(),
status: z.enum(['present', 'absent', 'late']),
notes: z.string().optional(),
});
export type Attendance = z.infer<typeof AttendanceSchema>;
// 分页响应类型
export function PaginatedResponseSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
data: z.array(itemSchema),
meta: z.object({
total: z.number(),
page: z.number(),
pageSize: z.number(),
totalPages: z.number(),
}),
});
}
// hooks/useStudents.ts
import { useApi } from './useApi';
import { StudentSchema, PaginatedResponseSchema } from '@/lib/api/types';
export function useStudents(page = 1, pageSize = 20) {
return useApi(
['students', 'page', page],
`/students?page=${page}&pageSize=${pageSize}`,
{
select: (data) => {
const parsed = PaginatedResponseSchema(StudentSchema).parse(data);
return parsed;
},
}
);
}
// hooks/useUpdateStudent.ts
export function useUpdateStudent() {
const queryClient = useQueryClient();
return useApiMutation(
(variables: { id: string; data: Partial<Student> }) =>
`/students/${variables.id}`,
{
onSuccess: (_, variables) => {
// 使查询无效并重新获取
queryClient.invalidateQueries({ queryKey: ['students'] });
queryClient.invalidateQueries({ queryKey: ['student', variables.id] });
},
context: { successMessage: 'Student updated successfully' },
}
);
}
// hooks/useDeleteStudent.ts
export function useDeleteStudent() {
const queryClient = useQueryClient();
return useApiMutation(
(id: string) => `/students/${id}`,
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['students'] });
},
context: { successMessage: 'Student deleted successfully' },
}
);
}
// 无限查询分页列表
import { useInfiniteQuery } from '@tanstack/react-query';
import { StudentSchema } from '@/lib/api/types';
export function useInfiniteStudents() {
return useInfiniteQuery({
queryKey: ['students', 'infinite'],
queryFn: async ({ pageParam = 1 }) => {
const response = await apiClient.get(`/students?page=${pageParam}&pageSize=20`);
const data = response.data.map((item: any) => StudentSchema.parse(item));
return {
data,
nextPage: data.length === 20 ? pageParam + 1 : null,
};
},
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
});
}
// 乐观更新与回滚
export function useUpdateAttendance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ studentId, date, status }: { studentId: string; date: string; status: string }) => {
return apiClient.put(`/attendance/${studentId}/${date}`, { status });
},
onMutate: async ({ studentId, date, status }) => {
// 取消外出查询
await queryClient.cancelQueries({ queryKey: ['attendance', studentId] });
// 快照先前值
const previousAttendance = queryClient.getQueryData(['attendance', studentId]);
// 乐观更新
queryClient.setQueryData(['attendance', studentId], (old: any) => ({
...old,
data: old.data.map((item: any) =>
item.date === date ? { ...item, status } : item
),
}));
return { previousAttendance };
},
onError: (error, variables, context) => {
// 错误回滚
if (context?.previousAttendance) {
queryClient.setQueryData(['attendance', variables.studentId], context.previousAttendance);
}
},
onSettled: (_, __, variables) => {
// 成功或错误后重新获取
queryClient.invalidateQueries({ queryKey: ['attendance', variables.studentId] });
},
});
}
// 离线检测
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
// 可取消请求的 AbortController
export function useFetchWithAbort<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
const fetchData = useCallback(async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
setLoading(true);
setError(null);
try {
const response = await apiClient.get<T>(url, {
signal: abortControllerRef.current.signal,
});
setData(response.data);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err);
}
} finally {
setLoading(false);
}
}, [url]);
return { data, error, loading, refetch: fetchData, abort: () => abortControllerRef.current?.abort() };
}
要求:
- 无限查询用于分页列表
- 乐观更新以获得即时反馈
- 错误回滚
- 离线检测和处理
- 可取消请求的 AbortController
输出要求
代码文件
-
API 客户端:
lib/apiClient.ts- 带有拦截器的 Axios 实例lib/queryClient.ts- TanStack Query 配置
-
错误处理:
lib/errorHandler.ts- 全局错误处理器hooks/useApi.ts- 类型安全的 API 钩子
-
类型定义:
lib/api/types.ts- Zod 模式和类型
-
功能钩子:
hooks/useStudents.ts- 特定于学生的钩子hooks/useAttendance.ts- 特定于出勤的钩子
集成要求
- @auth-integration: 使用来自认证存储的 JWT 令牌
- @react-component: 带有钩子的功能性组件
- @tailwind-css: 支持移动设备的响应式 UI
文档
- PHR: 为 API 决策创建提示历史记录
- ADR: 文档缓存策略,重试政策
- 注释: 文档 API 端点和数据流
工作流程
-
设置 API 客户端
- 配置 TanStack Query
- 创建 Axios 实例
- 设置 JWT 拦截器
-
定义类型
- 创建 Zod 模式
- 导出 TypeScript 类型
-
创建钩子
- 构建 useApi 和 useApiMutation
- 添加特定于功能
- 实施错误处理
-
与认证集成
- 自动附加 JWT 令牌
- 处理 401 响应
- 令牌到期时刷新令牌
-
实现功能
- 查询钩子用于数据获取
- 带有乐观更新的突变钩子
- 分页的无限查询
-
测试和优化
- 测试错误场景
- 验证离线行为
- 优化缓存策略
质量检查表
在完成任何 API 客户端实现之前:
- [ ] 类型安全的请求/响应: 所有数据的 Zod 模式
- [ ] 失败时重试: 重试请求的指数退避
- [ ] 离线检测: 处理网络断开
- [ ] AbortController: 支持可取消请求
- [ ] JWT 自动附加: 带有授权持有者的头部
- [ ] 错误处理: 全局错误处理器与 toast
- [ ] 401 注销: 令牌到期时自动重定向
- [ ] Zod 验证: 响应模式验证
- [ ] 乐观更新: 即时 UI 反馈
- [ ] 查询无效化: 自动缓存更新
常见模式
获取学生数据
// hooks/useStudent.ts
export function useStudent(id: string) {
return useApi(
['student', id],
`/students/${id}`,
{
enabled: !!id, // 仅在存在 id 时获取
}
);
}
// 使用
function StudentProfile({ studentId }: { studentId: string }) {
const { data: student, isLoading, error } = useStudent(studentId);
if (isLoading) return <LoadingSkeleton />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
<h1>{student?.name}</h1>
<p>{student?.email}</p>
</div>
);
}
API 错误 Toast 与 Zod 解析
// hooks/useCreateStudent.ts
export function useCreateStudent() {
const queryClient = useQueryClient();
return useApiMutation(
async (data: { name: string; email: string }) => {
const response = await apiClient.post('/students', data);
// Zod 验证
const parsed = StudentSchema.parse(response.data);
return parsed;
},
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['students'] });
},
context: { successMessage: 'Student created successfully' },
}
);
}
// 使用
function CreateStudentForm() {
const { mutate: createStudent, isPending } = useCreateStudent();
const handleSubmit = (data: FormData) => {
createStudent(data);
};
return <form onSubmit={handleSubmit}>{/* 表单字段 */}</form>;
}
带有无限查询的分页列表
// hooks/useInfiniteStudents.ts
export function useInfiniteStudents() {
return useInfiniteQuery({
queryKey: ['students', 'infinite'],
queryFn: async ({ pageParam = 1 }) => {
const response = await apiClient.get(`/students?page=${pageParam}&pageSize=20`);
const parsed = z.array(StudentSchema).parse(response.data);
return {
data: parsed,
nextPage: parsed.length === 20 ? pageParam + 1 : null,
};
},
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
});
}
// 使用
function StudentList() {
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteStudents();
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.data.map((student) => (
<StudentCard key={student.id} student={student} />
))}
</div>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
支持离线的出勤获取
// hooks/useAttendance.ts
export function useAttendance(studentId: string, date: string) {
const isOnline = useOnlineStatus();
return useApi(
['attendance', studentId, date],
`/attendance/${studentId}/${date}`,
{
enabled: !!studentId && !!date && isOnline,
staleTime: 5 * 60 * 1000,
}
);
}
// 使用
function AttendanceCard({ studentId, date }: { studentId: string; date: string }) {
const { data: attendance, isLoading, error } = useAttendance(studentId, date);
const isOnline = useOnlineStatus();
if (!isOnline) {
return <OfflineMessage />;
}
if (isLoading) return <LoadingSkeleton />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
<p>Status: {attendance?.status}</p>
</div>
);
}
缓存策略
// lib/queryClient.ts
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 新鲜数据在 5 分钟后被视为过时
staleTime: 5 * 60 * 1000,
// 在 10 分钟后垃圾收集未使用的查询
gcTime: 10 * 60 * 1000,
// 重试失败请求 3 次
retry: 3,
// 指数退避:1s, 2s, 4s (最大 30s)
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
// 窗口焦点时重新获取(可选)
refetchOnWindowFocus: false,
// 重新连接时重新获取
refetchOnReconnect: true,
},
},
});
环境变量
# .env.local
NEXT_PUBLIC_API_URL=http://localhost:3001/api
# For production
NEXT_PUBLIC_API_URL=https://api.yourapp.com
参考资料
- TanStack Query: https://tanstack.com/query/latest
- Axios: https://axios-http.com
- Zod: https://zod.dev
- React Query Examples: https://tanstack.com/query/latest/docs/react/examples