news 2026/11/6 16:08:40

axios请求封装实战:取消请求与Token刷新双能力落地

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
axios请求封装实战:取消请求与Token刷新双能力落地

接手一个有一定规模的某后台管理系统项目时,我发现各业务组件里散落着几十处 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 service

4.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、网络异常自动打点,方便排查线上问题。其实把这些做完,你的请求层就已经非常接近大型团队里基础设施的形态了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 11:13:33

AI芯片设计中的黑盒问题与可观测性实践

1. 这句话不是危言耸听&#xff0c;而是芯片工程师每天面对的现实“AI 在芯片领域依然是个黑盒”——这句话最近在多个技术社区和内部分享会上反复出现&#xff0c;不是调侃&#xff0c;也不是抱怨&#xff0c;而是某芯片设计公司前端验证团队负责人在一次跨部门对齐会上脱口而…

作者头像 李华
网站建设 2026/11/1 23:14:22

CST电磁仿真场分布数据导出与MATLAB后处理全流程指南

简介&#xff1a;电磁仿真CST场分布MATLAB后处理代码定位于CST仿真后的数据可视化工具&#xff0c;主要面向天线设计、微波器件分析等场景的电磁工程师与相关专业学生&#xff0c;用于解决场监视器导出的ASCII数据难直接解读的问题。压缩包内包含1个.m脚本文件&#xff0c;整体…

作者头像 李华
网站建设 2026/10/19 23:27:18

Android 10+ 系统级 Hook 方案:EdXposed-YAHFA 原理与实战

简介&#xff1a;本资源是面向Android系统深度定制爱好者的Magisk模块——EdXposed-YAHFA v0.4.6.0 beta 4471&#xff0c;专为安卓10&#xff08;含小米Note2等已验证机型&#xff09;设计&#xff0c;解决新版系统下Xposed框架兼容性难题&#xff0c;提供无需修改boot镜像、支…

作者头像 李华
网站建设 2026/10/23 22:35:25

MySQL多表联合查询优化:从执行计划到索引设计的实战指南

简介&#xff1a;MySQL多表联合查询效率分析及优化是一份面向数据库开发者与运维人员的PDF技术文档&#xff0c;旨在解决多表关联查询中结果集膨胀、响应缓慢等常见问题。资源系统梳理了笛卡尔积、内连接、左/右外连接等连接类型的工作原理与适用场景&#xff0c;并结合实际案例…

作者头像 李华
网站建设 2026/10/24 11:03:24

Android骨架屏从设计到上线的完整实战指南

刚接手一个项目&#xff0c;发现冷启动和列表加载时白屏时间特别长&#xff0c;用户流失率明显上去了。后来我把骨架屏方案落地进去&#xff0c;效果立竿见影——不是玄学&#xff0c;是实实在在的感知性能优化。这篇就把我在Android端做骨架屏从设计到上线的完整思路、踩坑记录…

作者头像 李华
网站建设 2026/10/21 13:09:05

高数中值定理与泰勒公式期末复习:辅助函数构造与展开策略

1. 复习内容整体设计与思路拆解期末复习到了第五天&#xff0c;基本上已经进入中值定理和泰勒公式这块硬骨头了。如果你是从第一天极限、第二天导数、第三天不定积分、第四天定积分一路跟过来的&#xff0c;那今天的内容算是把前面所有工具串起来用——罗尔定理、拉格朗日中值定…

作者头像 李华