axios请求层封装:取消请求与token刷新的实战设计
前阵子接手一个老项目中后台项目代码里到处都是axios.get(...).then(...)每个页面自己往 headers 里塞 token401 一出现就弹出一连串报错最要命的是用户手抖点两下提交按钮后台能生成两条重复订单。单靠零散地修某个页面这类问题永远修不干净。后来我把 axios 请求层整体封装了一遍核心就两个能力取消请求和刷新token。这篇就聊聊这套封装的设计思路、核心代码以及我在实际落地时踩过的几个比较隐蔽的坑。适合正在维护 Vue 中后台项目、有一定 axios 使用经验但想升级封装能力的同学参考。1. 先说清楚封装层的边界axios本身不背这些锅很多朋友一听到封装 axios就以为是在外面包一层函数、统一设置一下 baseURL 就完事了。实际上真正值得做的是把鉴权、取消、重试、错误处理这些横切逻辑收敛到一个请求库里业务代码只关心我要调这个接口其余都不管。1.1 裸用axios时最头疼的三个场景第一个场景鉴权逻辑散落。用户登录后拿到 token每个页面调接口时都要手动加一下请求头。有的页面写在 axios 实例的 interceptors 里有的页面直接在业务代码里改config.headers。某天登录态从 localStorage 换成 cookie 存储前端代码得全局搜一遍Authorization改到怀疑人生。第二个场景竞态和重复提交。搜索框里输入关键词用户手速快一点连续发出去两个请求第一次的响应比第二次晚回来于是页面被旧数据覆盖。再比如提交按钮没有加 loading用户点了一下没反应又点一下后端收到两条一模一样的订单。这类问题属于典型的请求层能解决但业务层经常忘了处理的痛点。第三个场景token过期后的并发401爆炸。token 有效期到了以后页面上正在发的一批请求陆续返回 401。如果每个请求都自己去刷新 token等于同一时间十几条 refresh 请求打到后端。后端一般只认第一条其余的要么失败要么重复刷新最后直接把 refreshToken 也搞失效用户好好的就被踢下线了。这三个场景没有一个是 axios 本身的 Bug但如果不做一次统一封装它们会一直以各种形态反复出现。1.2 请求库的职责清单与设计红线我最终落地时把请求库的职责收敛成下面这几条职责说明统一鉴权注入所有请求自动携带 token业务层无感知统一错误处理网络错误、业务错误、401 各走各的分支取消请求能力路由切换、组件卸载、防重复提交时能主动中止请求并发刷新token401 出现时只允许一个刷新请求在飞其余排队重放登录失效兜底刷新失败统一清凭证、踢出登录态、跳转登录页同时我也给自己划了几条红线请求库内部不直接 import 状态管理库。比如不直接引入 store 去拿 token、去调登出动作否则以后一旦 store 模块改名或循环依赖整个请求层就崩了。正确做法是外部把getToken、setToken、onLogout这个几个函数注入进来请求库只负责调用。不写死业务状态。哪些错误码需要重试、哪些错误码需要静默处理由配置项决定不在请求库里 if 死。保留业务方自由度。通过config.meta或自定义字段让单个请求可以覆盖全局默认行为比如这个接口不要走自动刷新。边界想清楚之后代码写起来才有章法。我后面发现很多请求库写得乱不是语法不行是因为什么都往里塞最后自己都理不清。2. 取消请求能力用请求管理器统一管理待取消状态取消请求这件事单看 API 很简单的但实际用起来最大的麻烦是取消函数散落在各个组件里。页面 A 里创建了一个 cancelToken页面 B 想取消它做不到。所以我做一个请求管理器用 Map 把每个还在途中的请求统一管起来。2.1 选型的纠结CancelToken还是AbortControlleraxios 早年提供了 CancelToken 方案用法是axios.CancelToken.source()拿到cancel函数和token。axios 0.22 之后又支持了signal配置底层用的是浏览器标准的AbortController。老项目里如果还在用 axios 0.x你可以看到不少代码是new axios.CancelToken(c cancelFn c)这种写法。axios 1.x 至今仍然兼容 CancelToken但官方已经把它标记为不推荐新项目建议直接走AbortController。这两者怎么选我的建议是新项目直接用signalAbortController它是 Web 标准axios 只是在配置项里透传未来即使更换请求库取消逻辑也可以复用。老项目能升 axios 就升升到 1.x 之后统一改 signal升不了就先把 CancelToken 收口到请求管理器里不要让业务页面直接去 new。还有一个容易被忽略的点一个AbortController可以同时控制多个请求比如用户切换了页面我把这个页面相关的所有接口全部取消用一个 controller 就能实现。CancelToken 反而要做一些额外管理。所以我全项目都改成了 signal。选择 signal 之后在 axios 的请求拦截器里给每个请求都挂一个自己的AbortControllerconst controller new AbortController() config.signal controller.signal这样做的目的不是为了取消时立刻断网而是让谁发起的请求、谁能取消它有一个明确的归属。取消动作发生时axios 会 reject 一个CanceledError通过axios.isCancel(error)可以识别这部分逻辑放到后面的拦截器里统一处理。2.2 核心实现请求管理器与Map的增删时机请求管理器其实就是一个聪明的 Map。key 用来唯一标识一个请求value 存放对应的AbortController。关键点有两个key 怎么生成和什么时候删除。我最终实现的 RequestManager 长这样class RequestManager { constructor() { this.pendingMap new Map() } // 用 method url 参数 拼出稳定的请求标识 getKey(config) { const { method, url, params, data } config const methodKey method.toLowerCase() const paramsKey JSON.stringify(params ?? {}) const dataKey JSON.stringify(data ?? {}) return [methodKey, url, paramsKey, dataKey].join() } add(config, controller) { const key this.getKey(config) // 如果配置了 cancelLast遇到相同请求时先取消旧的 if (config.cancelLast this.pendingMap.has(key)) { this.pendingMap.get(key).abort() } this.pendingMap.set(key, controller) } remove(config) { const key this.getKey(config) this.pendingMap.delete(key) } has(config) { return this.pendingMap.has(this.getKey(config)) } abort(config) { const key this.getKey(config) if (this.pendingMap.has(key)) { this.pendingMap.get(key).abort() this.pendingMap.delete(key) } } abortAll() { this.pendingMap.forEach((controller) controller.abort()) this.pendingMap.clear() } } export const requestManager new RequestManager()这里有个很重要的习惯请求成功或者失败后一定要从 Map 里 remove否则这个 key 永远不会消失既浪费内存也可能造成后续判断是否有相同请求在途时误判。我在响应拦截器的成功和失败分支里都会调用requestManager.remove(config)。为什么 key 是 method url 序列化参数因为一个相同的接口method 和 url 一样但参数不同本质上是两个请求。比如/api/list?page1和/api/list?page2它们不该互相取消。但如果连参数都一样那基本可以判定就是同一个请求可以走防重或替换逻辑。2.3 在页面卸载、路由切换、按钮防重三个场景里怎么用请求管理器做出来后三个高频场景都能很优雅地接上。场景一组件卸载时取消。Vue 3 组合式 API 里我直接在onUnmounted钩子里调用abortAll或者更精准一点只取消该请求自己的 controller。比较粗的设计是全页面共享一个 manager页面跳走了就把全屏的 pending 请求取消如果某些请求想在跳转后继续跑可以加一个配置项跳过取消。实际用下来我倾向于把取消能力收敛到一个useRequest里让每个业务调用自己持有 controller卸载时自动取消自己的请求这样比全局 cancelAll 更容易控制。场景二路由切换。中后台经常出现列表页请求还没返回用户就切到详情页的情况。全局做法是在router.beforeEach里判断当前路由要离开时调用请求管理器的abortAll()把上个页面尚未完成的接口直接掐掉。但是要小心一个时序问题beforeEach 里 cancelAll 之后新页面紧接着发起的请求不能也被取消。所以 cancelAll 一定要放在新路由确认之前并且新页面的请求是在路由进入后才发起的两者没有交集实际不受影响。场景三按钮防重。我遇到的最爽快的方案不是禁用按钮而是允许用户点但如果相同 key 的请求还挂在 pendingMap 里就把上一个 abort 掉、重新发起新的。这个动作在拦截器里做// 请求配置里带 cancelLast: true 时自动取消上一个相同请求 requestManager.add(config, controller)这样用户在切换 Tab 查询或者快速点击筛选条件时永远只有最新的一个请求生效不会出现旧响应覆盖新响应。UI 上的 loading 还是建议业务自己控制请求库负责把重复请求这个竞态源头掐掉。还有一个容易踩的坑是取消请求也走 error 分支。axios 对取消的请求会 reject如果你在拦截器里把一切 reject 都当作业务失败去 toast用户快速切页时就会看到一堆请求失败的提示。所以拦截器第一件事就是判断if (axios.isCancel(error)) { return Promise.reject(error) }判断取消后直接返回 reject不要做任何弹窗也不要打进埋点系统。这个分支必须单独放在最前面。3. token过期并发刷新标志位加等待队列的正确姿势刷新 token 的逻辑表面上看很简单401 之后调 refresh 接口拿新 token再重放原请求。但它真正难的地方在于并发控制。一个页面多个接口同时返回 401 时你要做的是只发一次刷新请求然后把其他请求挂起来等结果。3.1 为什么并发401会演化成刷新风暴想象一个后台管理页面用户进入时并行发起了 5 个接口。此时 token 恰好过期5 个接口几乎同时返回 401。如果你在响应拦截器里看到 401 就去 refresh那瞬间会有 5 条 refresh 请求冲向后端。刷新接口通常是一次性的后端收到第一条刷新请求后会把旧的 refreshToken 作废并签发新 token后面再来的刷新请求拿着已经作废的 refreshToken轻则失败重则直接把用户的登录态标记为异常。最后的结果是用户被强制登出而且看起来毫无征兆。要解决这个问题关键是让刷新动作变成单飞状态其余请求等待。3.2 单飞刷新与请求重放关键代码拆解下面是整个请求库里最核心的一段逻辑。我用isRefreshing标志位表示已经有一个刷新请求在途用waitingQueue数组保存等待重放的请求。let isRefreshing false let waitingQueue [] async function handleUnauthorized(config) { // 如果已经在刷新 token直接排队等待 if (isRefreshing) { return new Promise((resolve) { waitingQueue.push({ config, resolve }) }) } isRefreshing true try { // 这里只发一次刷新请求 const { token } await refreshTokenApi() setToken(token) // 刷新成功后把排队中的请求全部重放 waitingQueue.forEach(({ config: queuedConfig, resolve }) { resolve(instance.request({ ...queuedConfig, __isRetry: true })) }) waitingQueue [] // 当前这个请求自己也重放一遍 return instance.request({ ...config, __isRetry: true }) } catch (refreshError) { // 刷新失败统一登出 handleLogout() return Promise.reject(refreshError) } finally { isRefreshing false } }有几个细节值得展开。细节一进来的 401 一定要先检查isRefreshing再决定是排队还是发起刷新。如果检查和发起刷新之间没有先置标志位其它并发的 401 会都以为没人刷新各自发起 refresh。所以isRefreshing true必须放在任何异步操作之前保证第一个 401 进来后就霸占刷新权。细节二队列里存的不是 config而是 resolve 回调。因为业务方还在等这个请求的 Promise 结果重放完成后要正确地把结果交给原来的调用方。如果你只是把 config 存起来刷新后没人再 resolve 它业务代码会永远卡在 pending 状态。细节三重放请求时不能死循环。重放后的请求如果又返回 401说明新 token 也失效了这时应该直接走登出逻辑而不是再次进入刷新分支。我在 config 上加了一个__isRetry标记响应拦截器里判断如果__isRetry true并且还是 401就直接走登出。3.3 刷新失败时要做的清理动作刷新 token 失败最常见的原因是 refreshToken 本身也过期了。此时再重试也没有意义正确流程是清空本地 token 和用户信息通知全局事件比如emit(auth:logout)跳转到登录页带上当前地址以便登录后回跳把 waitingQueue 清空让等待中的请求 reject 掉避免挂起这里有一个多次触发跳转的坑并发 401 一批进来刷新失败后每个请求的 catch 里都调了一次跳转登录。页面可能会被重复 push 好几次或者出现路由警告。解决办法是加一个isLoggingOut标志位只在第一次进入登出流程时执行跳转和清空动作其它调用只 reject。3.4 放在重放队列里的请求也要尊重已被取消这是很多教程不会提到、但实战中一定会遇到的问题用户在等待刷新 token 的过程中已经开始快速切换页面之前的请求已经被请求管理器取消了。可是刷新成功后等待队列还是会把这些请求重放出来结果就是用户压根不在那个页面了数据却莫名被打回来。我的做法是在进入等待队列之前把请求的取消状态记录到requestManager里刷新成功准备重放时先检查requestManager.has(queuedConfig)如果这个 key 已经不在 Map 中说明请求已经被取消或已经完成跳过重放。waitingQueue.forEach(({ config: queuedConfig, resolve }) { if (!requestManager.has(queuedConfig)) { resolve(Promise.reject(new Error(request canceled))) return } resolve(instance.request({ ...queuedConfig, __isRetry: true })) })这样就把取消请求和刷新token两个能力协同起来了而不是各管各的。4. 一份可直接落地参考的完整封装与页面接入方式前面把两个核心能力拆开讲清楚了这一节给出一份完整的参考实现并把页面接入方式也列出来。代码风格偏向 Vue 3 组合式 API但核心请求文件其实框架无关。4.1 请求库文件完整结构与主要代码目录结构很简单src/ api/ request.js // 请求库本体 auth.js // 登录、刷新token的接口 modules/ // 业务接口模块request.js 完整参考import axios from axios import { getToken, setToken, clearAuth } from ./auth const instance axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }) // 请求管理器 class RequestManager { /* 与上文一致略 */ } export const requestManager new RequestManager() // 是否在刷新token let isRefreshing false let waitingQueue [] // 请求拦截器 instance.interceptors.request.use( (config) { const token getToken() if (token) { config.headers.Authorization Bearer ${token} } const controller new AbortController() config.signal controller.signal // 默认同一个请求如果还在途直接取消上一个 if (config.meta?.cancelLast ! false) { requestManager.add(config, controller) } return config }, (error) Promise.reject(error) ) // 响应拦截器 instance.interceptors.response.use( (response) { requestManager.remove(response.config) // 这里可以统一解包后端 data return response.data }, (error) { // 1. 取消的请求不做任何处理 if (axios.isCancel(error)) { return Promise.reject(error) } const config error.config || {} requestManager.remove(config) // 2. 401 处理 if (error.response?.status 401) { // 防止刷新token接口自己也进入401刷新分支 if (config.url.includes(/auth/refresh)) { clearAuth() return Promise.reject(error) } // 重试后的请求还是401直接登出 if (config.__isRetry) { clearAuth() return Promise.reject(error) } // 等待队列中的请求如果已被取消不再重放 if (!requestManager.has(config)) { return Promise.reject(new Error(request canceled)) } return handleUnauthorized(config) } // 3. 其他错误统一处理比如网络错误、业务错误码 return Promise.reject(error) } ) export default instance上面的代码看起来不长但把前面聊到的所有机制都收进去了。有一个点需要特别注意handleUnauthorized里重放请求时不要再走一遍请求拦截器因为那时 token 已经更新了但拦截器里getToken()读到的是新值所以这种担心其实多余。真正要避开的是不要给重放请求重新挂一个新的 AbortController 覆盖掉原有 signal否则业务侧拿到的 controller 就不是原来那个了。我的处理是重放时不做额外改造直接复用 config。4.2 基于组合式API的useRequest用法为了在页面里用得更顺手我写了一个useRequest组合式函数主要解决三个问题loading 状态声明、组件卸载时自动取消、以及避免竞态覆盖。import { ref, onUnmounted } from vue import { axios } from axios export function useRequest(requestFn, options {}) { const loading ref(false) const error ref(null) const data ref(null) let controller null async function run(...args) { loading.value true error.value null // 取消上一次未完成的请求 if (controller !controller.signal.aborted) { controller.abort() } controller new AbortController() try { const result await requestFn(...args, { signal: controller.signal }) data.value result return result } catch (e) { if (!axios.isCancel(e)) { error.value e } throw e } finally { loading.value false } } function cancel() { controller?.abort() } onUnmounted(cancel) return { data, loading, error, run, cancel } }页面里的搜索场景就能写得很干净const { data, loading, run } useRequest((signal) api.get(/list, { params: { keyword }, signal, meta: { cancelLast: true } }) ) function onSearch() { run() }用户每次点搜索如果上一次还没返回会被自动取消只留真正最后一次请求。组件卸载时请求也会被取消不会再有页面都关了console 还在打印接口日志的情况。4.3 与路由守卫、登录状态的协作细节请求库本身不该知道路由的存在但路由守卫可以借用请求管理器做取消动作。我习惯在router.beforeEach里做两件事router.beforeEach(async (to, from, next) { // 离开旧页面前取消旧页面未完成的请求 requestManager.abortAll() // 如果需要登录态的页面没有 token直接踢去登录页 if (to.meta.requiresAuth !getToken()) { next({ path: /login, query: { redirect: to.fullPath } }) return } next() })登录态失效的全局提示我选择用事件发布器而不是直接跳转路由。原因是请求库里如果import router会形成请求库依赖路由的循环依赖而且刷新 token 失败时可能同时有多个 reject跳转只应该在统一收口的地方做一次。我在封装的 auth 模块里维护了一个轻量事件订阅登录失效时触发emit(auth:logout)登录页模块自己监听这个事件去执行跳转边界很干净。5. 落地半年后总结的几条避坑经验功能开发的时候代码很容易自洽但真正上线跑一段时间各种边界问题才会浮出水面。下面这几条是我在维护这套请求库时总结出来的每一条都对应过真实事故。5.1 拦截器里的异常分支别混在一起处理响应拦截器的 error 回调里可能有四种完全不同的情况请求被取消、网络错误、HTTP 状态码异常、业务错误码异常。它们的处理方式完全不同情况处理方式请求被取消直接 reject不弹窗、不埋点网络错误提示网络异常可考虑是否自动重试HTTP 401进入刷新token流程业务错误码读取后端返回的 code按业务约定处理我见过一个项目把所有异常都统一toast(error.message)结果用户取消请求时弹了一堆网络错误体验非常差。所以响应拦截器里分支判断的顺序很重要取消判断一定要放在最前面其次是刷新 token 的分支最后才是常规错误。5.2 请求key的序列化要稳定这是我踩过最隐蔽的坑。请求管理器通过 method url 序列化参数生成 key但如果你直接用JSON.stringify(params)同一个接口可能因为参数的顺序不同而生成两个 key。比如/api/list?page1size10和/api/list?size10page1从业务角度是同一个请求但字符串序列化后不一样。解决方案有两种一种是用第三方库做稳定序列化比如 qs 排序另一种是自己统一规则比如对 params 的 key 做排序后再序列化。我更推荐qs 的字符串化配置它能控制 key 排序而且和 axios 的 params 序列化规则一致。5.3 刷新token接口自己要排除在401处理之外这个坑听起来简单但真的发生在你面前时就有点懵。某个凌晨接口报错排查发现刷新 token 的请求自身返回了 401然后它又进入了401 就去刷新 token的分支于是不断请求刷新接口形成一个死循环严重时直接把登录态弄崩。所以我在响应拦截器里加了一条硬编码判断如果请求 url 是刷新 token 接口永远不要走刷新流程出现 401 就直接登出。这也意味着刷新 token 接口的错误码约定要单独处理不能和业务接口混在一个错误提示机制里。还有一个小建议刷新 token 的接口建议走单独的 axios 实例或至少不携带 Authorization 头避免它拿到已经过期的 Bearer token 又被后端拦截。很多后端刷新接口只认 refreshToken 或 cookie前端把已经过期的 accessToken 也带去反而多出不必要的麻烦。这几条坑写出来有点事后诸葛亮的感觉但每一条都是实际排障一两个小时才搞明白的。这套封装在我当前负责的项目里跑了半年最直观的收益是前端代码里几乎没有手动拼接 Authorization 的地方也很少遇到用户因为连点按钮或快速切页看到一堆错误弹窗。如果让我重新实现一遍我会把刷新 token 和取消请求拆成两个可插拔的中间件而不是像现在这样都收敛在拦截器里。虽然目前的版本也足够稳定但中间件的隔离性会让单测和后续扩展轻松很多。希望对正在封装请求库的你有点帮助。