在前后端分离的架构中,前端几乎所有的数据交互都依赖 HTTP 请求。当项目接口数量达到一定规模后,如果每个请求都手动编写 Token 注入逻辑和错误处理代码,不仅维护成本极高,还容易遗漏边界场景。Axios 拦截器提供了一种在请求发送前和响应返回后插入自定义逻辑的能力,使得我们可以将公共逻辑集中管理。本文将以 Vue 项目为背景,从请求头注入、Token 无感刷新到统一错误提示,完整拆解拦截器的工程化实践。

一、Axios 拦截器基础概念与请求头注入
Axios 拦截器分为请求拦截器和响应拦截器两类。请求拦截器在请求发出之前执行,适合在这里统一添加请求头、设置 Token、附加时间戳防止缓存等操作。响应拦截器在收到服务器响应之后执行,适合统一处理错误码、过滤数据、触发 Token 刷新等逻辑。两者通过 axios.interceptors.request.use() 和 axios.interceptors.response.use() 进行注册。
在 Vue 项目中,推荐创建一个独立的 Axios 实例而不是直接使用默认实例。这样做的好处是可以为不同业务模块配置不同的 baseURL、超时时间等参数,同时保持拦截器逻辑的统一管理。比如你可以为需要鉴权的接口创建一个实例,为公开接口创建另一个实例,互不干扰。
请求头注入的核心思路是:从本地存储中读取 Token,如果 Token 存在,就将其添加到请求头的 Authorization 字段中。除了 Token 之外,还可以根据业务需要注入诸如时间戳、签名、设备标识等自定义头信息。下面是一个完整的请求拦截器示例:
import axios from 'axios'
import store from '@/store'
// 创建独立实例
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 15000
})
// 请求拦截器:统一注入请求头
service.interceptors.request.use(
config => {
// 从 Vuex 或本地存储中获取 Token
const token = store.getters.token || localStorage.getItem('access_token')
if (token) {
// 标准的 Bearer Token 格式
config.headers['Authorization'] = 'Bearer ' + token
}
// 添加时间戳,防止 IE 浏览器缓存 GET 请求
if (config.method === 'get') {
config.params = {
...config.params,
_t: Date.now()
}
}
// 可以根据业务需要添加自定义请求头
config.headers['X-Request-Id'] = generateRequestId()
return config
},
error => {
// 请求发送前的错误处理
console.error('请求拦截器错误:', error)
return Promise.reject(error)
}
)
上面的代码中,请求拦截器做了三件事:注入 Authorization 头、为 GET 请求添加时间戳防缓存、附加请求追踪 ID。其中 Token 的获取优先从 Vuex 读取,因为 Vuex 中的状态是响应式的,当 Token 刷新后所有后续请求都能立即拿到最新值。需要注意的是,config.headers 在不同版本的 Axios 中可能是一个普通对象或 AxiosHeaders 实例,赋值方式略有差异,建议根据实际使用的版本调整写法。
二、Token 过期自动刷新机制实现
在实际业务中,Access Token 通常有较短的有效期,短则几十分钟,长则数小时。当 Token 过期时,后端会返回 401 状态码。传统的处理方式是直接清除本地 Token 并跳转登录页,但这种方式会打断用户的操作流程,体验较差。更优雅的方案是在 Token 过期时自动使用 Refresh Token 获取新的访问令牌,然后重新发起失败的请求,整个过程对用户完全透明,这就是所谓的无感刷新。
实现无感刷新需要解决几个关键问题。第一,如何判断 Token 是否过期,通常通过响应状态码 401 来判定。第二,如何避免并发请求同时触发多次刷新,这是最容易踩坑的地方。第三,刷新失败后如何降级处理,比如 Refresh Token 也过期了,此时应该跳转登录页。第四,刷新成功后如何将队列中挂起的请求依次重发。
对于并发请求问题,核心思路是引入一个标志变量 isRefreshing 和一个请求队列数组。当第一个请求收到 401 响应时,设置 isRefreshing 为 true 并发起刷新请求,同时将后续收到 401 的请求通过 Promise 挂起到队列中。刷新成功后,取出队列中的请求依次重发;刷新失败则清空队列并跳转登录页。下面是完整的实现代码:
let isRefreshing = false
let requestQueue = []
// 响应拦截器:处理 Token 刷新
service.interceptors.response.use(
response => {
// 对响应数据做处理,直接返回 data 字段
return response.data
},
async error => {
const originalRequest = error.config
// 判断是否为 401 且未标记过重试
if (error.response && error.response.status === 401 && !originalRequest._retry) {
// 如果已经在刷新中,将请求加入队列等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
requestQueue.push({ resolve, reject, config: originalRequest })
})
}
// 标记为正在刷新
isRefreshing = true
originalRequest._retry = true
try {
// 调用刷新 Token 接口
const refreshToken = localStorage.getItem('refresh_token')
const res = await axios.post('/auth/refresh', { refreshToken })
const newToken = res.data.accessToken
// 更新本地存储和 Vuex 中的 Token
localStorage.setItem('access_token', newToken)
store.commit('SET_TOKEN', newToken)
// 重发队列中挂起的请求
requestQueue.forEach(item => {
item.config.headers['Authorization'] = 'Bearer ' + newToken
item.resolve(service(item.config))
})
requestQueue = []
// 重发当前失败的请求
originalRequest.headers['Authorization'] = 'Bearer ' + newToken
return service(originalRequest)
} catch (refreshError) {
// 刷新失败,清空队列并跳转登录页
requestQueue.forEach(item => {
item.reject(refreshError)
})
requestQueue = []
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
store.commit('CLEAR_TOKEN')
// 跳转登录页,并记录来源路径
router.push({
path: '/login',
query: { redirect: router.currentRoute.fullPath }
})
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)
这段代码有几个细节值得注意。首先,originalRequest._retry 标记用于防止同一个请求在刷新失败后被无限重试。其次,队列中存储的是 Promise 的 resolve 和 reject 函数,这样当刷新成功后可以直接 resolve 对应的请求,调用方无感知。最后,刷新接口使用的是原始的 axios.post 而非 service 实例,这是为了避免刷新请求本身也经过拦截器导致死循环。如果后端的 Refresh Token 也有过期时间,可以在刷新前先检查时间戳提前刷新,避免在请求过程中遇到 401。
三、统一错误处理与用户提示方案
后端接口返回的数据格式通常包含业务状态码和错误信息。即使 HTTP 状态码是 200,业务层面也可能存在错误,比如参数校验失败、权限不足、资源不存在等。因此,统一错误处理需要同时覆盖 HTTP 层错误和业务层错误两个维度。HTTP 层错误通过响应拦截器的 error 回调捕获,可以根据 status 码进行分类处理。业务层错误则需要在响应拦截器的成功回调中检查返回数据中的业务状态码字段。
在用户提示方面,Vue 项目中通常会结合 Element Plus 或 Ant Design Vue 的消息组件来实现。对于不同级别的错误,应当采用不同的提示策略:参数校验类错误用 warning 类型提示,系统级错误用 error 类型提示,网络断开或服务器不可达时可以用全局遮罩组件提示并支持重试。下面是一个结合 Element Plus 的统一错误处理方案:
import { ElMessage, ElMessageBox } from 'element-plus'
// 业务状态码与提示信息的映射表
const errorCodeMap = {
400: '请求参数错误',
401: '登录状态已过期,请重新登录',
403: '没有访问权限',
404: '请求的资源不存在',
429: '请求过于频繁,请稍后再试',
500: '服务器内部错误',
502: '网关错误',
503: '服务暂时不可用',
504: '网关超时'
}
// 统一错误处理函数
function handleError(error) {
let message = '未知错误'
if (error.response) {
// 服务器返回了响应,但状态码非 2xx
const status = error.response.status
const data = error.response.data
// 优先使用后端返回的具体错误信息
if (data && data.message) {
message = data.message
} else if (errorCodeMap[status]) {
message = errorCodeMap[status]
}
// 401 已在 Token 刷新逻辑中处理,这里跳过
if (status !== 401) {
if (status >= 500) {
ElMessage.error(message)
} else if (status === 403) {
ElMessage.warning(message)
} else {
ElMessage.error(message)
}
}
} else if (error.request) {
// 请求已发出但没有收到响应,通常是网络问题
message = '网络异常,请检查网络连接'
ElMessage.error(message)
} else {
// 请求配置阶段的错误
message = error.message || '请求发送失败'
ElMessage.error(message)
}
return Promise.reject(error)
}
// 在响应拦截器中接入统一错误处理
service.interceptors.response.use(
response => {
const res = response.data
// 假设后端约定 code 为 0 表示成功
if (res.code !== 0) {
ElMessage.error(res.message || '业务处理失败')
return Promise.reject(new Error(res.message || 'Error'))
}
return res
},
error => {
return handleError(error)
}
)
上述方案将错误处理逻辑封装为独立的 handleError 函数,职责清晰且易于扩展。当后端返回的错误信息中包含具体字段校验提示时,优先展示后端信息而非前端预设文案,这样能提供更精准的反馈。对于 5xx 系列的服务器错误,可以考虑加入自动重试机制,但需要设置最大重试次数避免无限循环。此外,在开发环境下可以将完整错误信息打印到控制台,方便调试排查,而在生产环境只展示用户可读的提示文案。
综合来看,通过请求拦截器注入 Token 和自定义头、响应拦截器实现无感刷新、统一错误处理函数管理用户提示,这三层拦截器机制构成了 Vue 项目中 HTTP 通信的完整基础设施。在实际项目中,还可以根据需要扩展请求重试、请求取消、接口缓存、接口防抖等功能,进一步提升前端应用的健壮性和用户体验。关键在于将公共逻辑收敛到拦截器层,让业务代码只关注数据本身,这也是前端工程化的核心目标之一。