Vue.js 项目中 Axios 拦截器封装与接口对接实战

作者:云间搭桥时间:2026-11-02 10:22:43浏览:318

深入讲解在 Vue.js 项目中如何高效封装 Axios 实例与请求/响应拦截器,覆盖环境配置、Token 动态注入、401 无感刷新、防重复提交与模块化 API 接口对接实战。

在中大型 Vue.js 项目中,直接在组件内部零散调用原始 axios 会带来配置重复、鉴权散落、错误处理繁琐及联调维护困难等痛点。本文将以工程化落地为主线,系统拆解如何基于 Axios 实例与拦截器机制,构建包含统一鉴权、状态码解包、无感刷新 Token、重复请求拦截及模块化管理的接口层架构,并结合 Vue 3 组合式 API 展示标准的联调对接方案。
Vue.js 项目中 Axios 拦截器封装与接口对接实战
图 1:Vue.js 视图层、API 解耦层、Axios 拦截管线与后端微服务的系统分层架构

一、为什么必须对 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 允许我们插入两个切面函数,形成一套高内聚的网络协议处理网关。

Vue.js 项目中 Axios 拦截器封装与接口对接实战
图 2:Axios 请求拦截与响应拦截双向管道数据流转机制

如上图所示,当组件发起调用时,请求配置会首先进入请求拦截器队列,完成鉴权凭据注入与重复请求去重,随后由底层传输适配器派发到服务端。服务端返回响应报文后,报文进入响应拦截器队列,完成网络协议状态校验、业务数据解包与全局错误捕获,最终才向业务组件交付纯净的业务数据实体。

二、Axios 拦截器工程化封装落地实战

为了保证网络层的扩展性与可维护性,我们按照标准工程化范式将封装过程划分为四个有序步骤:创建独立实例、装配请求拦截器、装配响应拦截器、导出泛型请求包装函数。

Vue.js 项目中 Axios 拦截器封装与接口对接实战
图 3:Axios 拦截器封装与工程落地标准四步实施路线

步骤一:创建独立 Axios 单例与环境配置

避免直接修改全局默认的 axios.defaults,推荐通过 axios.create() 创建项目专用的网络服务单例。创建独立实例能够确保当前配置只作用于业务接口,后续若引入第三方 CDN 或独立微服务,互不干扰。

src/utils/request.ts (或 request.js)
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 失效。此时必须使用状态锁与请求暂存重试队列。

Vue.js 项目中 Axios 拦截器封装与接口对接实战
图 4:401 状态下利用 Promise 挂起与重试队列实现无感刷新
核心思想:当首个请求收到 401 报错时,立刻获取排他锁 isRefreshing = true 并发起 Token 续期;此时后续并发的 401 请求不发起独立刷新,而是将自身的重放函数暂存进一个待执行队列中,返回一个“尚未 Resolve”的 Promise 挂起业务执行。待新 Token 成功返回后,顺序遍历重试队列,完成所有被挂起请求的自动重放。
src/utils/refreshToken.ts
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 统一管理

每个业务模块集中管理其数据交互契约,以用户模块为例:

src/api/user.ts
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)与数据渲染:

src/views/UserProfile.vue
<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。以下汇总了团队联调中最容易出现的四个典型问题及规避策略:

陷阱 1:无感刷新或 401 路由跳转引起的死循环
原因:若登录接口或刷新 Token 接口本身返回了 401(例如密码错误或 Refresh Token 已过期),如果该请求同样被当前拦截器的 401 逻辑捕获并再次发起刷新,将导致死循环发包。
解法:对刷新接口使用独立的 Axios 原生实例,或者在请求配置中增加自定义标记属性(如 config.skipAuthRefresh = true),拦截器检测到此标记时直接跳过刷新重试,执行登出。
陷阱 2:主动取消请求被误判为网络错误并在界面报红
原因:当调用 AbortController.abort() 或快速切换路由离开页面时,未结项的请求会被浏览器中断并进入响应拦截器的 error 回调。
解法:在响应拦截器的错误分支头部,务必通过 axios.isCancel(error) 进行防御性判定,若为真则仅打印调试日志,禁止触发全局的错误弹窗或跳转操作。
陷阱 3:文件下载导出被业务解包破坏格式
原因:文件导出接口通常返回二进制流(responseType: 'blob'),此时响应体中没有常规的 code 与 data 包装。如果拦截器盲目尝试读取 data.code,会导致文件对象损坏且流程抛错。
解法:在拦截器中优先判定 response.config.responseType === 'blob',若是文件流则直接原样放行返回二进制内容。
Vue.js 项目中 Axios 拦截器封装与接口对接实战
图 5:Axios 拦截器设计三原则、错误分发矩阵与生产自查防坑清单总结

全局异常状态码快速映射参考表

状态类型 代码标识 触发场景 拦截器处理策略
业务成功 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 客户端断网、请求超时 弹出网络状况诊断提示,引导用户检查本地连接
作者最新文章
相关文章
更多