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 兼容