首页 / 资讯中心 / 文章详情

Vue3中Axios请求封装:拦截器、取消请求与刷新Token实战

Vue3中Axios请求封装:拦截器、取消请求与刷新Token实战 ★ FEATURED ARTICLE
1. 封装之前先想清楚这四个问题1.1 为什么非要把 axios 再包一层几乎每个 Vue 项目都会用到 axios但它本身只是一个请求工具不是一个应用请求方案。直接在每个页面里axios.get(...)写业务一开始很爽等接口连到十几二十个、页面多到几十个的时候问题就全冒出来了baseURL 散了、token 到处拼、401 跳转写了好几遍、错误提示风格不统一、接口调完不管状态码就直接用返回值。我见过不少项目的请求代码长这样每个页面自己写axios.get然后res.data.code 200判断一下弹个 message跳个登录页。同一个接口被三个页面调用错误处理逻辑写三遍。这不是危言耸听是真实发生过的项目现状。封装 axios 的核心目标不是少写几行代码而是把请求生命周期里那些横切关注点集中收敛统一的 baseURL 处理、超时和取消策略、token 注入、响应码校验、登录态失效后的自动刷新、全局错误提示。把这些东西沉淀到一层请求库业务代码只需要关心我要调什么接口、参数是什么、返回数据长什么样。1.2 标题里藏着的两个硬需求这个标题里有两个关键词取消请求、刷新 token。这两个恰恰是 axios 封装里最有挑战性的两个点也是面试里问得最多、实际项目里最容易写出 bug 的地方。取消请求解决的问题是用户切换页面、组件卸载、重复点击提交按钮时已经发出的 HTTP 请求还在跑。回调返回时组件已经销毁setState 警告、数据覆盖新页面、提交按钮被连点两次发两条一模一样的数据……这些都是取消请求要消灭的场景。刷新 token 解决的问题是access token 过期后一般是 2 小时或 30 分钟用户还在当前页面操作接口突然全部 401。粗暴的做法是直接踢回登录页但用户可能填了一堆表单跳过去就全没了。优雅的做法是用 refresh token 静默换取新的 access token然后把刚才失败的请求自动重放一遍用户无感知。这两个功能不是锦上添花是生产环境里的刚需。尤其是管理后台这类长期停留、多标签操作的系统token 过期问题几乎必然遇到。1.3 版本选择axios 0.x 还是 1.x开始写封装之前先确认 axios 版本这一步很多人忽略等到代码写了一半才发现 API 变了。axios 在 1.x 版本里已经废弃了CancelToken官方推荐的取消方式是标准的AbortController。0.27 及以前的版本还有CancelToken而 1.0 之后2022 年底发布CancelToken虽然还能用但文档里已经标了 deprecated给的是 AbortController 方案。所以我的建议是新项目直接用 axios 1.x取消请求一律用AbortController。理由很简单这是浏览器原生标准不绑定 axios以后即使换请求库也是通用的而且 CancelToken 在并发取消、多个信号叠加这类场景下写起来更繁琐。这篇文章下面给的示例代码基于 axios 1.6 和 Vue 3 组合式 API如果你用的是 Vue 2 Options API把响应式相关的地方稍作调整即可。2. 基础封装与双拦截器设计2.1 实例级别的基础配置先创建一个request.js模块把 axios 实例的公共配置统一凝固下来// src/utils/request.js import axios from axios import { message } from ant-design-vue const service axios.create({ baseURL: import.meta.env.VITE_APP_BASE_URL, // 按环境变量区分 timeout: 15000, // 15s 超时 withCredentials: false, // 取决于项目是否需要携带 cookie }) export default service这一步没什么花活但有两个细节值得说baseURL 别写死。我见过直接把http://xxx.com/api写死在代码里的项目换环境就得全局替换。正确做法是走构建工具的 env 配置比如 Vite 的import.meta.env.VITE_APP_BASE_URL各个环境的.env文件里配置不同值。timeout 按业务定。15 秒不是标准答案。如果项目里有下载类接口可能要单独放宽如果是纯页面的交互接口10 秒以内更合理。这里建议实例默认一个值个别慢接口可以在调用时用timeout字段单独覆盖。组件里怎么用比如登录接口import request from /utils/request export function login(data) { return request.post(/auth/login, data) }业务代码不感知封装细节但这只是开始。2.2 请求拦截器token 注入的几种姿势请求拦截器的作用是在每个请求发出前插入逻辑最常见的就是 token 注入import { getToken } from /utils/auth service.interceptors.request.use( (config) { const token getToken() if (token !config.headers.noAuth) { config.headers.Authorization Bearer ${token} } return config }, (error) Promise.reject(error) )这里要敲一下黑板不是所有接口都需要 Authorization。登录接口、刷新 token 接口、部分公开接口如果都带上 token反而可能引发逻辑混乱比如 refresh token 接口自己拿到一个过期 access token服务端可能直接拒掉。我习惯用请求配置里的自定义标记来控制比如上面的config.headers.noAuth在调用公开接口时request.post(/auth/refresh, params, { headers: { noAuth: true } })token 存储也是容易有坑的地方。localStorage 放 access token 是常见做法简单但存在 XSS 风险放内存里更安全但刷新页面就丢了。很多项目是折中方案access token 放内存或 localStoragerefresh token 放 httponly cookie由后端控制它的生命周期。我的实际经验是access token 放 localStorage 刷新后重新登录校验牺牲一点安全性换取用户体验适合内部管理后台。如果是 toC 应用建议把登录态整体交给后端 session 或 httponly cookie 管理前端不碰 token。2.3 响应拦截器统一错误处理不是简单的 if else响应拦截器是封装的心脏。很多项目的封装就是在这里写了一个code ! 200就弹错误提示的逻辑然后就没有然后了。合理的响应拦截器应该做好这几件事service.interceptors.response.use( (response) { // 二进制数据直接返回比如文件流下载 if (response.config.responseType blob || response.config.responseType arraybuffer) { return response } const res response.data // 和后端约定code 0 或 200 表示业务成功 if (res.code 0 || res.code 200) { return res.data ?? res } // 业务级错误 message.error(res.message || 请求失败) return Promise.reject(new Error(res.message || request failed)) }, (error) { // HTTP 层错误包括取消请求、网络错误、401/403/500 等 if (axios.isCancel(error)) { // 主动取消的请求静默处理 return Promise.reject(error) } const status error.response?.status const config error.config // 这里先不展开下一节细说 401 和 token 刷新 if (status 401 !config._isRetry !config.headers.noAuth) { // 走刷新 token 流程 } // 404 / 500 / 网络超时 if (!error.response) { message.error(网络异常请检查网络连接) } else if (status 500) { message.error(服务器繁忙请稍后重试) } else if (status 400) { message.error(error.response.data?.message || 请求错误) } return Promise.reject(error) } )有几个关键点必须说明第一区分业务错误和HTTP 错误。业务错误是 HTTP 200 但 code 不是成功值比如余额不足HTTP 错误是网络层的问题比如 500、404。两者展示的提示风格不同处理链路也不同。把两者混在一起是新手封装最容易犯的错——一旦后端返回的 code 不在预期范围内你就很难判断到底是不是该走错误弹窗。第二blob 类型要直接返回整个 response。文件下载接口如果做了解包处理拿到的 data 可能就是乱码这时候必须判断responseType走旁路。第三axios.isCancel 要第一个识别。取消请求不是错误不应该弹用户提示。很多项目取消请求后还会触发红色 error message就是因为这里没做拦截。到这里基础骨架完成了。下面进入硬核部分取消请求。3. 取消请求从 CancelToken 到 AbortController3.1 两种方案的差异与选型axios 提供过两种取消方式CancelToken旧const source axios.CancelToken.source() axios.get(/api/xxx, { cancelToken: source.token }) source.cancel(取消原因)AbortController新推荐const controller new AbortController() axios.get(/api/xxx, { signal: controller.signal }) controller.abort()AbortController 是浏览器原生的 APIAbortSignal.abort()这个静态方法也很常用语义清晰标准的 Fetch API 也是用它。axios 1.x 里signal字段直接支持传入AbortSignal。选型结论新项目无脑用 AbortController。别给自己挖坑用 CancelToken。两者的本质区别在于CancelToken 是 axios 自己实现的取消机制而 AbortController 是浏览器层面的取消机制。后者可以直接中断底层的 XHR/Fetch 请求在取消的及时性、信号可组合性上更有优势。3.2 请求队列的维护与管理取消请求最简单的场景是一个接口正在请求中当发出新请求时把旧请求取消掉比如关键词搜索的防抖场景。但更多场景是多个请求并行发出页面切换时全部取消。我的做法是用一个 Map 维护请求标识 - AbortController的映射关系// src/utils/request/cancel.js const pendingMap new Map() // key 生成规则method url params 序列化 function getRequestKey(config) { return [ config.method?.toUpperCase(), config.url, JSON.stringify({ params: config.params, data: config.data, }), ].join() } export function addPendingRequest(config) { const controller new AbortController() config.signal controller.signal const key getRequestKey(config) if (pendingMap.has(key)) { // 存在重复请求取消上一次 pendingMap.get(key)?.abort() pendingMap.delete(key) } pendingMap.set(key, controller) } export function removePendingRequest(config) { const key getRequestKey(config) if (pendingMap.has(key)) { pendingMap.delete(key) } } export function abortAllRequests(reason 页面跳转) { pendingMap.forEach((controller, key) { controller.abort(reason) }) pendingMap.clear() }然后在请求拦截器里加入队逻辑响应拦截器里做出队逻辑// 请求拦截器 service.interceptors.request.use((config) { const token getToken() if (token !config.headers.noAuth) { config.headers.Authorization Bearer ${token} } // 取消请求相关 addPendingRequest(config) return config }) // 响应拦截器成功和失败都要移除 function onFulfilled(response) { removePendingRequest(response.config) // ... 其余逻辑 } function onRejected(error) { removePendingRequest(error.config || {}) // ... 其余逻辑 }为什么 key 要把 params 和 data 也序列化进去因为同一个接口带不同参数是两个语义上不同的请求。比如列表页切换页码GET/list?page1和 GET/list?page2是并发时应该互不干扰的请求。如果只按method url去重切换页码太快就会误杀正在返回的上一页数据。这个细节踩过坑的人都有体会列表快速翻页数据错乱排查半天发现是请求被另一个页面的同 url 请求取消了。3.3 实际场景页面跳转、重复提交、组件卸载取消请求落地的几个场景分别给出处理姿势。场景 1路由跳转时取消所有请求在路由守卫里调用abortAllRequests最简单粗暴也最有效// router/index.js router.beforeEach((to, from, next) { abortAllRequests() next() })这样做的价值在于从列表页跳详情页时列表页还在飞的数据请求直接 abort新页面组件挂载后不会被旧页面的回包干扰。注意路由守卫里取消的是已经发出的、还在 pending 的请求不会影响跳转后的新请求。场景 2组件卸载时取消组件内的请求Vue 3 的script setup里可以用onUnmounted配合组件级 controller 实现onUnmounted(() { abortAllRequests() // 项目级做法 // 或者更精细的只取消本组件相关的请求 })实践中有个取舍如果项目每个组件卸载都调abortAllRequests()确实能保证不出现组件销毁后回调还在跑的脏数据问题但也会误伤全局共享的请求比如某个服务是跨页面的导致 A 页面卸载把 B 页面的请求也取消了。更精细的做法是给组件内的请求统一打标记比如config.headers.componentId然后在 Map 里注册时按标记分组卸载时只取消对应分组。场景 3防止重复提交提交按钮被用户快速点了两次发出去两条 POST后台生成两条订单记录。这种事故每个公司都发生过。配合封装的方案有两种策略。第一种是在请求层全局拦截重复请求前面addPendingRequest里已经写了存在相同 key 就 abort 旧的但这种做法其实有副作用如果用户连续点了保存按钮第二下会直接取消第一下的请求——这恰恰是用户想要的吗不一定。如果是提交表单用户希望的是第二次点击不要生效或提示正在提交中。所以我的建议是防重复提交更适合在业务层做请求库只提供基础设施。比如用一个工具函数const pendingSubmit new Set() export function withSubmitLock(apiFn, lockKey default) { if (pendingSubmit.has(lockKey)) { message.warning(请求已在处理中请勿重复提交) return Promise.resolve(null) } pendingSubmit.add(lockKey) return apiFn().finally(() { pendingSubmit.delete(lockKey) }) } // 使用 withSubmitLock(() request.post(/order/create, data), createOrder)这样既保证了请求库的通用性也不至于把取消旧请求的副作用扩大到所有写操作。这个思路值得记下来请求库做通用能力业务锁在业务侧做。4. 刷新 token避免刷爆炸的完整方案4.1 401 拦截的触发逻辑与边界处理刷新 token 的全部逻辑都挂在响应拦截器的status 401分支上。但这里有个非常隐秘的坑多个接口同时返回 401你发出的刷新 token 请求也会瞬间多飞出去几条。举个例子页面上一排组件各自在挂载时调接口。用户 token 刚好过期10 个接口同时返回 401。如果你的代码是遇到 401 就发刷新请求那就有 10 个 refreshToken 请求同时发出。第一个成功了后面 9 个用的还是旧的 refresh token大概率全部失败。这就是所谓的刷新风暴。解决方案有两个思路请求加锁单飞模式和等待队列。4.2 单飞模式同一时刻只刷新一次单飞模式的核心是用一个标志位或者一个 Promise 引用保证同一时间只有一次刷新请求在飞。let isRefreshing false let refreshPromise null function getRefreshTokenFromStorage() { return localStorage.getItem(refreshToken) } function doRefreshToken() { if (refreshPromise) return refreshPromise refreshPromise axios.post(/auth/refresh, { refreshToken: getRefreshTokenFromStorage(), }) .then((res) { const { accessToken, refreshToken: newRefreshToken } res.data.data setToken(accessToken) setRefreshToken(newRefreshToken) return accessToken }) .finally(() { refreshPromise null }) return refreshPromise }注意这里我用的是refreshPromise而不是简单的布尔标志。这样所有并发调用者拿到的都是同一个 Promise刷新完成后大家一起 await 到结果。如果只是设一个isRefreshing true其他请求只能轮询或 sleep写法又绕又容易出竞态。axios.post这里直接用裸 axios不能走 service 实例——因为 service 的拦截器会注入旧 token还会触发 401 逻辑极容易重入造成死循环。4.3 等待队列期间新请求怎么办单飞模式解决了刷新请求本身的数量问题但还没有解决失败请求的重放问题。设计思路每个因 401 失败的请求在刷新 token 成功后用新 token 重新请求一次原始接口。这个重放过程需要一个队列把失败请求的上下文挂起来。// 等待队列存储失败的请求刷新成功后逐个重放 let waitQueue [] function handleUnauthorized(config) { // 标记该请求已经重试过避免死循环 if (config._isRetry) { // 重试过一次仍然 401说明 refresh token 也失效了直接跳登录 redirectToLogin() return Promise.reject(new Error(unauthorized)) } config._isRetry true return doRefreshToken() .then((newToken) { // 用新 token 重放原请求 config.headers.Authorization Bearer ${newToken} return service(config) }) .catch((refreshError) { // 刷新失败踢回登录 redirectToLogin() return Promise.reject(refreshError) }) }但这里有一个并发重放的坑如果页面同时有 5 个接口失败上面这段代码会被并行调用 5 次。第一次进入时doRefreshToken()开始飞后面 4 次如果也执行doRefreshToken()由于我们用了refreshPromise它们拿到的还是同一个刷新任务。可是问题来了5 次调用都各自在 catch 里写redirectToLogin()万一刷新 token 本身失败就会重复执行 5 次跳转。解决方式是把重新请求原接口这个动作也放到队列里等刷新结果出来后再统一分发let isRefreshing false let waitQueue [] function resolveQueue(newToken) { waitQueue.forEach((cb) cb(newToken)) waitQueue [] } function rejectQueue(err) { waitQueue.forEach((cb) cb.reject(err)) waitQueue [] } function handle401(config) { if (config._isRetry) { redirectToLogin() return Promise.reject(new Error(unauthorized)) } config._isRetry true if (!isRefreshing) { isRefreshing true // 用裸 axios 刷 token axios.post(/auth/refresh, { refreshToken: getRefreshToken() }) .then((res) { const { accessToken, refreshToken } res.data.data setToken(accessToken) setRefreshToken(refreshToken) resolveQueue(accessToken) }) .catch((err) { rejectQueue(err) redirectToLogin() }) .finally(() { isRefreshing false }) } // 返回一个新 Promise刷新完成后用新 token 重放原请求 return new Promise((resolve, reject) { waitQueue.push({ resolve: (newToken) { config.headers.Authorization Bearer ${newToken} resolve(service(config)) }, reject: (err) reject(err), }) }) }这个模式的逻辑很优雅第一个 401 进来isRefreshing是 false开始刷新 token同时把本请求的包装对象挂进队列。后续并发 401 进来isRefreshing是 true不重复发刷新请求直接把自己的包装对象挂进队列。刷新成功resolveQueue遍历队列每个请求用新 token 重放。刷新失败rejectQueue把队列里的请求全部 reject然后统一跳登录页。这里每个请求在队列里等待期间Promise 始终处于 pending 状态业务侧看起来就是这个接口变慢了一点用户无感。4.4 刷新失败的处理链路刷新 token 失败通常意味着 refresh token 也过期了这个状态只能让用户重新登录。但跳登录页也有讲究不要直接window.location.href /login要考虑当前 URL 上是否带着参数、是否需要弹确认框、是否是多标签页都要重置状态。我推荐的做法是做一个独立的事件通知由路由层或全局状态层来处理// 事件总线或一个订阅机制 export function redirectToLogin() { // 避免重复跳转 const current router.currentRoute.value if (current.path /login) return // 清空本地 token clearToken() clearRefreshToken() // 记录来源路径登录后可以跳回 window.location.href /login?redirect${encodeURIComponent(current.fullPath)} }还有个细节跳登录前要把pendingMap里的请求全部 abort 掉。否则已经等待在队列里的请求可能在新 token 不存在的情况下继续重放发出一些带着无效 token 的请求。这是我在一个刷新 token 失败跳登录后、控制台还在持续报接口错误的场景里撞上的问题补上abortAllRequests()才彻底消停。4.5 白名单与标记机制最后刷新 token 的逻辑里必须想清楚哪些接口不应该走这个流程。登录接口必然不携带 token后端返回 401 或业务 code应该走普通错误提示。刷新 token 接口本身走裸 axios绕过了拦截器天然不受影响。某些第三方公开接口不需要 token 的业务接口后端万一返回 401可能是权限判定问题而非 token 过期盲目刷新反而隐蔽了真实原因。所以请求配置里的noAuth标记要贯穿始终。请求拦截器里不带 token响应拦截器里遇到 401 也不触发刷新流程if (status 401 !config.headers.noAuth !config._isRetry) { // token 刷新流程 }5. 常见问题与排查技巧实录这部分是我自己踩过的坑以及帮别人 review 代码时看到的高频问题整理成速查表供参考。问题现象根因分析处理方式取消请求后仍然弹错误提示响应拦截器没识别axios.isCancel在 onRejected 最前面判断取消场景静默返回刷新 token 死循环接口连续调 5、6 次刷新请求走了带有 401 拦截逻辑的实例刷新 token 用裸 axios或者带特殊标记绕过拦截器切换页面后旧页面的 setState 还在执行路由跳转前没取消请求或者没判断组件卸载状态路由守卫里abortAllRequests 组件卸载标记多个请求同时 401刷新 token 发出 10 次没有做单飞用refreshPromise缓存刷新操作刷新 token 成功但重放请求还是 401重放时 URL 或 headers 丢失重放必须基于原始 config而不是request(config)时手动拼错 headers文件下载接口被封装的解包逻辑误伤响应拦截器直接返回了response.data判断responseType blob时直接返回整个 response快速点击搜索旧请求覆盖新请求的结果请求 key 没有包含 paramskey 里序列化 params 和 data刷新 token 失败后控制台持续报错等待队列里的请求没清空也没 abort刷新失败时 reject 队列 abortAllRequests再说三个小技巧都是常规文档里不写的技巧一给每个请求加一个来源标记在请求拦截器里给 config 挂一个config.meta { from: currentRoute.path }取消请求时不仅能识别哪个页面发起的,排障时也方便看日志。挂上这个 meta 之后你在 Network 面板里看到一条请求立刻知道它是哪个路由发出来的排查效率翻倍。service.interceptors.request.use((config) { // 方便排查 config.meta config.meta || {} config.meta.from router.currentRoute.value.fullPath return config })技巧二用 AbortSignal.timeout 代替全局 timeoutaxios 的 timeout 字段用的是内部定时器超时后请求可能还在底层连接上。AbortSignal.timeout(15000)是浏览器提供的静态方法超时直接中断请求。在 axios 1.x 里signal支持传入复合信号也可以考虑用AbortSignal.any([userSignal, timeoutSignal])组合多个取消来源不过要注意浏览器兼容性2024 年之后的主流浏览器基本都支持了。技巧三刷新 token 期间的 UI 反馈如果刷新时间超过 500ms用户可能会觉得接口卡住了。经验做法是在doRefreshToken入口加一个全局 loading 或在关键交互区域显示登录状态刷新中的轻提示。我一般用一个小型响应式标志暴露到全局 store顶部进度条或按钮 loading 都可以挂它。写在最后我前前后后给不同的 Vue 项目封装过四五个版本的请求库每次重写都能发现上一个版本的别扭之处。最大的体会是请求库的封装没有一步到位的万能模板它是跟着业务形态一起演化的。比如项目从单页变成多标签页取消请求的维度就从路由级细化到了标签页级后端从单 token 变成双 token刷新逻辑就从跳登录进化成了静默重放接口量从几十个涨到几百个你就不得不给请求库加上全局 loading 聚合和接口维度日志。这篇文章给到的封装方式是我目前觉得在通用性和复杂度之间比较平衡的一版请求实例 双拦截器 AbortController 维护的取消机制 Promise 单飞式的 token 刷新这套组合拳能覆盖绝大多数中后台前端项目的需求。如果你现在的项目还没有封装请求库可以从最低配开始实例 拦截器 统一错误提示把能跑通的基础版本先落地再按需引入取消和刷新 token。如果已经是成型的项目在重构建议把取消请求和 token 刷新这两个模块单独抽文件独立测试不要和请求实例强耦合——这样即使以后换了请求库、换了 token 方案核心能力也能直接搬走。
阅读完成 · 觉得有帮助?
咨询建站