项目里的 axios 到处散落着 token 判断和 loading 控制?从裸用到生产级,四步封装------请求拦截、Token 无感刷新、防重复提交、统一报错。
一、每个前端都写过的那坨请求代码
如果你打开一个跑了一段时间的 Vue3 项目,大概率会在各个页面里看到这样的代码:
javascript
// 页面 A
axios.post('/api/order/list', params, {
headers: { Authorization: 'Bearer ' + localStorage.getItem('token') }
}).then(res => {
if (res.data.code === 200) { /* ... */ }
else { ElMessage.error(res.data.msg) }
}).catch(err => { ElMessage.error('网络异常') })
然后页面 B 又写了一遍,页面 C 再写一遍。
axios 不封装,等于裸奔;封装过头,等于裹脚布。关键是找到那个"刚好够用"的度。
二、第一阶段:基础封装------消灭散装 axios
javascript
// utils/request.js
import axios from 'axios'
import { ElMessage } from 'element-plus'
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
headers: { 'Content-Type': 'application/json;charset=utf-8' }
})
// 响应拦截------统一拆包
service.interceptors.response.use(
response => {
const { code, data, msg } = response.data
if (code === 200) return data // 只返回业务数据
ElMessage.error(msg || '系统异常')
return Promise.reject(new Error(msg))
},
error => {
ElMessage.error('网络异常,请检查网络连接')
return Promise.reject(error)
}
)
// 请求拦截------自动注入 Token
service.interceptors.request.use(config => {
const token = localStorage.getItem('accessToken')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
export default service
现在业务代码干净了:
csharp
// 之前:4 行
const res = await axios.post('/api/order/list', params)
if (res.data.code === 200) { tableData.value = res.data.data }
// 之后:1 行
tableData.value = await service.post('/api/order/list', params)
三、第二阶段:Token 无感刷新
3.1 双 Token 机制
| Token | 有效期 | 存储位置 | 用途 |
|---|---|---|---|
| accessToken | 30 分钟 | localStorage | 每次请求携带 |
| refreshToken | 7 天 | localStorage | 换取新 accessToken |
3.2 核心难题:并发刷新冲突
页面同时发 3 个请求,accessToken 全部过期 → 3 个 401 → 不能各自刷新。需要刷新锁 + 请求队列。
javascript
let isRefreshing = false
let pendingRequests = []
service.interceptors.response.use(
response => {
const { code, data, msg } = response.data
if (code === 200) return data
ElMessage.error(msg || '系统异常')
return Promise.reject(new Error(msg))
},
async error => {
const { config, response } = error
if (!response || response.status !== 401) {
ElMessage.error('网络异常,请检查网络连接')
return Promise.reject(error)
}
// 刷新接口本身 401 = refreshToken 也过期了
if (config.url.includes('/api/auth/refresh')) {
localStorage.clear()
window.location.href = '/login'
return Promise.reject(error)
}
if (!isRefreshing) {
isRefreshing = true
try {
const { accessToken, refreshToken: newRefreshToken } = await refreshToken()
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', newRefreshToken)
pendingRequests.forEach(cb => cb(accessToken))
pendingRequests = []
config.headers.Authorization = `Bearer ${accessToken}`
return service(config) // 重试原请求
} catch (err) {
pendingRequests.forEach(cb => cb(null))
pendingRequests = []
localStorage.clear()
window.location.href = '/login'
return Promise.reject(err)
} finally {
isRefreshing = false
}
} else {
// 正在刷新中,排队等待
return new Promise((resolve) => {
pendingRequests.push((token) => {
if (token) {
config.headers.Authorization = `Bearer ${token}`
resolve(service(config))
} else {
resolve(Promise.reject(new Error('刷新失败')))
}
})
})
}
}
)
关键设计:
isRefreshing锁------同一时刻只有一个刷新请求pendingRequests队列------其他 401 排队等待,刷新成功后批量重放- 刷新接口 401 特殊处理------防止死循环
四、第三阶段:防重复提交
scss
const pendingMap = new Map()
function getRequestKey(config) {
const { method, url, params, data } = config
return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&')
}
function addPending(config) {
const key = getRequestKey(config)
if (pendingMap.has(key)) {
const controller = new AbortController()
config.signal = controller.signal
controller.abort()
return
}
pendingMap.set(key, config)
}
function removePending(config) {
const key = getRequestKey(config)
pendingMap.delete(key)
}
// 在请求拦截器中调用 addPending(config)
// 在响应拦截器的成功和失败分支中都调用 removePending(config)
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 按钮 loading | 点完 disabled | 简单直观 | 多个入口可能重复调用 |
| 前端防抖 | debounce 300ms | 代码少 | 长耗时请求仍可能重复 |
| 接口幂等 key | 后端加唯一 key | 最可靠 | 需要后端配合 |
| 请求拦截去重 | 拦截器判断 | 前端全自动 | 依赖 URL+参数作为标识 |
五、第四阶段:Loading 与错误统一管理
arduino
// 按需 Loading
service.interceptors.request.use(config => {
if (config.showLoading !== false) {
config._loadingInstance = ElLoading.service({
lock: true,
text: config.loadingText || '加载中...',
background: 'rgba(0, 0, 0, 0.1)'
})
}
// ... Token 注入等
})
service.interceptors.response.use(
response => {
if (response.config._loadingInstance) response.config._loadingInstance.close()
// ...
},
error => {
if (error.config?._loadingInstance) error.config._loadingInstance.close()
// ...
}
)
六、成品目录结构
bash
src/
├── utils/
│ ├── request.js # 四层封装
│ └── errorHandler.js # 错误码映射
├── api/
│ └── modules/
│ ├── order.js # 工单接口
│ ├── customer.js # 客户接口
│ └── auth.js # 认证接口
业务代码一行搞定:
javascript
import { listOrder, saveOrder } from '@/api/modules/order'
const tableData = await listOrder({ pageNum: 1, pageSize: 10 })
await saveOrder(form) // loading + 防重复全自动
七、三个关键决策
- 双 Token vs 单 Token:单 Token 时间长了不安全,短了体验差;双 Token 兼顾安全与体验。
- 拦截器里不用
router.push:router 可能未初始化,用window.location.href硬跳更可靠。 - 前端防重复不是终点:后续结合后端幂等 key 双重保障才是最终形态。
关于作者
全栈开发者,深圳创业,专注印刷包装行业数字化。技术栈:Java / Spring Boot / Vue3 / uni-app。
持续分享全栈实战、若依框架系列、MES & CRM 产品设计。
每周更新,欢迎关注微信公众号「MqCode」👇

长按识别二维码关注,获取更多全栈开发实战内容。