axios 请求封装
以下集成内容仅对axios的封装思想进行说明,如需进行代码对照,可参考目录下 react19_ts 项目内实际代码,文档与代码会在git上持续补充
git仓库地址:https://gitee.com/xiaoli-account/react19_ts核心思想
- axios请求封装,一定要理解思想,而不是照搬照抄,那会让你失去对axios的掌控力,没办法随心所欲的根据项目的不同去定制处理
- 封装的目的:统一处理请求/响应、错误处理、token管理、加载状态等通用逻辑
- 保持灵活性:通过配置项控制行为,避免过度封装导致无法满足特殊需求
https://axios-http.com/zh/docs/intro
封装结构
- 实例创建:创建 axios 实例,配置基础 URL、超时时间、默认请求头
- 请求拦截器:统一添加 token、显示加载进度、处理请求前逻辑
- 响应拦截器:统一处理业务状态码、HTTP 错误、自动跳转错误页面
- HTTP 方法封装:封装 GET、POST、PUT、DELETE、PATCH 等常用方法
- 文件上传下载:封装文件上传和下载方法,处理 FormData 和 Blob
- 方法导出:导出实例方法和绑定方法,支持多种使用方式
核心功能
1. 请求拦截器
- 自动添加 Token:从 user store 获取 token 并添加到请求头
- 加载进度条:使用 NProgress 显示请求进度(可通过 `skipLoading` 跳过)
- 统一请求头:默认设置 `Content-Type: application/json`
2. 响应拦截器
- 业务状态码处理:根据 `success` 或 `code === 200` 判断请求成功
- HTTP 状态码处理:统一处理 401、403、404、500、502、503、504 等错误
- 自动错误跳转:根据错误码自动跳转到对应错误页面
- 错误消息提示:使用 Ant Design 的 message 组件显示错误信息
3. 错误处理机制
- 业务错误:通过 `handleBusinessError` 处理业务层面的错误(如 code !== 200)
- HTTP 错误:通过 `handleHttpError` 处理 HTTP 层面的错误(如 404、500)
- 网络错误:处理超时、网络断开等异常情况
- 跳过错误处理:通过 `skipErrorHandler` 配置项跳过全局错误处理
4. 认证处理
- Token 获取:从 `useUserStore` 获取 token
- 401 处理:自动清除缓存并跳转到登录页
- Token 注入:自动在请求头中添加 `Authorization: ${token}`
使用方式
基础使用
import { $get, $post, $put, $delete, $patch } from "@/utils/request";// GET 请求const getUserInfo = async () => { const res = await $get<{ name: string; age: number }>("/user/info"); console.log(res.data); // 直接获取 data 字段};// POST 请求const createUser = async (userData: { name: string; email: string }) => { const res = await $post("/user/create", userData); return res.data;};// PUT 请求const updateUser = async (id: string, userData: any) => { const res = await $put(`/user/${id}`, userData); return res.data;};// DELETE 请求const deleteUser = async (id: string) => { const res = await $delete(`/user/${id}`); return res.data;};
跳过全局处理
// 跳过错误处理(自定义错误处理)const res = await $get("/api/data", { skipErrorHandler: true,});// 跳过加载进度条const res = await $get("/api/data", { skipLoading: true,});
文件上传
import { $upload } from "@/utils/request";const handleUpload = async (file: File) => { try { const res = await $upload("/upload", file); console.log("上传成功", res.data); } catch (error) { console.error("上传失败", error); }};
文件下载
import { $download } from "@/utils/request";const handleDownload = async () => { try { await $download("/download/file", "文件名.pdf"); } catch (error) { console.error("下载失败", error); }};
使用实例方法
import { request } from "@/utils/request";// 使用实例方法(需要绑定 this)const getUser = async () => { const res = await request.get("/user/info"); return res.data;};
使用原始 axios 实例
import { httpInstance } from "@/utils/request";// 特殊场景下直接使用 axios 实例const res = await httpInstance.get("/special-endpoint");
接口类型定义
ApiResponse
export interface ApiResponse<T = any> { code: number; // 业务状态码 data: T; // 响应数据 message: string; // 错误消息 success: boolean; // 是否成功}
RequestConfig
export interface RequestConfig extends AxiosRequestConfig { skipErrorHandler?: boolean; // 跳过全局错误处理 skipLoading?: boolean; // 跳过加载状态}
错误码处理
业务错误码
- `401`:未授权,自动清除缓存并跳转登录页
- `403`:没有权限,提示并跳转 403 页面
- `404`:资源不存在,提示并跳转 404 页面
- `500`:服务器内部错误,提示并跳转 500 页面
- `502`:网关错误,提示并跳转 502 页面
- `503`:服务不可用,提示并跳转 503 页面
- `504`:网关超时,提示并跳转 504 页面
HTTP 状态码
- 与业务错误码处理逻辑相同
- 额外处理:`ECONNABORTED`(请求超时)、网络错误
依赖项说明
必需依赖
- `axios`:HTTP 请求库
- `antd`:UI 组件库(用于 message 提示)
- `nprogress`:进度条组件
项目依赖
- `@/store/user`:用户状态管理(获取 token,通过 `useUserStore.getState().getToken()`)
- `@/utils/utils`:工具函数(clearAllCache,用于清除所有缓存)
- `@/utils/navigation`:路由导航服务(navigationService.replace,用于错误页面跳转)
环境变量配置
# .envVITE_API_BASE_URL=https://api.example.com
如果不设置,默认使用 `/api` 作为基础路径。
注意事项
1. Token 管理:
- `useUserStore.getToken()` 返回 `string | undefined`
- Token 存储在 localStorage 的 `authorization` 键和 cookie 中
- 如果 token 不存在,请求头中不会添加 Authorization
2. 错误页面:项目中已配置所有错误页面路由(401、403、404、500、502、503、504),位于 `src/pages/error/` 目录
3. 缓存清理:`clearAllCache` 函数会清除 storage、sessionStorage、cookie 和用户状态
4. 类型安全:使用泛型 `<T>` 确保返回数据的类型安全
5. 错误处理:特殊场景需要自定义错误处理时,使用 `skipErrorHandler`
6. 加载状态:频繁请求的场景可以使用 `skipLoading` 避免进度条闪烁
7. 导航服务:`navigationService` 需要在路由初始化时通过 `setNavigate` 设置 navigate 函数,否则会回退到 `window.location`
扩展建议
添加请求重试
// 在请求拦截器中添加重试逻辑if (config.retry && config.retryCount < config.maxRetry) { // 重试逻辑}
添加请求缓存
// 在请求拦截器中添加缓存逻辑if (config.cache) { const cached = getCache(config.url); if (cached) return cached;}
添加请求取消
// 使用 AbortController 取消请求const controller = new AbortController();request.get("/api/data", { signal: controller.signal });controller.abort();
添加请求日志
// 在拦截器中添加日志记录console.log("Request:", config.method, config.url);console.log("Response:", response.status, response.data);
代码备份参考
/** @format */import axios from "axios";import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError, InternalAxiosRequestConfig,} from "axios";import qs from "qs";import { message } from "../../utils/globalAntd";import NProgress from "nprogress";import "nprogress/nprogress.css";import { useUserStore } from "@/store/user";import { clearAllCache } from "../../utils";import { navigationService } from "./navigation";import { REQUEST_LOG_META } from "./leeLogger";import { hasApiPermission } from "./leePermission";// 配置 NProgressNProgress.configure({ showSpinner: false });// 分页参数类型export interface PageNumber { pageNum: number; pageSize: number;}// 请求响应接口export interface ApiResponse<T = any> { code: number; data: any; list?: T; message: string; success: boolean; msg: string;}// 请求配置接口export interface RequestConfig extends AxiosRequestConfig { skipErrorHandler?: boolean; // 跳过全局错误处理 skipLoading?: boolean; // 跳过加载状态 customTransformParams?: (data: any) => any; // 转换请求参数 customTransformResponse?: (data: any) => any; // 转换响应值 /** * 索引签名:允许扩展任意未声明字段 */ [key: string]: any;}/** * 优化后的文件名提取工具函数 * 支持 filename 和 filename* (RFC 5987) */function getFileName(response) { const disposition = response.headers["content-disposition"]; if (!disposition) { return `download_${newDate().getTime()}`; } let filename = ""; // 1. 优先尝试匹配 filename* (支持 UTF-8 编码) // 匹配格式如:filename*=utf-8''%E4%BD%A0%E5%A5%BD.zip const filenameStarRegex = /filename\*=[^']+'[^']*'([^;\n]+)/; const starMatches = filenameStarRegex.exec(disposition); if (starMatches && starMatches[1]) { filename = decodeURIComponent(starMatches[1]); } // 2. 如果没有 filename*,则匹配普通的 filename if (!filename) { const filenameRegex = /filename=([^;\n]+)/; const matches = filenameRegex.exec(disposition); if (matches && matches[1]) { // 移除引号并解码 filename = decodeURIComponent(matches[1].replace(/['"]/g, "")); } } return filename || "unnamed_file";}class Request { public instance: AxiosInstance; constructor() { const isDev = import.meta.env.DEV; const baseURL = import.meta.env.VITE_API_BASE_URL; this.instance = axios.create({ baseURL: baseURL, timeout: 0, headers: { "Content-Type": "application/json", }, // 使用qs库对查询参数进行序列化,防止参数默认序列化导致传参异常情况 paramsSerializer: { serialize(params) { return qs.stringify(params, { allowDots: true, arrayFormat: "repeat", }); }, }, }); this.setupInterceptors(); } // 设置拦截器 private setupInterceptors() { // 请求拦截器 this.instance.interceptors.request.use( (config: InternalAxiosRequestConfig) => { // 需要使用自定义属性时,使用requestConfig const requestConfig = config as RequestConfig; // 转换请求参数 if ( requestConfig.customTransformParams && typeof requestConfig.customTransformParams === "function" ) { // 分别处理get参数类型params与post参数类型data if (config.data) { config.data = requestConfig.customTransformParams(config.data); } if (config.params) { config.params = requestConfig.customTransformParams(config.params); } } // 校验接口级权限 if (!hasApiPermission(config.url as string)) { // 构造权限错误 const error: any = { message: "您没有权限访问此接口", code: "PERMISSION_DENIED", permission: config.url, skipErrorHandler: true, }; return Promise.reject(error); } // 显示加载进度 if (!requestConfig.skipLoading) { NProgress.start(); } // 添加 token const token = this.getToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, (error) => { NProgress.done(); return Promise.reject(error); } ); // 响应拦截器 this.instance.interceptors.response.use( (response: AxiosResponse<ApiResponse>) => { NProgress.done(); const requestConfig = response.config as RequestConfig; let responseData = typeof response.data === "string" ? JSON.parse(response.data) : response.data; // 文件流响应判断 // 判断是否为 Blob 对象(流文件) if (responseData instanceof Blob) { // 确实是文件流,直接返回整个 response // (因为下载需要获取 headers 里的 filename) const responseFilename = getFileName(response); return { ...response, filename: responseFilename }; } // 检查业务状态码 (使用当前最新的 responseData) if ( !responseData.success && (responseData.code !== 200 || responseData.status_code !== 200) ) { this.handleBusinessError(responseData); return Promise.reject(new Error(responseData.message || "请求失败")); } // 2. 校验通过后,再进行自定义的数据转换 if ( requestConfig.customTransformResponse && typeof requestConfig.customTransformResponse === "function" ) { responseData.data = requestConfig.customTransformResponse( responseData.data ); response.data = responseData; } return response; }, (error: AxiosError) => { NProgress.done(); const config = error.config as RequestConfig; // 权限错误 if (error.code === "PERMISSION_DENIED") { return Promise.reject(error); } // 跳过错误处理 if (config?.skipErrorHandler) { return Promise.reject(error); } // 处理 HTTP 错误 this.handleHttpError(error); return Promise.reject(error); } ); } // 获取 token private getToken(): string | undefined { // 使用 user store 的 getToken 方法 // getToken 返回 string | undefined return useUserStore.getState().getToken(); } // 处理业务错误 private handleBusinessError(data: ApiResponse) { const { code, message: msg } = data; switch (code) { case 401: // 未授权,跳转登录 this.handleUnauthorized(); break; case 403: message.error("没有权限访问该资源"); setTimeout(() => navigationService.replace("/403"), 1500); break; case 404: message.error("请求的资源不存在"); setTimeout(() => navigationService.replace("/404"), 1500); break; case 500: message.error(msg); // message.error("服务器内部错误"); // setTimeout(() => navigationService.replace("/500"), 1500); break; case 502: message.error("网关错误"); setTimeout(() => navigationService.replace("/502"), 1500); break; case 503: message.error("服务不可用"); setTimeout(() => navigationService.replace("/503"), 1500); break; case 504: message.error("网关超时"); setTimeout(() => navigationService.replace("/504"), 1500); break; default: message.error(msg || "请求失败"); } } // 处理 HTTP 错误 private handleHttpError(error: AxiosError) { const { response } = error; if (response) { const { status, data } = response; switch (status) { case 401: this.handleUnauthorized(); break; case 403: message.error("没有权限访问该资源"); setTimeout(() => navigationService.replace("/403"), 1500); break; case 404: message.error("请求的资源不存在"); setTimeout(() => navigationService.replace("/404"), 1500); break; case 500: // 检查是否登录成功 const token = this.getToken(); if (!token) { // 返回登录页面 setTimeout(() => navigationService.replace("/login"), 1500); return; } message.error(data.msg); // message.error("服务器内部错误"); // setTimeout(() => navigationService.replace("/500"), 1500); break; case 502: message.error("网关错误"); setTimeout(() => navigationService.replace("/502"), 1500); break; case 503: message.error("服务不可用"); setTimeout(() => navigationService.replace("/503"), 1500); break; case 504: message.error("网关超时"); setTimeout(() => navigationService.replace("/504"), 1500); break; default: message.error(`请求失败: ${status}`); } } else if (error.code === "ECONNABORTED") { message.error("请求超时"); } else { message.error("网络错误"); } } // 处理未授权 private handleUnauthorized() { message.error("登录已过期,请重新登录"); // 清除 全部缓存 clearAllCache(); // 跳转到 401 错误页面 setTimeout(() => navigationService.replace("/401"), 1500); } // GET 请求 get<T = any>( url: string, data?: any, config?: RequestConfig ): Promise<ApiResponse<T>> { if (data) { config = { ...config, params: data, }; } const promise = this.instance .get(url, config) .then((response) => response.data); // 注入元数据供 LeeLogger 识别 (promise as any)[REQUEST_LOG_META] = { url, data, method: "GET" }; return promise; } // GET 请求 getById<T = any>( url: string, id?: any, data?: any, config?: RequestConfig ): Promise<ApiResponse<T>> { if (data) { config = { ...config, params: data, }; } const promise = this.instance .get(url + "/" + id, config) .then((response) => response.data); // 注入元数据供 LeeLogger 识别 (promise as any)[REQUEST_LOG_META] = { url, id, method: "GET" }; return promise; } // GET 请求 (将参数拼接在 url 后) getParams<T = any>( url: string, data?: any, config?: RequestConfig ): Promise<ApiResponse<T>> { let requestUrl = url; if (data !== undefined && data !== null) { if (typeof data === "object") { const cleanData = Object.fromEntries( Object.entries(data).filter(([_, v]) => v !== undefined && v !== null) ); const query = new URLSearchParams(cleanData as any).toString(); if (query) { requestUrl += (requestUrl.includes("?") ? "&" : "?") + query; } } else { requestUrl += (requestUrl.endsWith("/") ? "" : "/") + data; } } const promise = this.instance .get(requestUrl, config) .then((response) => response.data); // 注入元数据供 LeeLogger 识别 (promise as any)[REQUEST_LOG_META] = { url: requestUrl, data, method: "GET", }; return promise; } /** * POST 请求 * @param url 接口地址 * @param data 请求参数 * @param config 请求配置 * @returns Promise<ApiResponse<T>> */ post<T = any>( url: string, data?: any, config?: RequestConfig ): Promise<ApiResponse<T>> { const promise = this.instance .post(url, data, config) .then((response) => response.data); // 注入元数据供 LeeLogger 识别 (promise as any)[REQUEST_LOG_META] = { url, data, method: "POST" }; return promise; } // POST 请求 postById<T = any>( url: string, id?: any, data?: any, config?: RequestConfig ): Promise<ApiResponse<T>> { const promise = this.instance .post(url + "/" + id, data, config) .then((response) => response.data); // 注入元数据供 LeeLogger 识别 (promise as any)[REQUEST_LOG_META] = { url, id, data, method: "POST" }; return promise; } // PUT 请求 put<T = any>( url: string, data?: any, config?: RequestConfig ): Promise<ApiResponse<T>> { const promise = this.instance .put(url, data, config) .then((response) => response.data); // 注入元数据供 LeeLogger 识别 (promise as any)[REQUEST_LOG_META] = { url, data, method: "PUT" }; return promise; } // PUT 请求 putById<T = any>( url: string, id?: any, data?: any, config?: RequestConfig ): Promise<ApiResponse<T>> { const promise = this.instance .put(url + "/" + id, data, config) .then((response) => response.data); // 注入元数据供 LeeLogger 识别 (promise as any)[REQUEST_LOG_META] = { url, id, data, method: "PUT" }; return promise; } // DELETE 请求 delete<T = any>( url: string, data?: any, config?: RequestConfig ): Promise<ApiResponse<T>> { if (data) { config = { ...config, data: data, }; } const promise = this.instance .delete(url, config) .then((response) => response.data); // 注入元数据供 LeeLogger 识别 (promise as any)[REQUEST_LOG_META] = { url, data, method: "DELETE" }; return promise; } // DELETE 请求 deleteById<T = any>( url: string, id?: any, data?: any, config?: RequestConfig ): Promise<ApiResponse<T>> { if (data) { config = { ...config, params: data, }; } const promise = this.instance .delete(url + "/" + id, config) .then((response) => response.data); // 注入元数据供 LeeLogger 识别 (promise as any)[REQUEST_LOG_META] = { url, id, data, method: "DELETE" }; return promise; } // PATCH 请求 patch<T = any>( url: string, data?: any, config?: RequestConfig ): Promise<ApiResponse<T>> { const promise = this.instance .patch(url, data, config) .then((response) => response.data); // 注入元数据供 LeeLogger 识别 (promise as any)[REQUEST_LOG_META] = { url, data, method: "PATCH" }; return promise; } // 上传文件 upload<T = any>( url: string, file: File, config?: RequestConfig ): Promise<ApiResponse<T>> { const formData = new FormData(); formData.append("file", file); const promise = this.instance .post(url, formData, { ...config, headers: { "Content-Type": "multipart/form-data", }, }) .then((response) => response.data); // 注入元数据供 LeeLogger 识别 (promise as any)[REQUEST_LOG_META] = { url, data: "FormData", method: "UPLOAD", }; return promise; } // 下载文件 download( url: string, filename?: string, config?: RequestConfig ): Promise<void> { if (url.indexOf("http") != -1) { const link = document.createElement("a"); link.href = url; link.download = filename || "download"; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); return Promise.resolve(); } return this.instance .request({ url, method: config?.method || "GET", ...config, responseType: "blob", }) .then((response) => { const blob = new Blob([response.data]); const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = downloadUrl; link.download = response.filename || filename || "download"; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(downloadUrl); }) .catch((err) => { console.error("文件下载失败", err); }); }}// 创建请求实例export const request = new Request();// 绑定方法到实例,避免 this 上下文丢失export const $get = request.get.bind(request);export const $getParams = request.getParams.bind(request);export const $getById = request.getById.bind(request);export const $post = request.post.bind(request);export const $postById = request.postById.bind(request);export const $put = request.put.bind(request);export const $putById = request.putById.bind(request);export const $delete = request.delete.bind(request);export const $deleteById = request.deleteById.bind(request);export const $patch = request.patch.bind(request);export const $upload = request.upload.bind(request);export const $download = request.download.bind(request);// 导出 axios 实例(用于特殊场景)export const httpInstance = request.instance;