三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Vue项目axios二次封装实战:从基础拦截器到高级特性

Vue项目axios二次封装实战:从基础拦截器到高级特性

1. 项目概述:为什么我们需要二次封装axios?

在Vue项目里,你肯定用过axios。它简单、强大,是处理HTTP请求的首选。但如果你还在每个组件里直接import axios from ‘axios’,然后写一堆重复的.then.catch,那说明你的项目还停留在“能用”的阶段,离“好用”和“好维护”还有一段距离。我见过太多项目,因为早期图省事,后期接口一多,各种请求配置、错误处理、加载状态管理散落在各个角落,改一处而动全身,维护起来苦不堪言。

二次封装axios,本质上不是为了炫技,而是为了解决这些工程化痛点。它就像给你的项目搭建一个统一的“通信基站”。所有进出项目的网络请求,都经过这个基站进行标准化处理:统一添加身份令牌、格式化请求数据、拦截并处理全局错误、管理请求的加载状态、甚至做接口缓存和重试。这样做的好处是显而易见的:业务组件可以更专注于业务逻辑本身,而不用操心网络层的脏活累活;整个应用的网络行为变得可预测、可管理;团队协作时,大家遵循同一套请求规范,代码风格和健壮性都能得到保障。

所以,今天要聊的,不是简单地教你写一个request.js文件,而是基于我多年在多个中大型Vue项目中的实战经验,拆解一套完整、健壮、可扩展的axios二次封装方案。我们会从最基础的拦截器开始,一步步构建出包含错误分级处理、请求取消、接口类型安全、甚至模拟数据等高级特性的请求层。无论你是刚接触Vue的新手,还是正在为老项目技术债头疼的资深开发者,这套方案都能给你带来直接的参考价值。

2. 核心设计思路与架构规划

在动手写代码之前,理清设计思路至关重要。一个随意的封装,后期可能比不封装还要糟糕。我们的目标是构建一个高内聚、低耦合、易于扩展的请求层。这里我分享一下我的设计思路,你可以对照自己的项目需求进行调整。

2.1 分层架构设计

我倾向于将请求层进行清晰的分层,这能让职责更分明:

  1. Axios实例层:这是最底层,负责创建axios实例,配置基础URL、超时时间等全局设置。一个项目可能根据后端微服务划分,创建多个不同的axios实例(例如:userApiInstance,orderApiInstance)。
  2. 拦截器层:这是核心增强层。分为请求拦截器和响应拦截器。
    • 请求拦截器:在请求发出前统一处理。典型任务包括:注入认证Token、设置Content-Type、为GET请求序列化参数、显示全局Loading动画等。
    • 响应拦截器:在收到响应后统一处理。典型任务包括:剥离后端统一响应包装、处理网络错误和业务错误、隐藏Loading动画、对特定错误码(如401未授权)执行统一跳转等。
  3. 业务封装层:将常用的请求方法(GET, POST, PUT, DELETE等)进行二次封装,暴露更简洁、语义化的API给业务组件调用。例如,封装一个get方法,自动处理参数序列化;封装一个post方法,自动根据数据格式设置请求头。
  4. 类型定义与Mock层(可选但推荐):对于使用TypeScript的项目,定义清晰的请求/响应数据类型接口,能极大提升开发体验和代码可靠性。同时,集成Mock.js或类似的库,在前端独立开发时模拟后端数据,实现前后端并行开发。

2.2 关键特性考量

在设计时,我们需要决定封装要支持哪些特性:

  • 自动携带Token:如何安全地存储和获取用户令牌(如从Vuex/Pinia或LocalStorage),并在请求时自动添加到Header中。
  • 错误统一处理:如何区分网络错误、HTTP状态码错误、后端自定义业务错误?每种错误应该如何友好地反馈给用户(例如,网络错误提示“请检查网络”,401错误跳转登录页,业务错误Toast提示具体原因)?
  • 请求取消:在页面切换或组件销毁时,如何自动取消未完成的请求,避免内存泄漏和不可预期的状态更新?这在SPA应用中尤为重要。
  • Loading状态管理:是否需要一个全局的Loading状态来控制页面顶部的进度条或某个区域的加载图标?如何避免多个并行请求时Loading状态的闪烁?
  • 重试机制:对于某些偶发性的网络错误(如超时),是否要加入自动重试逻辑?
  • 接口防抖/节流:对于频繁触发的查询接口(如搜索框),是否需要在封装层支持防抖控制?

我的建议是,从核心需求开始,逐步迭代。一个最基础的封装必须包含:实例创建、请求/响应拦截器、错误处理。其他高级特性可以根据项目复杂度按需引入。下面,我们就从零开始,实现这个核心。

3. 基础封装实战:从零搭建请求层

让我们开始动手。首先在你的Vue项目(假设是Vue 3 + Vite)中,创建一个专用的目录来管理请求层,例如src/utils/request/

3.1 创建Axios实例与基础配置

src/utils/request/index.js中,我们首先创建axios实例。

// src/utils/request/index.js import axios from ‘axios‘; import { ElMessage } from ‘element-plus‘; // 示例使用Element Plus的提示组件,你可替换为任何UI库或自定义提示 // 创建axios实例 const service = axios.create({ baseURL: import.meta.env.VITE_APP_BASE_API, // 从环境变量读取基础API地址 timeout: 10000, // 请求超时时间(毫秒) headers: { ‘Content-Type‘: ‘application/json;charset=utf-8‘, // 默认请求头 }, }); // 这里先导出基础的service实例,拦截器将在下一步添加 export default service;

注意baseURL通过环境变量VITE_APP_BASE_API配置,这非常关键。它允许你在开发、测试、生产环境中使用不同的后端地址。在你的.env.development.env.production文件中分别配置即可。

3.2 实现请求拦截器

请求拦截器的主要任务是在请求发出前,对配置进行修改

// 继续在 src/utils/request/index.js 中编写 // 请求拦截器 service.interceptors.request.use( (config) => { // 在发送请求之前做些什么 // 1. 自动携带Token const token = localStorage.getItem(‘access_token‘); // 示例从localStorage获取,实际可能来自Vuex/Pinia if (token) { config.headers.Authorization = `Bearer ${token}`; } // 2. 针对GET请求,参数序列化(axios默认对数组等处理不友好,可在此统一处理) if (config.method === ‘get‘ && config.params) { let url = config.url + ‘?‘; for (const propName of Object.keys(config.params)) { const value = config.params[propName]; // 过滤掉空值,避免传给后端无效参数 if (value !== undefined && value !== null && value !== ‘‘) { if (typeof value === ‘object‘) { // 处理数组或对象参数,根据后端要求序列化,这里示例为JSON字符串 config.params[propName] = JSON.stringify(value); } // 其他类型参数,axios会正常处理 } else { delete config.params[propName]; // 删除空参数 } } // 注意:这里修改的是config.params,axios最终会将其拼接到url上 } // 3. 显示全局Loading(如果需要) // showFullScreenLoading(); // 假设你有一个全局Loading控制函数 return config; }, (error) => { // 对请求错误做些什么(通常很少发生,如配置错误) // hideFullScreenLoading(); // 关闭Loading console.error(‘Request Interceptor Error:‘, error); return Promise.reject(error); } );

实操心得:关于Token存储,在生产环境中,直接放在localStorage虽然方便,但可能存在XSS攻击风险。对于安全性要求极高的应用,可以考虑使用HttpOnly的Cookie,或者结合Vuex/Pinia并将Token存储在内存中,但需处理好页面刷新后的重新认证流程。这里为了示例清晰,使用了localStorage。

3.3 实现响应拦截器

响应拦截器是错误处理的核心,逻辑相对复杂。

// 继续在 src/utils/request/index.js 中编写 // 响应拦截器 service.interceptors.response.use( (response) => { // hideFullScreenLoading(); // 请求成功,关闭Loading const res = response.data; // axios的响应数据在data字段中 // 假设后端返回的统一格式为 { code: 200, data: {}, message: ‘success‘ } const { code, message } = res; // 判断业务状态码是否成功(这里假设200为成功) if (code === 200) { // 直接返回后端接口的`data`字段,这样业务层拿到的就是纯净的数据 return res.data; } else { // 业务逻辑错误(例如:code=50001 代表参数错误) // 1. 统一提示错误信息(使用你项目中的UI提示组件) ElMessage({ message: message || ‘业务逻辑错误‘, type: ‘error‘, duration: 5 * 1000, // 显示5秒 }); // 2. 处理特定的业务错误码,例如未登录、权限不足等 if (code === 401) { // 触发登出操作,清空token和用户信息 localStorage.removeItem(‘access_token‘); // 跳转到登录页,带上当前路由以便登录后回跳 window.location.href = `/login?redirect=${encodeURIComponent(window.location.pathname)}`; // 或者使用Vue Router: router.push(`/login?redirect=${route.fullPath}`); } else if (code === 403) { // 权限不足,可以跳转到无权限页面 // router.push(‘/403‘); } // 3. 将错误继续抛出,这样在业务代码的catch中还能捕获到 return Promise.reject(new Error(message || ‘业务错误‘)); } }, (error) => { // hideFullScreenLoading(); // 发生错误,也要关闭Loading // 处理HTTP网络错误(状态码不在2xx范围内) if (error.response) { // 请求已发出,服务器也响应了状态码,但状态码超出了2xx的范围 const { status, data } = error.response; let errMessage = ‘请求错误‘; switch (status) { case 400: errMessage = ‘请求参数错误‘; break; case 401: errMessage = ‘未授权,请重新登录‘; // 同样执行登出和跳转逻辑 localStorage.removeItem(‘access_token‘); window.location.href = ‘/login‘; break; case 403: errMessage = ‘拒绝访问‘; break; case 404: errMessage = `请求地址出错: ${error.response.config.url}`; break; case 408: errMessage = ‘请求超时‘; break; case 500: errMessage = ‘服务器内部错误‘; break; case 501: errMessage = ‘服务未实现‘; break; case 502: errMessage = ‘网关错误‘; break; case 503: errMessage = ‘服务不可用‘; break; case 504: errMessage = ‘网关超时‘; break; case 505: errMessage = ‘HTTP版本不受支持‘; break; default: errMessage = `未知网络错误 ${status}`; } // 如果后端在错误响应体里提供了message,优先使用 if (data && data.message) { errMessage = data.message; } ElMessage.error(errMessage); } else if (error.request) { // 请求已经发出,但没有收到响应(例如网络断开、服务器无响应) // `error.request` 在浏览器中是 XMLHttpRequest 的实例 if (error.message.includes(‘timeout‘)) { ElMessage.error(‘网络请求超时,请检查网络连接‘); } else if (error.message.includes(‘Network Error‘)) { ElMessage.error(‘网络连接异常,请检查网络设置‘); } else { ElMessage.error(‘网络异常,请检查您的网络‘); } } else { // 在设置请求时触发错误(例如,配置错误) console.error(‘Error in request setup:‘, error.message); ElMessage.error(`请求配置错误: ${error.message}`); } // 将错误继续抛出,保证业务层的catch能捕获到 return Promise.reject(error); } ); // 最后,导出配置好的service实例 export default service;

至此,一个具备基础认证、错误处理和业务状态码判断的axios封装就完成了。你可以在业务组件中这样使用:

import request from ‘@/utils/request‘; // 导入我们封装的实例 export function getUserInfo(userId) { return request({ url: `/api/user/${userId}`, method: ‘get‘, }); } // 在Vue组件中 async function fetchData() { try { const userData = await getUserInfo(123); console.log(‘成功:‘, userData); // 直接使用数据,无需再解构res.data } catch (error) { // 这里捕获到的错误,已经是经过拦截器处理并提示过的。 // 你可以在这里做一些组件特有的错误处理,比如重置状态。 console.error(‘组件内捕获错误:‘, error); } }

4. 高级特性与工程化增强

基础封装满足了80%的需求,但对于一个追求卓越的项目,我们还需要考虑更多。下面介绍几个我实践中觉得非常有价值的高级特性。

4.1 请求取消与自动取消

在单页面应用(SPA)中,用户切换页面时,上一个页面发起的请求可能还在进行中。如果这些请求在组件销毁后仍然更新已被销毁组件的状态,会导致内存泄漏和控制台警告。Axios提供了CancelToken(旧版)和AbortController(新版)来取消请求。

方案:使用AbortController(推荐,更现代)

我们可以封装一个“请求取消器”,在发起请求时自动生成一个信号(signal),并在组件卸载时自动取消该请求。

首先,创建一个工具函数或Composable(Vue 3):

// src/utils/request/requestCancel.js class RequestCanceler { constructor() { this.pendingMap = new Map(); // 存储每个请求的标识和对应的AbortController } // 生成请求的唯一标识(方法+url) getPendingKey(config) { const { method, url } = config; return [method, url].join(‘&‘); } // 添加请求到等待列表 addPending(config) { this.removePending(config); // 发起新请求前,先取消相同的未完成请求 const key = this.getPendingKey(config); const controller = new AbortController(); config.signal = controller.signal; // 将signal挂载到请求配置上 this.pendingMap.set(key, controller); } // 从等待列表中移除请求(请求完成或取消后) removePending(config) { const key = this.getPendingKey(config); if (this.pendingMap.has(key)) { const controller = this.pendingMap.get(key); controller.abort(‘请求被取消‘); // 取消请求 this.pendingMap.delete(key); } } // 清空所有等待中的请求(例如:用户登出时) clearAllPending() { this.pendingMap.forEach((controller) => { controller.abort(‘清空所有请求‘); }); this.pendingMap.clear(); } } export const requestCanceler = new RequestCanceler();

然后,在请求拦截器中添加请求,在响应拦截器中移除请求:

// 在 src/utils/request/index.js 的请求拦截器中 import { requestCanceler } from ‘./requestCancel‘; service.interceptors.request.use((config) => { // ... 其他逻辑 requestCanceler.addPending(config); // 添加请求到取消列表 return config; }); // 在响应拦截器的成功和失败回调中 service.interceptors.response.use( (response) => { requestCanceler.removePending(response.config); // 请求成功,移除 // ... 其他逻辑 }, (error) => { // 注意:如果请求被取消,error的code是 ‘ERR_CANCELED‘ if (axios.isCancel(error)) { console.log(‘请求已被取消:‘, error.message); // 被取消的请求不需要提示错误 return Promise.reject(new Error(‘请求取消‘)); // 返回一个特定的错误,避免触发通用错误提示 } // 对于其他错误,依然需要移除pending记录(如果有的话) if (error.config) { requestCanceler.removePending(error.config); } // ... 其他错误处理逻辑 } );

最后,在Vue组件中,你通常不需要手动操作,因为拦截器已经自动管理了。但如果你有特殊场景(比如一个查询框,用户连续输入时只发送最后一次请求),你也可以手动利用这个机制。

4.2 业务方法二次封装

虽然直接使用request({...})没问题,但封装成语义化的函数能让业务代码更简洁。我习惯在src/api/目录下,按模块组织API函数。

// src/api/user.js import request from ‘@/utils/request‘; // 用户相关接口 export function login(data) { return request({ url: ‘/auth/login‘, method: ‘post‘, data, // ES6简写,等同于 data: data }); } export function getUserInfo(params) { return request({ url: ‘/user/info‘, method: ‘get‘, params, // GET请求参数用params }); } export function updateUserProfile(data) { return request({ url: ‘/user/profile‘, method: ‘put‘, data, }); } // 带特定配置的请求示例:上传文件 export function uploadAvatar(file) { const formData = new FormData(); formData.append(‘file‘, file); return request({ url: ‘/user/avatar‘, method: ‘post‘, data: formData, headers: { ‘Content-Type‘: ‘multipart/form-data‘, // 覆盖默认的json头 }, timeout: 30000, // 上传文件需要更长时间 }); }

在组件中使用时,代码非常清晰:

<script setup> import { getUserInfo, updateUserProfile } from ‘@/api/user‘; import { ref } from ‘vue‘; const user = ref(null); async function loadUser() { user.value = await getUserInfo({ id: 123 }); } async function saveProfile() { await updateUserProfile({ name: ‘新名字‘ }); // 成功后提示... } </script>

4.3 集成TypeScript以获得类型安全

如果你的项目使用TypeScript,那么给请求层加上类型,体验会提升一个档次。主要做两件事:定义后端通用的响应体类型,定义具体API的请求/响应类型。

首先,定义通用类型:

// src/types/api.d.ts // 后端统一的响应结构 export interface ApiResponse<T = any> { code: number; data: T; message: string; [key: string]: any; // 其他可能的字段 } // 分页列表数据的通用结构(如果后端统一) export interface ApiPageResponse<T = any> { list: T[]; total: number; page: number; size: number; [key: string]: any; }

然后,改造我们的request函数,使其支持泛型:

// src/utils/request/index.ts import axios, { AxiosRequestConfig, AxiosResponse } from ‘axios‘; import { ApiResponse } from ‘@/types/api‘; // 导入通用类型 const service = axios.create({ /* ...配置同上... */ }); // 重写axios的请求方法,使其返回我们定义的Promise<ApiResponse<T>> // 这里我们覆盖AxiosInstance的类型 export interface CustomAxiosInstance extends AxiosInstance { <T = any>(config: AxiosRequestConfig): Promise<T>; // 注意:这里直接返回T,因为拦截器已经帮我们剥离了code/message // 你也可以保留原始结构:Promise<ApiResponse<T>> } const service: CustomAxiosInstance = axios.create({ /* ... */ }) as any; // ... 拦截器逻辑(TypeScript需要适当调整类型) ... export default service;

接着,为具体的API函数定义类型:

// src/api/user.ts import request from ‘@/utils/request‘; import type { ApiResponse, ApiPageResponse } from ‘@/types/api‘; // 定义具体的业务数据类型 export interface UserInfo { id: number; username: string; email: string; avatar: string; } export interface LoginParams { username: string; password: string; } export interface LoginResult { token: string; expiresIn: number; } // 带类型的API函数 export function login(data: LoginParams): Promise<LoginResult> { return request({ url: ‘/auth/login‘, method: ‘post‘, data, }); } export function getUserInfo(params: { id: number }): Promise<UserInfo> { return request({ url: ‘/user/info‘, method: ‘get‘, params, }); }

现在,在Vue组件中使用时,你将获得完美的代码提示和类型检查:

<script setup lang=“ts“> import { getUserInfo, type UserInfo } from ‘@/api/user‘; import { ref } from ‘vue‘; const user = ref<UserInfo | null>(null); // user有明确的类型 async function loadUser() { user.value = await getUserInfo({ id: 123 }); // 参数和返回值都有类型约束 // 访问 user.value.username 会有智能提示 } </script>

实操心得:引入TypeScript初期会增加一些工作量,但对于长期维护和团队协作来说,其带来的代码智能提示、编译时错误检查和重构便利性是巨大的。强烈建议在中大型项目中采用。

5. 常见问题、性能优化与踩坑记录

即使有了完善的封装,在实际开发中还是会遇到各种问题。下面是我总结的一些高频问题和优化点。

5.1 如何避免重复提示错误?

在响应拦截器中,我们对所有非200的业务码和HTTP错误都进行了ElMessage.error提示。但有时候,在某个具体组件里,我们想自己处理错误,不希望弹出全局提示。我们可以通过给请求配置添加一个自定义参数来控制。

解决方案:利用axios的config对象传递自定义元数据。

  1. 在请求拦截器中,允许一个自定义字段(例如noErrorTip)来跳过全局错误提示。
// 修改响应拦截器的错误处理部分 service.interceptors.response.use( (response) => { /* ...成功逻辑不变... */ }, (error) => { const { config, response } = error; // 检查请求配置中是否有自定义字段,决定是否显示全局错误 if (config && config.noErrorTip) { // 不显示全局提示,直接抛出错误,由业务代码处理 return Promise.reject(error); } // ... 原有的全局错误提示逻辑 ... } );
  1. 在发起请求时,传入这个配置。
// 在业务API调用处 export function someApi() { return request({ url: ‘/api/some‘, method: ‘get‘, noErrorTip: true, // 添加自定义字段 }); } // 在组件中,你就可以自己捕获并处理错误,而不会触发全局提示 try { await someApi(); } catch (e) { // 在这里使用自定义的提示方式,或者静默处理 console.log(‘业务特定处理:‘, e); }

5.2 多个并行请求的Loading管理

如果在拦截器中直接控制一个全局Loading的显示/隐藏,当多个请求并行时,会出现Loading频繁闪烁(一个请求结束就关闭,但另一个还在进行)。我们需要一个计数器来管理。

// src/utils/request/loading.js let loadingCount = 0; let loadingInstance = null; // 存储Loading组件实例 export function showFullScreenLoading() { if (loadingCount === 0) { // 只有当第一个请求开始时,才显示Loading loadingInstance = ElLoading.service({ lock: true, text: ‘加载中...‘, background: ‘rgba(0, 0, 0, 0.7)‘, }); } loadingCount++; } export function hideFullScreenLoading() { loadingCount--; if (loadingCount <= 0) { // 当所有请求都结束时,才关闭Loading loadingInstance?.close(); loadingCount = 0; loadingInstance = null; } }

然后在请求和响应拦截器中分别调用showFullScreenLoadinghideFullScreenLoading即可。这样就实现了多个请求共享一个Loading状态,只有当所有请求都完成时,Loading才会消失。

5.3 处理后端返回的非JSON数据

有些接口可能返回文件流(如下载)或纯文本。我们的拦截器默认将响应作为JSON处理,这会导致错误。我们可以通过检查响应头Content-Type来动态处理。

service.interceptors.response.use( (response) => { const contentType = response.headers[‘content-type‘]; // 如果是文件流,直接返回整个response,让业务代码处理 if (contentType && contentType.indexOf(‘application/octet-stream‘) !== -1) { return response; } // 如果是JSON,走原有的业务逻辑处理 if (contentType && contentType.indexOf(‘application/json‘) !== -1) { const res = response.data; // ... 原有的code判断逻辑 ... return res.data; } // 其他类型,直接返回原始数据 return response.data; }, (error) => { /* ...错误处理... */ } );

5.4 请求参数序列化的坑

axios默认将对象参数序列化成key=value&key2=value2的形式,但对于数组,默认会转换成key[]=value1&key[]=value2。如果你的后端期望接收key=value1,value2或者JSON字符串,就需要自定义序列化。我们在3.2节的请求拦截器中已经演示了将对象参数转为JSON字符串的方法。更复杂的场景可以使用axios的paramsSerializer配置项。

import qs from ‘qs‘; // 一个常用的查询字符串库 const service = axios.create({ baseURL: ‘...‘, paramsSerializer: (params) => { // 使用qs库进行序列化,可以处理嵌套对象和数组 return qs.stringify(params, { arrayFormat: ‘brackets‘ }); // 可选 ‘indices‘, ‘brackets‘, ‘repeat‘, ‘comma‘ }, });

5.5 封装后的代码组织建议

一个清晰的目录结构能让你的请求层更易维护:

src/ ├── api/ # 所有API模块 │ ├── index.js # 可选:统一导出所有API │ ├── user.js # 用户相关接口 │ ├── order.js # 订单相关接口 │ └── product.js # 产品相关接口 ├── utils/ │ └── request/ │ ├── index.js # axios实例创建与拦截器核心 │ ├── requestCancel.js # 请求取消器 │ ├── loading.js # 全局Loading管理 │ └── status.js # 统一维护HTTP状态码和业务状态码映射 └── types/ └── api.d.ts # TypeScript类型定义

最后,我想说的是,封装没有银弹。本文提供的方案是一个功能比较全面的起点,你需要根据自己项目的实际技术栈(是Vue 2还是Vue 3?用Pinia还是Vuex?)、UI库以及后端接口规范进行适配和调整。核心是理解每一层拦截器、每一个配置项的作用,然后打造出最适合自己团队的“通信基站”。在项目迭代过程中,这个请求层也会不断进化,记录下每一次遇到的问题和解决方案,它就会成为你们项目最稳固的基石之一。

← 返回列表