Axios 二次封装:拦截器、统一错误处理与文件下载

Axios 二次封装:拦截器、统一错误处理与文件下载

前端项目一旦接口多起来,就会出现这些痛点:

  • 每个请求都要手动带 token
  • 401/500 的处理散落在各个页面
  • 文件下载(Excel)处理不统一,兼容性一堆坑

这篇给你一套可复用、可维护、能直接落地到业务项目里的 Axios 二次封装思路:

  • 请求实例:把 baseURL/timeout/header 做成统一入口
  • 拦截器:Token 注入、返回结构归一、错误提示、登录失效跳转
  • 下载能力:Blob 直通 + 文件名解析 + "失败时返回 JSON"兼容

文中示例会对齐一份典型的工程实现(与你项目 frontend/vue/src/api/request.js 的处理方式一致):

  • Token 以 Authorization: Bearer <token> 方式注入
  • config.silent 控制是否弹出错误提示
  • responseType=blob/arraybuffer 直接返回原始响应
  • 兼容 { success, message, data }{ code, message, data } 两种返回
  • 业务 code != 200 统一当作失败处理

0. 先定"契约":你希望页面拿到什么

做封装前,先把"页面能依赖的返回契约"定下来,否则项目后期会出现:

  • 有的接口返回 { code, message, data }
  • 有的接口返回 { success, message, data }
  • 有的接口直接返回数组

最终导致:

  • 每个页面都在写 if-else 兼容
  • 错误提示风格不一致

建议你把页面层的依赖收敛为:

  • 成功 :返回 { code: 200, message, data }(或直接返回 data,但要统一)
  • 失败:Promise 直接 reject(页面只写一个 catch 就能兜底)

1. 为什么需要二次封装

你希望达到的效果:

  • 所有请求默认带上公共配置(baseURL、timeout)
  • token 注入、错误提示、登录失效处理集中在一处
  • 页面只关心"业务成功/失败",而不是关心 HTTP 细节

2. 创建实例:把环境差异和默认 header 固化

工程里最常见的 baseURL 策略:

  • 开发环境走代理(baseURL 为空)
  • 生产环境走真实域名(通过环境变量配置)

同时统一 Content-Type,避免每个请求都写一遍。

一个可参考的实例骨架(与项目里常见写法一致):

js 复制代码
import axios from 'axios'

const service = axios.create({
  baseURL: import.meta.env.DEV
    ? ''
    : (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'),
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  }
})

3. 请求拦截器:统一注入 token

js 复制代码
service.interceptors.request.use((config) => {
  const token = getToken()
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

注意点:

  • 不要在每个 API 函数里重复拼 header
  • token 的读取来源建议是 store(Pinia)+ 持久化兜底

工程建议:

  • Token 缺失要允许请求继续发(例如登录接口本来就不需要 token)
  • 不要在请求拦截器里做业务提示(提示应尽量收敛在响应拦截器)

4. 响应拦截器:三类返回要分开处理

响应拦截器建议先把返回分为三类:

  • 下载类responseType=blob/arraybuffer,直接返回原始 response
  • 业务类:JSON 返回,走"结构归一 + code 判定"
  • 异常类:HTTP 状态码异常、网络异常

常见后端返回:

  • HTTP 200,但 code != 0 表示业务失败

你可以在拦截器里统一收敛:

下面给一份"能直接抄到项目里"的完整骨架(与常见 Vue3 + Element Plus + Pinia 的组合一致):

js 复制代码
import { ElMessage } from 'element-plus'
import router from '@/router'
import { useUserStore } from '@/store/modules/user'

function handleLogoutAndRedirect() {
  if (import.meta.env.DEV) return
  const userStore = useUserStore()
  userStore.logout()
  router.push('/auth/login')
}

function normalizeHttpErrorMessage(error) {
  if (error.response) {
    switch (error.response.status) {
      case 401:
        return '未授权,请重新登录'
      case 403:
        return '拒绝访问'
      case 404:
        return '请求错误,未找到该资源'
      case 500:
        return '服务器错误'
      default:
        return `连接错误${error.response.status}`
    }
  }
  if (error.request) return '网络连接失败,请检查网络'
  return '请求失败'
}

service.interceptors.response.use(
  (response) => {
    // 文件下载(blob/arraybuffer)直接返回原始响应,不走统一 code 判断
    const rt = response.config?.responseType
    if (rt === 'blob' || rt === 'arraybuffer') {
      return response
    }

    const res = response.data
    const silent = !!response.config?.silent

    // 兼容部分接口返回:{ success: boolean, message, data }
    if (res && typeof res === 'object' && typeof res.code === 'undefined' && typeof res.success === 'boolean') {
      return {
        code: res.success ? 200 : 500,
        message: res.message,
        data: res.data
      }
    }

    if (res.code !== 200) {
      if (!silent) {
        ElMessage.error(res.message || '请求失败')
      }
      if (res.code === 401) {
        handleLogoutAndRedirect()
      }
      return Promise.reject(new Error(res.message || '请求失败'))
    }

    return res
  },
  (error) => {
    const silent = !!error?.config?.silent
    const message = normalizeHttpErrorMessage(error)
    if (!silent) {
      ElMessage.error(message)
    }
    if (error.response?.status === 401) {
      handleLogoutAndRedirect()
    }
    return Promise.reject(error)
  }
)

关键点:

  • 页面只处理 Promise reject,不要再关心 code
  • 401 逻辑务必统一,否则体验会很割裂

落地建议(很重要):

  • 弹窗提示要可控 :列表页自动刷新、轮询接口失败,不应该一直弹框,所以需要 silent 这种开关
  • 登录失效要防"开发环境误踢":开发环境经常没开完整登录链路,建议按环境判断是否强制跳转

5. 文件下载:一套完整的"可复用"方案

文件下载看似简单,但坑通常集中在三点:

  • 返回是 Blob,你却按 JSON 处理
  • 文件名在 Content-Disposition 里,需要解析与解码
  • 后端失败时仍然返回 JSON(但 responseType 是 blob),前端要能把错误信息读出来

常见坑:

  • 后端返回的是 blob,但你按 JSON 解析会报错
  • 下载文件名在 header 里,需要解码

请求侧:

  • 下载接口设置 responseType: 'blob'
js 复制代码
export function exportExcel(params) {
  return instance.get('/xxx/export', {
    params,
    responseType: 'blob',
  })
}

响应侧:

  • 从 header 获取文件名
  • 创建临时 a 标签触发下载
js 复制代码
function downloadBlob(blob, filename) {
  const url = window.URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = filename
  a.click()
  window.URL.revokeObjectURL(url)
}

如果后端在下载失败时返回 JSON(错误信息),你需要兼容:

  • blob 转 text -> JSON.parse 判断是否是错误

给一份可直接复用的工具函数示例:

js 复制代码
function getFilenameFromDisposition(disposition) {
  if (!disposition) return ''
  const match = /filename\*=UTF-8''([^;]+)|filename="?([^;"]+)"?/i.exec(disposition)
  const raw = match?.[1] || match?.[2] || ''
  try {
    return decodeURIComponent(raw)
  } catch (e) {
    return raw
  }
}

async function tryParseBlobAsJson(blob) {
  try {
    const text = await blob.text()
    return JSON.parse(text)
  } catch (e) {
    return null
  }
}

export async function downloadByResponse(response, fallbackName = 'export.xlsx') {
  const blob = response.data
  const contentType = response.headers?.['content-type'] || ''

  // 后端失败时返回 JSON,但前端按 blob 接收,需要把错误信息读出来
  if (contentType.includes('application/json')) {
    const json = await tryParseBlobAsJson(blob)
    const msg = json?.message || json?.msg || '导出失败'
    throw new Error(msg)
  }

  const disposition = response.headers?.['content-disposition']
  const filename = getFilenameFromDisposition(disposition) || fallbackName

  const url = window.URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = filename
  a.click()
  window.URL.revokeObjectURL(url)
}

工程化建议:

  • 把"解析文件名 + 下载 + 失败解析"封装成 downloadByResponse(response)
  • 下载接口统一返回 Axios 的原始响应(因为你要拿 headers)

6. 常见工程增强(可选但很加分)

6.1 请求取消与防抖

列表页常见场景:

  • 关键字搜索、快速切换筛选条件

你可以:

  • AbortController 取消上一次请求
  • 或在输入侧做防抖(更推荐)

6.2 统一重试与降级(谨慎使用)

对"幂等查询接口"可以在网络抖动时做少量重试,但要避免:

  • 把后端瞬时故障放大成雪崩
  • 让用户以为操作成功但其实重复请求

7. 排查清单:封装后出了问题怎么定位

  • Token 没带上 :看请求头是否包含 Authorization: Bearer ...
  • 页面拿到的不是 data :确认响应拦截器最终返回了什么(是否返回 res 还是 res.data
  • 下载得到一个很小的文件:通常是后端返回了错误 JSON,被当成文件保存了;需要做 blob->text 解析判断
  • 一直弹错误 :给轮询/静默接口加 silent: true
  • 开发环境频繁被踢登录:确认是否对 DEV 做了"401 不强制跳转"的处理

8. 总结

  • Axios 二次封装的核心是:把"通用横切逻辑"从页面里剥离
  • 请求拦截器统一注入 Bearer Token
  • 响应拦截器先分流(下载直通 / 业务归一 / 异常兜底),再统一处理 code 与 401
  • 通过 silent 把"是否提示错误"变成可控开关
  • 下载接口返回原始响应,统一做文件名解析与错误 JSON 兼容
相关推荐
24白菜头2 小时前
若依框架Ruoyi-Vue-SpringBoot3部署
前端·javascript·笔记·后端·学习
问道飞鱼2 小时前
【Tauri框架学习】Tauri 与 React 前端集成:通信机制与交互原理详解
前端·学习·react.js·rust·通信
霍理迪2 小时前
Vue列表过滤与排序
前端·javascript·vue.js
牛十二2 小时前
智能体框架开发实战
运维·服务器·前端
鹅天帝2 小时前
20230319网安学习日志——XSS漏洞
前端·学习·web安全·网络安全·xss
floret. 小花2 小时前
Vue3 + Electron 知识点总结 · 2026-03-21
前端·面试·electron·学习笔记·vue3
蓝黑20202 小时前
Vue的v-if和v-for放在同一个HTML元素里的坑
前端·javascript·vue.js
转角羊儿2 小时前
精灵图案例
开发语言·前端·javascript
大雷神2 小时前
HarmonyOS APP<玩转React>开源教程十八:课程详情页面
前端·react.js·开源·harmonyos