接手一个有一定规模的某后台管理系统项目时,我发现各业务组件里散落着几十处 axios.get、axios.post 的调用,每处都在重复处理错误提示、loading 状态、Token 过期跳登录这些逻辑。当时项目里有一个高频报表导出页面,用户频繁点击导出按钮,连续请求导致接口压力大、页面卡顿明显;另一个模块里,用户登录状态到期后总是跳回登录页,重新登录后的操作又莫名其妙失败。这些问题的根源,都指向同一件事:axios 的用法太原始,缺少一层统一封装的请求层。
所以这一篇,我围绕"封装 axios 请求库"这件事,把取消请求和刷新 Token 这两个最刚需的能力一起做进去。这两块属于日常开发中绕不开的硬需求:取消请求能解决组件卸载后的状态更新、重复提交、路由切换时的竞态问题;刷新 Token 能解决登录态过期后的静默续期,避免用户被频繁踢下线。适合正在做中后台项目、或者准备把项目里的请求代码好好收拢一下的同学参考,也适合刚接触前端工程化的新手理解拦截器、AbortController、响应状态码处理这些概念是怎么落地的。
1. 内容整体设计与思路拆解
1.1 为什么一定要做这层封装
先说个现象。很多同学写项目时直接这样用 axios:
// 某个页面里 axios.get('/api/user/list').then(res => { this.list = res.data }).catch(() => { this.$message.error('请求失败') })单个页面这么写没问题,但项目一旦超过 20 个接口、5 个以上页面,问题就暴露了:
重复代码爆炸。每个请求都要手动处理错误提示,都要手动判断 Token 是否存在,都要手动拼接公共参数。某天后端说"公共参数里加一个 platform 字段",你得把所有页面里的 axios 调用全部改一遍。
错误处理混乱。有的接口报错要弹全局提示,有的接口报错只需要在页面里静默处理,有的接口报错要重新登录。如果这些逻辑散落在各个组件里,排查问题的时候基本靠全局搜索。
完全没法做统一治理。接口超时时间、请求取消、Token 过期自动刷新,这些东西没有统一出口就无从下手。你总不能要求每个业务组件都自己写一遍 AbortController 吧。
所以封装的核心目标不是"少写几行代码",而是给整个项目的网络层提供一个统一出口。所有请求都从这个出口进出,下游业务通过配置项表达诉求,上游统一切面做拦截、处理、重试、取消。
1.2 核心需求拆解:三个必须解决的问题
我接手这个项目时梳理了一下诉求,发现其实就三个点:
第一,统一请求出入场和错误处理。请求之前自动带 Token、自动做 Loading 计数、自动过滤无效参数;拿到响应之后统一解包、统一处理业务状态码。
第二,支持取消请求。页面切换时自动取消上个页面的未完成请求;用户重复点击提交按钮时只保留最后一次请求;组件卸载后不再响应任何请求结果。
第三,Token 过期自动刷新。Access Token 过期后,用一个 Refresh Token 去换新的 Access Token,期间挂起的业务请求排队等待,刷新成功后自动重放。用户无感知,不会突然跳回登录页。
这三个需求其实是有层级的。第一点是基础设施,第二点和第三点是在第一点之上叠加的横切能力。所以封装的时候我也按这个顺序来设计:先搭好实例和拦截器骨架,再加上取消能力,最后做 Token 刷新。
1.3 技术选型:为什么还是选择 axios
这个项目本身已经用了 axios,正常情况没必要换。但如果是从零开始选型,我仍然会在 axios 和 fetch 之间考虑一下。
axios 的核心优势在于:拦截器机制非常成熟,request 和 response 两个拦截器可以处理绝大部分横切逻辑;取消请求有 AbortController 和 CancelToken 两套方案,兼容性好;浏览器兼容性宽,老项目里不用做太多适配。
fetch 的优势是内置在浏览器里,不用额外引包,流式场景处理更直接。但是 fetch 没有拦截器,取消请求只能靠 AbortController,错误处理还要自己区分网络错误和业务错误。对于中后台管理系统这种以 JSON 接口为主的场景,axios 的成熟度和生态更好。
另外我还用了一个额外的小技巧:给 axios 实例挂载了自定义配置字段,比如skipAuthRefresh、retry这类标记。axios 的config对象允许携带任意自定义字段,这给很多横切逻辑提供了扩展口子。后面讲 Token 刷新的时候你会看到这个自定义配置的用武之地。
2. 取消请求的实现原理与实践
2.1 AbortController 和 CancelToken 怎么选
axios 取消请求有两条技术路线。
CancelToken 是 axios 旧版方案,用起来需要维护一个 cancelToken 实例,然后把 cancel 函数存起来。这个方案的问题在于,CancelToken 被标记为 deprecated,老版本虽然还能用,但新项目没有必要再踩这个坑。
AbortController 是浏览器标准方案,从 axios 0.22 开始可以原生支持。用法是创建一个 AbortController 实例,把signal传给请求配置,需要取消时调用controller.abort()。
我的建议是直接用 AbortController,代码更干净,而且符合浏览器标准,未来即使换请求库,这套思路也能迁移。下面封装里,我会给每个请求生成唯一的取消控制器,并统一存到一个 Map 里管理。
2.2 取消请求的三个典型场景
封装之前先确认一下到底哪些场景需要取消请求,避免把能力做出来又不知道往哪儿用。
场景一:组件卸载时取消。比如用户进入某个详情页,请求还没回来就点了返回。如果不取消,响应回来之后组件里还在执行this.detail = res.data,轻则内存泄漏,重则报错"Cannot set property on unmounted component"。Vue 3 里如果用<script setup>,还会出现组件已卸载但异步回调还在执行的状态混乱。
场景二:路由切换时清理上个页面请求。在列表页翻页、搜索、跳详情再返回,如果不取消,之前页面的请求会继续跑完,白白浪费带宽,还可能因为响应顺序错乱导致页面展示内容对不上。
场景三:重复提交防抖。用户快速点击"导出"按钮、连续触发搜索、提交表单时双击了确认,这些场景都只应该保留最后一次请求。
这里有个常见认知偏差:取消请求不等于防抖。防抖是延迟发出请求;取消请求是上一次请求已发出,但我要把它作废。实际使用时两者经常配合,比如输入框搜索:先用防抖控制发出节奏,再在发出前取消上一次未完成的搜索请求,双保险。
2.3 实现一个基于 AbortController 的取消管理
先看核心管理模块,我用一个 Map 来存储所有进行中请求的取消控制器。
// utils/cancelManager.js class CancelManager { constructor () { this.pendingMap = new Map() } /** * 为请求生成唯一的标识key * 这里用请求方法 + URL + 参数序列化后的字符串做key, * 确保同一个接口的重复请求可以被区分 */ generateKey (config) { const { method, url, params, data } = config const serializedParams = params ? JSON.stringify(params) : '' const serializedData = data ? JSON.stringify(data) : '' return [method.toUpperCase(), url, serializedParams, serializedData].join('&') } /** * 添加请求到pending列表 * 同一个key如果已经存在,说明有相同的请求还在进行中, * 默认行为是取消旧的、保留新的 */ addPending (config) { const controller = new AbortController() config.signal = controller.signal const key = this.generateKey(config) if (this.pendingMap.has(key)) { // 如果已存在同key请求,取消它 this.pendingMap.get(key).abort() this.pendingMap.delete(key) } this.pendingMap.set(key, controller) config.cancelKey = key return config } /** * 请求完成(成功/失败/取消)后从pending列表移除 */ removePending (config) { const key = config.cancelKey || this.generateKey(config) if (this.pendingMap.has(key)) { this.pendingMap.delete(key) } } /** * 对外提供手动取消所有请求的能力 * 路由切换时调用:router.afterEach(() => cancelManager.cancelAll()) */ cancelAll () { this.pendingMap.forEach((controller) => { controller.abort() }) this.pendingMap.clear() } } export default new CancelManager()这个管理模块有几个细节要注意。
generateKey的设计决定了请求去重的粒度。我在项目里遇到过这种情况:同一接口用不同参数请求,参数不同但 URL 相同,如果不把参数序列化进 key,就会误伤正常请求。所以 key 里一定要带 params 和 data 的序列化字符串。
addPending里做了同 key 时取消旧请求的处理。比如搜索框场景:第一次搜索请求还没回来,用户又输入了新的关键词发起了第二次请求,此时旧请求被取消,只留新的。这个逻辑在提交类场景中特别有用。
然后看如何在拦截器里接入这个管理器。
// utils/http.js import axios from 'axios' import { message } from 'ant-design-vue' import cancelManager from './cancelManager' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) // 请求拦截器 service.interceptors.request.use((config) => { // 是否启用取消功能,默认启用 if (config.enableCancel !== false) { // 这一步会挂载 signal,并记录到 pendingMap config = cancelManager.addPending(config) } return config }, (error) => { return Promise.reject(error) })这里我设计了一个自定义配置项enableCancel,默认开启取消管理,但允许单个请求关闭。比如某些接口需要在跳转页面后仍在后台执行(上传、下载类),那就在调用时传enableCancel: false。
响应拦截器里要在请求结束时移除记录。
service.interceptors.response.use((response) => { const config = response.config cancelManager.removePending(config) // 统一业务处理... return response.data }, (error) => { // 请求失败时也要移除记录 if (error.config) { cancelManager.removePending(error.config) } // 判断是否为主动取消 if (axios.isCancel(error)) { // 主动取消不提示错误,静默处理 return Promise.reject(new Error('cancelled')) } // 其他错误照常处理... return Promise.reject(error) })有个容易踩坑的点:请求被取消后,Axios 抛出的错误对象并不完全一样。如果你使用的是controller.abort(),错误类型是CanceledError;判断是否有axios.isCancel(error)即可。但如果请求因为超时被取消,错误类型是ETIMEDOUT,这个判断不了,得看error.code。所以业务代码里如果想要区分"主动取消"和"超时取消",要分别处理。
2.4 组件里怎么用取消请求
拦截器层面做完了,组件里要怎么触发取消呢?我提供了两个方式。
方式一:请求级别手动取消。比如上传大文件时用户点击了"取消上传",可以在对应的请求上下文里拿到控制器,调用abort()。
方式二:全局取消。组件卸载时、路由切换时调用cancelManager.cancelAll()。
具体使用场景我拿代码说话。某个列表页面,用户进入页面后请求数据,离开时取消:
<script setup> import { onMounted, onUnmounted } from 'vue' import cancelManager from '@/utils/cancelManager' import { getList } from '@/api/list' onMounted(async () => { try { const data = await getList({ page: 1 }) // 正常渲染数据 } catch (error) { // 如果是主动取消,不弹错误提示 if (error.message !== 'cancelled') { // 真实错误才提示 } } }) onUnmounted(() => { cancelManager.cancelAll() }) </script>又比如搜索防抖 + 取消旧请求,可以在请求层做,也可以在业务层做:
// 业务层写法,输入框 change 时 async function handleSearch () { // 先取消所有进行中的列表请求 cancelManager.cancelAll() // 再发新请求 listData.value = await getList({ keyword: keyword.value }) }这个写法有个隐含问题:cancelAll()把当前页面所有请求都取消了,如果页面上还有其他非列表请求正在进行,会被误伤。所以更精细的做法是给请求分组,或者直接用addPending里的同 key 取消逻辑。如果你的场景确实需要精细控制,建议在CancelManager里再增加一个cancelByUrl的方法,只取消指定 URL 的请求。
还有一个实践中的心得:取消请求后,组件里的 loading 状态一定要记得复位。很多人只关注到接口报错了要关 loading,但取消请求走的也是错误分支,如果不在 catch 里关闭 loading,用户就会看到页面一直转圈,而且这个 bug 很难排查,因为控制台并没有红色报错。
3. Token 自动刷新机制的实现
3.1 双 Token 机制与 401 处理流程
做 Token 刷新之前,先想清楚一个问题:项目的 Token 策略是啥?绝大多数中后台项目的标准做法是双 Token:
- Access Token:短期有效(比如 2 小时),每次请求头里带着,有效期短所以泄露风险可控。
- Refresh Token:长期有效(比如 7 天),只有刷新 Access Token 时才用它。
刷新流程是这样的:用户登录成功,后端返回两个 Token。Access Token 过期后,后端接口返回 401。前端拦截到 401 后,拿着 Refresh Token 去请求刷新接口,换取新的 Access Token,然后把这次 401 失败的请求重新发一遍。Refresh Token 也过期了,那就跳回登录页,让用户重新登录。
这里面有几个关键设计点需要强调:
刷新接口本身不能再触发 401 刷新逻辑,否则会死循环。解决方式是在刷新请求的配置里加一个标识别,比如skipAuthRefresh: true,头部的请求拦截器看到这个标识别就直接跳过 Token 校验。
刷新期间如果有多个并发请求都收到 401,不能让每个请求都去调一次刷新接口。这样会让后端收到一堆重复刷新请求,而且你自己也没办法保证多个请求都拿到最新的 Token。正确做法是做一个"刷新中"的锁,第一个 401 触发刷新,后面 401 的请求进入等待队列,刷新完成后统一重放。
刷新失败时要清理本地 Token 并跳登录,这时候不能再把已写入的请求重放出去。
3.2 封装请求队列与刷新逻辑
看具体实现。我在项目里是这么组织代码的。
// utils/http.js import axios from 'axios' import { message } from 'ant-design-vue' import cancelManager from './cancelManager' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) // 是否正在刷新token的标记 let isRefreshing = false // 需要重放的请求队列 let waitQueue = [] // 刷新token function refreshToken () { return axios.post('/api/auth/refresh', { refreshToken: localStorage.getItem('refreshToken') }).then(res => res.data) } // 重放请求 function retryRequest (config) { return service(config) } // 清空队列 function flushQueue (error) { waitQueue.forEach((item) => { if (error) { item.reject(error) } else { item.resolve() } }) waitQueue = [] isRefreshing = false }响应拦截器里处理 401:
service.interceptors.response.use((response) => { const config = response.config cancelManager.removePending(config) return response.data }, async (error) => { const { response, config } = error // 请求被取消或没有响应,直接交给上层处理 if (!response || axios.isCancel(error)) { return Promise.reject(error) } // 非401错误直接抛出去 if (response.status !== 401) { return Promise.reject(error) } // 刷新接口自身401,说明refreshToken也失效了,直接跳登录 if (config.skipAuthRefresh) { message.error('登录已过期,请重新登录') localStorage.clear() window.location.href = '/login' return Promise.reject(error) } // 如果没有refreshToken,也没法刷新了 const refreshToken = localStorage.getItem('refreshToken') if (!refreshToken) { localStorage.clear() window.location.href = '/login' return Promise.reject(error) } // 如果当前已经在刷新token了,就把请求加入队列等待 if (isRefreshing) { return new Promise((resolve, reject) => { waitQueue.push({ resolve, reject, config }) }) } // 发起刷新 isRefreshing = true try { const res = await refreshToken() const newToken = res.accessToken localStorage.setItem('accessToken', newToken) // 把所有等待的请求重放 waitQueue.forEach((item) => { // 重放前设置新的token item.config.headers.Authorization = `Bearer ${newToken}` resolve(item.config) }) return retryRequest(config) } catch (err) { flushQueue(err) message.error('登录已过期,请重新登录') localStorage.clear() window.location.href = '/login' return Promise.reject(err) } finally { isRefreshing = false waitQueue = [] } })这个实现的思路很清晰:收到 401 时先判断是否已经在刷新。如果在刷新,就先把请求挂到一个队列里,等刷新完成后再遍历队列,重新走一遍完整请求流程。如果不在刷新,就立即发起刷新,刷新成功后重放当前请求,同时把等待队列里的其他请求也重放掉。
这里有一个需要特别注意的细节:retryRequest(config)用的config是原始请求配置,但重放时config里的 headers 还是旧 Token,所以要在addPending阶段和重放阶段都确保 headers 带上新 Token。我在实现里通过在重放前手动覆盖item.config.headers.Authorization来解决。
如果刷新过程中 Refresh Token 也失效,后端会返回 401 或其他特定状态码,这时候队列里的所有请求都没有意义了,直接全部 reject,并清理本地 Token 跳转登录页。
3.3 请求拦截器里自动携带 Token
Token 刷新逻辑做完了,别忘了请求发出的第一步就要带 Token。这个在请求拦截器里做:
service.interceptors.request.use((config) => { // 跳过某些无需登录的接口(比如 login、refresh) if (config.skipAuth === true) { return config } const accessToken = localStorage.getItem('accessToken') if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}` } return config }, (error) => { return Promise.reject(error) })我一般习惯把"是否需要鉴权"做成一个配置项skipAuth,让登录、刷新验证码之类的接口可以直接跳过携带 Token,避免无意义的判断。
3.4 与请求取消的结合:刷新 Token 时取消旧请求的特殊处理
玩过这个组合的人才会遇到一个隐藏问题。
假设用户在一个页面上同时发出了 5 个请求,全部 401。此时第一个请求触发了 Token 刷新,其他 4 个进入了等待队列。刷新成功后,这 4 个等待中的请求会被重放。但问题来了:用户可能已经切换了路由。
按照前面的取消请求方案,路由切换时会执行cancelManager.cancelAll()。但这 4 个请求目前还在waitQueue里面,cancelAll 并没有取消它们,等到刷新成功后它们照样会被重放,哪怕页面已经不存在了。
这个问题的解法我在实际项目中用了两种:
方案一是把waitQueue里的请求也送入 CancelManager 管理。也就是在进入队列时,同时调用cancelManager.addPending(config),这样 cancelAll 也会把它们一并取消。
方案二是给flushQueue增加一个检查,重放前判断对应的组件是否还挂载着。这个判断在封装层不好做,所以我更推荐方案一。
代码上就是在waitQueue.push前增加一步:
if (isRefreshing) { cancelManager.addPending(config) return new Promise((resolve, reject) => { waitQueue.push({ resolve, reject, config }) }) }刷新成功后重放这些请求,重放时cancelManager.removePending会在新的响应拦截器里被调用,不会造成 Map 泄漏。
这个细节的重要性在于:如果不处理,Token 刷新完成那一刻往往是用户已经离开页面的时刻,被重放的请求会白白消耗资源,甚至因为响应回来更新了已卸载组件的数据,造成状态污染。加了取消管理之后,这个问题就彻底堵住了。
4. 核心封装完整落地与业务接入
4.1 完整请求库代码整合
把上面的模块整合成一个完整的http.js,我贴一个我在项目里实际使用的简化版,方便你直接搬。
// utils/http.js import axios from 'axios' import { message } from 'ant-design-vue' import cancelManager from './cancelManager' const service = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) // ===================== 请求拦截器 ===================== service.interceptors.request.use((config) => { // 1. 携带token if (config.skipAuth !== true) { const accessToken = localStorage.getItem('accessToken') if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}` } } // 2. 注册取消管理(默认开启,通过 enableCancel 可关闭) if (config.enableCancel !== false) { config = cancelManager.addPending(config) } return config }, (error) => Promise.reject(error)) // ===================== 响应拦截器 ===================== service.interceptors.response.use((response) => { cancelManager.removePending(response.config) const res = response.data // 后端业务码为200表示成功,其他状态码都为业务异常 if (res.code !== 200) { message.error(res.message || '业务处理失败') return Promise.reject(new Error(res.message || '业务处理失败')) } return res.data }, async (error) => { const { response, config } = error // 主动取消的请求静默处理 if (axios.isCancel(error)) { return Promise.reject(new Error('cancelled')) } // 没有响应(网络错误/超时)直接抛出 if (!response) { message.error('网络连接异常,请稍后重试') return Promise.reject(error) } // 401进入token刷新逻辑 if (response.status === 401) { // 刷新接口自身401,跳转登录 if (config.skipAuthRefresh) { message.error('登录已过期,请重新登录') localStorage.clear() window.location.href = '/login' return Promise.reject(error) } const refreshToken = localStorage.getItem('refreshToken') if (!refreshToken) { localStorage.clear() window.location.href = '/login' return Promise.reject(error) } if (isRefreshing) { cancelManager.addPending(config) return new Promise((resolve, reject) => { waitQueue.push({ resolve, reject, config }) }) } isRefreshing = true try { const { accessToken } = await refreshToken() localStorage.setItem('accessToken', accessToken) waitQueue.forEach((item) => { item.config.headers.Authorization = `Bearer ${accessToken}` cancelManager.removePending(item.config) item.resolve() }) waitQueue = [] config.headers.Authorization = `Bearer ${accessToken}` return retryRequest(config) } catch (err) { waitQueue.forEach((item) => { cancelManager.removePending(item.config) }) waitQueue = [] message.error('登录已过期,请重新登录') localStorage.clear() window.location.href = '/login' return Promise.reject(err) } finally { isRefreshing = false } } // 其他HTTP错误码统一提示 if (response.status === 403) { message.error('没有操作权限') } else if (response.status >= 500) { message.error('服务器异常,请稍后重试') } return Promise.reject(error) }) function refreshToken () { return axios.post('/api/auth/refresh', { refreshToken: localStorage.getItem('refreshToken') }).then(res => res.data) } function retryRequest (config) { return service(config) } // 导出统一的请求方法 export function request (config) { return service(config) } export function get (url, params, config = {}) { return service({ url, method: 'get', params, ...config }) } export function post (url, data, config = {}) { return service({ url, method: 'post', data, ...config }) } export default service4.2 业务 API 封装示例
有了统一的请求库,业务 API 就可以封装成一个个模块,页面里不再出现 axios 字样。
// api/user.js import { get, post } from '@/utils/http' // 获取用户列表 export function getUserList (params) { return get('/api/user/list', params) } // 新增用户 export function addUser (data) { return post('/api/user/add', data) } // 导出用户报表(这个接口比较重,关闭取消管理避免误伤) export function exportUserReport (params) { return get('/api/user/export', params, { enableCancel: false, timeout: 120000 }) }页面里的用法变成:
import { getUserList } from '@/api/user' const res = await getUserList({ page: 1, size: 10 })这样切面逻辑全部下沉到请求库,页面只关注数据本身。
4.3 路由切换时自动清除未完成请求
为了做到"路由切换自动取消请求",不需要在每个页面里手动调cancelAll(),可以在全局路由守卫里做。
// router/index.js import router from './router' import cancelManager from '@/utils/cancelManager' router.afterEach(() => { cancelManager.cancelAll() })注意这里我用的是afterEach,而不是beforeEach。原因是:beforeEach里取消的话,新页面还没开始渲染,旧页面的组件还处于激活状态,此时取消请求会导致组件卸载流程中访问了 pendingMap,但有些请求还没来得清理干净。而afterEach在新路由已经确认后触发,此时旧组件即将卸载或已经进入卸载流程,取消请求是安全的。
实际项目里用afterEach和onUnmounted配合的情况比较多。全局afterEach管大部分场景,个别页面如果有独立于页面的跨请求(比如弹窗还开着,只是 url 变了),那就在组件卸载时自己补充取消。
5. 常见问题与排查技巧实录
5.1 取消请求之后状态管理混乱
很多同学在组件里碰到这样场景:发起请求后组件卸载了,结果接口还是返回了数据,然后页面里this.detail = res.data报错了。
排查思路:
一是确认是否真的走了取消逻辑。在请求拦截器里打印一下config.signal,看看是否挂载成功。
二是检查enableCancel是否被业务配置关闭了。如果某个接口传了enableCancel: false,组件卸载时就不会被取消,自然会触发状态更新。
三是检查生命周期函数里有没有清理 Loading 状态。取消请求走的是 catch 分支,如果你原来的代码只在成功分支里关 loading,卸载时 loading 永远不会关。
5.2 同时多个请求 401 导致重复刷新 Token
这是最常见的问题。如果后端某个时间点集中返回 401,而你每个请求都各自触发一次刷新 Token,后端收到大量重复刷新请求,轻则告警,重则触发限流或封号。
排查思路:看网络请求里同时出现了多少个/auth/refresh。是多个就说明isRefreshing锁没有生效。
常见原因有:
isRefreshing变量作用域不对,每个请求都初始化了一份。- 刷新逻辑里 try-catch 后忘了把
isRefreshing复位,后续 401 永远进不了刷新分支。 - 等待队列里的请求根本没有等,而是直接执行了
retryRequest,相当于每个请求各自发起刷新。
我给一个快速检查的板子:在刷新逻辑入口加一个计数器,如果同一时间进入刷新分支超过一次,就说明锁没锁住。
5.3 刷新 Token 期间取消请求从未完成队列中移除
这个问题前面提过了,就是waitQueue中的请求在cancelAll时不生效。如果你用的是方案一,把队列请求也注册到了 CancelManager,那就不存在这个问题。但如果没处理,会出现一个现象:页面都跳转了,Token 刷新成功后,控制台还是发起了几个原本不该存在的请求。
排查思路:在cancelAll里加一行调试日志,打印每次被取消的请求的 cancelKey。然后对比一下waitQueue里的请求有没有出现在日志里。没出现就说明没接上取消管理。
5.4 刷新 Token 后重放的请求 still 带着旧 Token
这种情况多见于网络层重放时,config.headers没有更新。
我的习惯封装做法是,在重放时统一替换config.headers.Authorization,而不是只改某几个示例。同时要注意 axios 内部可能在某些场景对 headers 做了合并,直接config.headers.Authorization = ...有时候不生效,更稳妥的是用:
config.headers.set('Authorization', `Bearer ${accessToken}`)如果你用的是 axios 1.x,header 对象内置了set方法,推荐用这个方式,避免 header 属性合并的问题。
5.5 接口返回 200 但业务 code 非 200 时怎么处理
很多后端接口 HTTP 状态码永远返回 200,只有 body 里的 code 字段表示业务状态。我在响应拦截器里对res.code !== 200做统一弹错。
但这里有个实际坑:某些接口的 code 值设计不统一。有的地方成功是 0,有的地方成功是 200,有的地方是"0000"。如果你直接把 code 写死在封装里,碰到别的项目就得改。我建议把状态码常量抽成配置,或者至少加个注释,说明当前项目的业务码约定。
另一个注意事项:大文件导出接口通常返回的是 Blob 类型,此时 res.code 根本不存在,直接把整个 response 返回给业务层处理。这个也需要在响应拦截器里做类型判断。
// 处理blob类型的响应 if (response.request.responseType === 'blob' || response.data instanceof Blob) { return response.data }5.6 请求超时与取消如何区分
axios 的timeout超时后抛出的错误,在error.code === 'ECONNABORTED';主动取消是axios.isCancel(error)为 true。这两个不能混在一起。
实践中出现的一个问题是:用户以为点击了"取消"按钮,请求应该被取消,但实际上请求是因为超时挂掉了,页面里依然会走到超时错误提示分支,给用户造成了困惑。
处理方式是在调用层约定好:如果错误原因是主动取消,不弹任何提示;只有非主动取消的请求才弹错误提示。
service.interceptors.response.use(..., (error) => { if (axios.isCancel(error)) { // 主动取消,静默处理 return Promise.reject(new Error('cancelled')) } if (error.code === 'ECONNABORTED') { message.error('请求超时,请稍后重试') } return Promise.reject(error) } )5.7 刷新 Token 时页面被强制跳转登录页
可能的原因有好几个:
- Refresh Token 也过期了。
- 后端刷新接口要求的 Header 或参数格式不对。
skipAuthRefresh标识别没有正确传给刷新接口,导致刷新请求自身进入 401 分支。
排查方式:在刷新失败的 catch 里把错误信息打出来,看看后端到底返回了什么状态码和错误描述。别只看页面跳转的结果,网络面板里的刷新请求响应才是真相。
5.8 测试环境共用同一套 Token 导致 401 状况不间断
多人共用后端测试环境时,经常遇到一个人登录了,另一个人把这个账号踢下线,导致其他人请求全部 401。这不是你封装的问题,而是登录策略的问题。排查时先确认是不是只有自己复现,如果是,看看是不是 Token 被其他设备挤掉了。
5.9 一个经验:取消请求的"度"
取消请求不是越多越好。有些场景下取消请求会造成"请求刚要返回、组件才刚挂载"的竞态。比如你通过路由进入了详情页,详情页发出请求,但用户很快从列表页跳过来覆盖了路由,旧详情页的请求被取消。此时用户可能确实想看旧详情页的数据,但因为路由守卫的afterEach直接把请求取消掉了,数据永远回不来。
解决办法:对于可恢复的数据加载,我倾向于用"忽略过期响应"而不是"取消请求"。也就是给请求加一个请求 ID,响应回来后检查 ID 是否还是最新,不是就不处理。但对于重复提交、导出、老旧页面清理这类场景,取消请求则非常合适。
所以封装时我保留了enableCancel配置项,并在业务中做了一个约定:
- 列表加载/搜索请求:默认取消。
- 详情页获取数据:默认取消。
- 提交/删除操作:默认取消(防重复点击)。
- 导出/上传/下载:默认不取消。
- 需要忽略过期响应但不想真正取消的请求:可以手动传
enableCancel: false并在业务层做判断。
6. 用了一段时间后的心得与后续扩展
这套封装在项目里跑了一段时间,整体稳定性还是有保障的。我个人在实际操作中的体会是,最容易被忽略的不是写代码时的功能,而是取消请求和 Token 刷新两者组合时的那几个边界情况,比如刷新期间路由跳转、等待队列里的请求被取消、还有响应拦截器里对不同错误类型的静默处理。把这些边界想清楚,封装才算是真正可用。
最后再分享几个小技巧。
第一,封装做好之后,建议把enableCancel、skipAuth、skipAuthRefresh这几个自定义配置的默认值和行为写进团队文档,否则后续同事碰到这些配置会一头雾水,不知道为什么会冒出这些字段。
第二,Token 刷新失败跳转登录页时,记得把当前页面的地址存下来,登录成功后回到原来的页面而不是永远落在首页。这个体验细节很多项目都忽略了。实现方式很简单:
const redirect = encodeURIComponent(window.location.href) window.location.href = `/login?redirect=${redirect}`第三,如果你用的是 Vue 3 Composition API,可以考虑把请求状态做成一个通用的 composable,比如useRequest,把 loading、error、data 这些状态集中管理。请求库负责网络层,composable 负责 UI 状态,两者配合会非常顺手。
这个内容后续还可以这样扩展:给 CancelManager 增加分组能力,按页面维度管理请求取消;给 Token 刷新加上重试次数上限,防止极端情况下无限重试;也可以把错误上报集成进来,401、500、网络异常自动打点,方便排查线上问题。其实把这些做完,你的请求层就已经非常接近大型团队里基础设施的形态了。