APIClient技能Skill api-client

提供使用 TanStack Query 和 Axios 构建 API 客户端的专业指导,包括 JWT 令牌管理、错误处理、响应解析和分页列表处理。

前端开发 0 次安装 0 次浏览 更新于 3/2/2026

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

输出要求

代码文件

  1. API 客户端:

    • lib/apiClient.ts - 带有拦截器的 Axios 实例
    • lib/queryClient.ts - TanStack Query 配置
  2. 错误处理:

    • lib/errorHandler.ts - 全局错误处理器
    • hooks/useApi.ts - 类型安全的 API 钩子
  3. 类型定义:

    • lib/api/types.ts - Zod 模式和类型
  4. 功能钩子:

    • hooks/useStudents.ts - 特定于学生的钩子
    • hooks/useAttendance.ts - 特定于出勤的钩子

集成要求

  • @auth-integration: 使用来自认证存储的 JWT 令牌
  • @react-component: 带有钩子的功能性组件
  • @tailwind-css: 支持移动设备的响应式 UI

文档

  • PHR: 为 API 决策创建提示历史记录
  • ADR: 文档缓存策略,重试政策
  • 注释: 文档 API 端点和数据流

工作流程

  1. 设置 API 客户端

    • 配置 TanStack Query
    • 创建 Axios 实例
    • 设置 JWT 拦截器
  2. 定义类型

    • 创建 Zod 模式
    • 导出 TypeScript 类型
  3. 创建钩子

    • 构建 useApi 和 useApiMutation
    • 添加特定于功能
    • 实施错误处理
  4. 与认证集成

    • 自动附加 JWT 令牌
    • 处理 401 响应
    • 令牌到期时刷新令牌
  5. 实现功能

    • 查询钩子用于数据获取
    • 带有乐观更新的突变钩子
    • 分页的无限查询
  6. 测试和优化

    • 测试错误场景
    • 验证离线行为
    • 优化缓存策略

质量检查表

在完成任何 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

参考资料