Vue.js 项目中 Axios 拦截器封装与接口对接实战
深入讲解在 Vue.js 项目中如何高效封装 Axios 实例与请求/响应拦截器,覆盖环境配置、Token 动态注入、401 无感刷新、防重复提交与模块化 API 接口对接实战。
axios 会带来配置重复、鉴权散落、错误处理繁琐及联调维护困难等痛点。本文将以工程化落地为主线,系统拆解如何基于 Axios 实例与拦截器机制,构建包含统一鉴权、状态码解包、无感刷新 Token、重复请求拦截及模块化管理的接口层架构,并结合 Vue 3 组合式 API 展示标准的联调对接方案。
一、为什么必须对 Axios 实例与拦截器进行独立封装?
初学者或小型 DEMO 项目常常通过 axios.get('/api/users') 直接发起网络调用,但在真实项目开发中,这种做法会快速暴露以下缺陷:
- 配置割裂与硬编码:接口的基础域名(Base URL)、超时阈值和默认请求头无法统一变更。当项目切换开发、测试与生产环境时,全局修改成本极高。
- 鉴权逻辑深度侵入业务:每次请求都需要手动从本地存储或状态中心获取 Token 并组装进 Header,一旦登录状态失效或 Token 过期,需要在各个业务组件中重复编写重定向逻辑。
- 数据解包与状态处理冗余:Axios 默认返回由浏览器封装的完整报文(包含
status、headers、config等),而前端业务通常只关心response.data.data。若缺乏中间拦截解构,页面代码中将充斥重复的层级访问代码。 - 异常处理标准缺失:HTTP 状态码(如 403、404、500)与后端约定的业务状态码(如
code: 20001业务异常)无法在单一入口做差异化告警,容易造成界面提示风格不一或错误遗漏。
Axios 提供的拦截器(Interceptors)本质上是一种管道设计模式(Pipeline)。在请求发出之前和响应到达组件 then/catch 之前,Axios 允许我们插入两个切面函数,形成一套高内聚的网络协议处理网关。
如上图所示,当组件发起调用时,请求配置会首先进入请求拦截器队列,完成鉴权凭据注入与重复请求去重,随后由底层传输适配器派发到服务端。服务端返回响应报文后,报文进入响应拦截器队列,完成网络协议状态校验、业务数据解包与全局错误捕获,最终才向业务组件交付纯净的业务数据实体。
二、Axios 拦截器工程化封装落地实战
为了保证网络层的扩展性与可维护性,我们按照标准工程化范式将封装过程划分为四个有序步骤:创建独立实例、装配请求拦截器、装配响应拦截器、导出泛型请求包装函数。
步骤一:创建独立 Axios 单例与环境配置
避免直接修改全局默认的 axios.defaults,推荐通过 axios.create() 创建项目专用的网络服务单例。创建独立实例能够确保当前配置只作用于业务接口,后续若引入第三方 CDN 或独立微服务,互不干扰。
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios';
// 1. 根据 Vite / Vue CLI 环境变量读取服务端接口前缀
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
// 2. 创建独立 Axios 实例
const service: AxiosInstance = axios.create({
baseURL: BASE_URL,
timeout: 10000, // 超时时间统一设为 10 秒
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
});
步骤二:装配请求拦截器(Token 自动注入与防重复拦截)
请求拦截器的核心任务包括两点:一是自动在请求头附带 Authorization: Bearer <Token>,二是通过 AbortController 实现防重复提交机制,防止用户高频点击按钮导致并发发送多份相同请求。
// 内存中维护正在处理中的请求集合
const pendingMap = new Map<string, AbortController>();
// 生成请求唯一标识 Key
function generateRequestKey(config: AxiosRequestConfig): string {
const { method, url, params, data } = config;
return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&');
}
// 添加请求到挂起池并绑定 AbortController
function addPendingRequest(config: InternalAxiosRequestConfig) {
const requestKey = generateRequestKey(config);
if (pendingMap.has(requestKey)) {
// 发现已存在相同未完成请求,立即中止前一次请求
const controller = pendingMap.get(requestKey);
controller?.abort();
pendingMap.delete(requestKey);
}
const controller = new AbortController();
config.signal = controller.signal;
pendingMap.set(requestKey, controller);
}
// 请求完成或异常后移出挂起池
export function removePendingRequest(config: AxiosRequestConfig) {
const requestKey = generateRequestKey(config);
if (pendingMap.has(requestKey)) {
pendingMap.delete(requestKey);
}
}
// 挂载请求拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 1. 防重复提交检查与中止控制
addPendingRequest(config);
// 2. 从本地缓存或全局状态仓库 (Pinia) 获取 Token
const token = localStorage.getItem('access_token');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
// 请求配置生成阶段即发生异常时的兜底
return Promise.reject(error);
}
);
步骤三:装配响应拦截器(业务解包与异常统一拦截)
在接口交互中,必须清晰区分HTTP 传输状态码与服务端业务状态码。HTTP 200 仅代表网络传输成功,后端返回的 JSON 报文中可能携带 code: 50001, message: "余额不足" 等业务失败信息。响应拦截器需在此进行统一解包与错误提示。
// 模拟 UI 组件库的消息弹窗 (如 Element Plus 的 ElMessage 或 Naive UI)
function showToastMessage(msg: string, type: 'error' | 'warning' = 'error') {
console.error(`[系统提示-${type}]: ${msg}`);
// 实际项目中可直接调用: ElMessage.error(msg);
}
// 挂载响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
// 请求正常返回,首先从挂起池中移除
removePendingRequest(response.config);
const { data, config } = response;
// 特殊情况:文件导出或下载(Blob/ArrayBuffer)不参与业务状态码校验
if (config.responseType === 'blob' || config.responseType === 'arraybuffer') {
return data;
}
// 标准 RESTful 约定:后端返回结构通常为 { code: 200, data: ..., message: "ok" }
if (data.code === 200 || data.code === 0) {
// 核心动作:直接返回业务层真实需要的数据载荷 (Payload)
return data.data !== undefined ? data.data : data;
}
// 命中后端业务异常,弹出统一错误并中断当前 Promise
showToastMessage(data.message || '业务逻辑执行失败');
return Promise.reject(new Error(data.message || 'Error'));
},
async (error) => {
// 请求失败后同样清除防重记录
if (error.config) {
removePendingRequest(error.config);
}
// 若属于主动取消请求 (AbortController.abort),不做多余的报错提示
if (axios.isCancel(error)) {
console.warn('请求已按策略主动取消:', error.message);
return Promise.reject(error);
}
// 网络层/HTTP 协议状态码分流判断
if (error.response) {
const { status, data } = error.response;
switch (status) {
case 400:
showToastMessage(data?.message || '请求参数格式有误 (400)');
break;
case 401:
// 401 Unauthorized: 凭证已过期或缺失,下文详述无感刷新逻辑
return handleTokenExpired(error.config);
case 403:
showToastMessage('无权限访问此受保护资源 (403)');
break;
case 404:
showToastMessage('目标服务端接口不存在 (404)');
break;
case 500:
case 502:
case 503:
showToastMessage('服务端内部错误,请稍后重试 (5xx)');
break;
default:
showToastMessage(`网络请求异常 (${status})`);
}
} else if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) {
showToastMessage('网络连接超时,请检查您的网络状况');
} else {
showToastMessage('网络故障,无法连接至远程服务');
}
return Promise.reject(error);
}
);
步骤四:构建强类型的统一请求方法封装
为便于业务层调用并获得完整的代码补全提示,将 Axios 实例的各类 HTTP Method 包装成统一的 Promise 帮助函数:
// 统一封装 GET / POST / PUT / DELETE 工具函数
export const http = {
get<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, { params, ...config });
},
post<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config);
},
put<T = any>(url: string, data?: object, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config);
},
delete<T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, { params, ...config });
}
};
export default service;
三、生产级关键细节:401 Token 无感刷新与队列重试机制
在基于 JWT(JSON Web Token)的双 Token 鉴权体系中,访问令牌(Access Token)通常有效期较短(如 15 到 30 分钟),而刷新令牌(Refresh Token)有效期较长(如 7 天到 30 天)。
当用户的 Access Token 过期时,如果直接将用户踢回登录页重新输入密码,会造成极其突兀的用户体验。同时,现代前端页面初次加载时通常会并发调用 3 到 5 个初始化接口。如果每个接口都各自发起一次刷新请求,会造成“刷新接口并发雪崩”甚至导致后端单次刷新的 Refresh Token 失效。此时必须使用状态锁与请求暂存重试队列。
isRefreshing = true 并发起 Token 续期;此时后续并发的 401 请求不发起独立刷新,而是将自身的重放函数暂存进一个待执行队列中,返回一个“尚未 Resolve”的 Promise 挂起业务执行。待新 Token 成功返回后,顺序遍历重试队列,完成所有被挂起请求的自动重放。
import axios, { type InternalAxiosRequestConfig } from 'axios';
import service from './request';
// 是否正在进行 Token 刷新的锁标记
let isRefreshing = false;
// 存储因 401 被挂起的请求重试回调队列
let retryQueue: Array<(token: string) => void> = [];
/**
* 处理 401 错误的核心调度函数
*/
export async function handleTokenExpired(config: InternalAxiosRequestConfig) {
// 1. 如果已有请求在刷新中,将当前请求闭包推进重试队列,返回未决 Promise
if (isRefreshing) {
return new Promise((resolve) => {
retryQueue.push((newToken: string) => {
// 用最新的 Token 替换当前过期请求的 Authorization Header
if (config.headers) {
config.headers.Authorization = `Bearer ${newToken}`;
}
// 重新调用 service 发起请求并解析结果
resolve(service(config));
});
});
}
// 2. 抢占锁并启动刷新任务
isRefreshing = true;
const refreshToken = localStorage.getItem('refresh_token');
// 若本地连 Refresh Token 都没有,直接触发退出登录流程
if (!refreshToken) {
isRefreshing = false;
redirectToLogin('登录凭据已过期,请重新登录');
return Promise.reject(new Error('No refresh token'));
}
try {
// 3. 调用独立的刷新接口(注意:使用原始 axios 发起,避免被全局拦截器死循环拦截)
const res = await axios.post('/api/auth/refresh-token', {
refresh_token: refreshToken
});
const { access_token: newAccessToken, refresh_token: newRefreshToken } = res.data.data;
// 4. 持久化存储最新令牌
localStorage.setItem('access_token', newAccessToken);
if (newRefreshToken) {
localStorage.setItem('refresh_token', newRefreshToken);
}
// 5. 遍历并执行所有积压在队列中的并发请求
retryQueue.forEach((callback) => callback(newAccessToken));
retryQueue = [];
// 6. 重新执行触发本次 401 的原始请求
if (config.headers) {
config.headers.Authorization = `Bearer ${newAccessToken}`;
}
return service(config);
} catch (refreshError) {
// 7. Refresh Token 亦失效或被注销,强制清理并退回登录界面
retryQueue = [];
redirectToLogin('身份验证失效,请重新登录');
return Promise.reject(refreshError);
} finally {
// 8. 释放状态锁
isRefreshing = false;
}
}
function redirectToLogin(message: string) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
console.warn(message);
// 跳转到登录路由并记录当前页面路径,便于登录后重定向返回
const redirectUrl = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/login?redirect=${redirectUrl}`;
}
四、接口分层组织与 Vue 3 组件对接实战
在大型前端团队协作中,严禁在 Vue 组件中直接书写具体的 URL 路径和 HTTP 请求参数。合理的架构是建立独立的 api/ 目录,按业务域(Domain)划分接口服务。
1. 模块化 API 统一管理
每个业务模块集中管理其数据交互契约,以用户模块为例:
import { http } from '@/utils/request';
// 定义用户业务实体类型契约
export interface UserProfile {
id: number;
username: string;
avatar: string;
role: string;
email: string;
}
export interface UpdateUserDto {
avatar?: string;
email?: string;
}
// 导出具有明确语义的方法对象
export const userApi = {
// 获取当前登录用户个人资料
getProfile() {
return http.get<UserProfile>('/users/profile');
},
// 更新个人资料
updateProfile(dto: UpdateUserDto) {
return http.put<UserProfile>('/users/profile', dto);
},
// 登出系统
logout() {
return http.post<void>('/auth/logout');
}
};
2. 在 Vue 3 组合式 API (Script Setup) 中的规范对接
在组件中引入接口时,借助 async/await 与 try...finally 块,可以清晰管理界面的加载骨架屏(Loading)与数据渲染:
<template>
<div class="profile-card">
<!-- 加载中占位 -->
<div v-if="loading" class="loading-state">正在拉取用户资料...</div>
<!-- 真实数据渲染 -->
<div v-else-if="user" class="content-box">
<img :src="user.avatar" alt="Avatar" class="avatar" />
<h3>{ user.username }</h3>
<p>角色权限:{ user.role }</p>
<p>电子邮箱:{ user.email }</p>
<button :disabled="submitting" @click="handleUpdateEmail">
{ submitting ? '保存中...' : '变更邮箱' }
</button>
</div>
<!-- 请求异常兜底 -->
<div v-else class="error-state">资料加载失败,请刷新重试</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { userApi, type UserProfile } from '@/api/user';
const loading = ref<boolean>(false);
const submitting = ref<boolean>(false);
const user = ref<UserProfile | null>(null);
// 初始化加载数据
async function fetchUserProfile() {
loading.value = true;
try {
// 拦截器已自动解包并校验,此处直接获取纯净 UserProfile 数据
const result = await userApi.getProfile();
user.value = result;
} catch (err) {
// 拦截器已处理全局错误提示,组件内仅需承接状态分支
console.error('组件感知到加载异常:', err);
} finally {
loading.value = false;
}
}
// 业务按钮操作
async function handleUpdateEmail() {
if (!user.value) return;
submitting.value = true;
try {
const updated = await userApi.updateProfile({ email: 'developer@example.com' });
user.value = updated;
alert('资料更新成功');
} catch (err) {
console.error('更新失败:', err);
} finally {
submitting.value = false;
}
}
onMounted(() => {
fetchUserProfile();
});
</script>
五、生产环境高频陷阱与开发自查清单
拦截器作为整个应用的数据咽喉,如果配置稍有不慎,极易引发难以定位的诡异 Bug。以下汇总了团队联调中最容易出现的四个典型问题及规避策略:
原因:若登录接口或刷新 Token 接口本身返回了 401(例如密码错误或 Refresh Token 已过期),如果该请求同样被当前拦截器的 401 逻辑捕获并再次发起刷新,将导致死循环发包。
解法:对刷新接口使用独立的 Axios 原生实例,或者在请求配置中增加自定义标记属性(如
config.skipAuthRefresh = true),拦截器检测到此标记时直接跳过刷新重试,执行登出。
原因:当调用
AbortController.abort() 或快速切换路由离开页面时,未结项的请求会被浏览器中断并进入响应拦截器的 error 回调。解法:在响应拦截器的错误分支头部,务必通过
axios.isCancel(error) 进行防御性判定,若为真则仅打印调试日志,禁止触发全局的错误弹窗或跳转操作。
原因:文件导出接口通常返回二进制流(
responseType: 'blob'),此时响应体中没有常规的 code 与 data 包装。如果拦截器盲目尝试读取 data.code,会导致文件对象损坏且流程抛错。解法:在拦截器中优先判定
response.config.responseType === 'blob',若是文件流则直接原样放行返回二进制内容。
全局异常状态码快速映射参考表
| 状态类型 | 代码标识 | 触发场景 | 拦截器处理策略 |
|---|---|---|---|
| 业务成功 | HTTP 200 / code: 200 | 数据正常返回 | 剥离外层,返回 response.data.data 供组件使用 |
| 业务校验失败 | HTTP 200 / code: 400xx | 参数非法、业务前置条件不满足 | 弹出 Toast 警告提示,中断 Promise 并向组件抛出错误 |
| 鉴权失效 | HTTP 401 Unauthorized | Token 过期、未携带凭据 | 挂起当前请求,加锁发起无感刷新;若刷新失败则清理会话并重定向登录页 |
| 权限不足 | HTTP 403 Forbidden | 账号权限等级不够 | 弹出“无权访问该资源”拒绝提示,阻止非授权操作 |
| 服务端故障 | HTTP 500 / 502 / 503 | 服务端宕机、网关超时、数据库异常 | 统一弹出“系统繁忙,请稍后重试”,避免直接向用户暴露后端敏感报错堆栈 |
| 网络链路异常 | ECONNABORTED / Network Error | 客户端断网、请求超时 | 弹出网络状况诊断提示,引导用户检查本地连接 |
Vue.js 是什么:渐进式 JavaScript 框架的核心优势与适用场景
2026-11-03 20:00:13
为什么JS代码执行了但页面没有跳转
2026-11-03 16:29:09
前端PC端自适应开发:两种常见适配方案解析
2026-11-01 03:30:54
Vue3中通过JS全局调用弹窗组件的实现方案
2026-10-31 21:34:31
Vue3中利用input与audio标签实现音频上传及播放控制
2026-10-31 14:07:34








