1. 背景动机:为什么需要“请求管理层”
在前后端分离的 SPA 应用中,如果不控制请求的生命周期,很容易产生以下问题:
- 安全问题
- 用户点击“退出登录”后,仍有携带旧 Token 的请求在进行。
- 这些请求返回的数据可能会渲染到已经“退出”的界面上,造成数据泄露或权限错乱。
- 一致性问题
- 路由已切换、组件已卸载,但请求返回后仍然尝试更新页面状态,引发:
- 状态更新报错(如对已卸载组件 setState)
- UI 闪烁或短暂显示旧数据
- 路由已切换、组件已卸载,但请求返回后仍然尝试更新页面状态,引发:
- 性能与体验问题
- 多个重复请求、无意义的“僵尸请求”占用带宽和连接资源。
- 在弱网环境下更明显,用户感觉页面“卡顿、慢”。
因此,我们需要一个统一的请求控制机制,让每个请求从发起到结束都有“生命周期”概念,并在关键时点(如退出登录)可被统一中断。
2. 整体设计概览
这套方案的核心由三部分组成:
- 全局进行中请求表:
pendingRequests: Map - 在请求拦截器中为每个请求建立“可取消控制柄”
- 暴露统一的
cancelAllPendingRequests接口,在退出登录等场景一键中断
结构示意:
1Axios 请求发起2 ↓3请求拦截器4 - 生成请求唯一 key5 - 创建 AbortController / CancelToken6 - 注册到 pendingRequests Map7 ↓8接口请求真正发送9 ↓10响应拦截器11 - 根据 key 从 pendingRequests 中删除12 ↓13业务逻辑处理1415退出登录 / 路由切换 等场景16 ↓17调用 cancelAllPendingRequests()18 ↓19遍历 pendingRequests 中所有控制对象20 - controller.abort() 或 cancel(reason)21 ↓22所有未完成请求被中断
3. 全局 pending 请求表:请求管理的“中控室”
代码位置:src/utils/request.js:21-32
1const pendingRequests = new Map();2export function cancelAllPendingRequests(reason = 'canceled') {3 console.log('pendingRequests:', pendingRequests);4 for (const [, ctrl] of pendingRequests.entries()) {5 if (ctrl?.controller && typeof ctrl.controller.abort === 'function') {6 ctrl.controller.abort();7 } else if (ctrl?.cancel && typeof ctrl.cancel === 'function') {8 ctrl.cancel(reason);9 }10 }11 pendingRequests.clear();12}
3.1 pendingRequests 的角色
- 使用
Map维护所有“进行中的请求”。 - Key:请求的唯一标识(下面会看到如何生成)。
- Value:请求控制对象,可能有两种形态:
{ controller }:基于AbortController{ cancel }:基于axios.CancelToken
3.2 cancelAllPendingRequests 的设计要点
- 统一入口 <br> 任何需要“清理请求”的场景(退出登录、路由切换等)都可以调用它。
- 适配两类控制方式
AbortController:controller.abort()CancelToken:cancel(reason)
- 支持自定义中断原因
reason = 'canceled'为默认说明- 在退出登录时传入
'logout',方便后续错误拦截器中区分不同中断场景。
- 最后
clear()- 中断完成后统一
pendingRequests.clear(),保证无引用残留。
- 中断完成后统一
这一层可以理解为“请求的中控室”,所有请求的“生杀大权”都集中在这里。
4. 请求拦截器:为每个请求植入“控制柄”
代码位置:src/utils/request.js:200-230
1service.interceptors.request.use(2 (config) => {3 if (store.getters.token) {4 config.headers['authorization'] = getToken();5 }6 config.headers['X-Client-Type'] = 'frontend';7 config.headers['X-Client-Info'] = CACHED_USER_AGENT;8 config.headers['X-Client-Fingerprint'] = CACHED_FINGERPRINT;9 const key = `${config.method || 'get'}:${10 config.url11 }:${Date.now()}:${Math.random()}`;12 config.__requestKey = key;13 try {14 if (typeof AbortController !== 'undefined') {15 const controller = new AbortController();16 config.signal = controller.signal;17 pendingRequests.set(key, { controller });18 } else if (axios.CancelToken) {19 const source = axios.CancelToken.source();20 config.cancelToken = source.token;21 pendingRequests.set(key, { cancel: source.cancel });22 }23 } catch (e) {24 console.log(e);25 }26 return config;27 },28 (error) => {29 return Promise.reject(error);30 }31);
4.1 请求唯一标识 key 的构造
1const key = `${config.method || 'get'}:${2 config.url3}:${Date.now()}:${Math.random()}`;4config.__requestKey = key;
- 包含了:
config.method:请求方法config.url:请求 URLDate.now():时间戳Math.random():随机数
- 特点:
- 不追求“重复检测”,而是确保每个请求都有一个唯一 key。
- 这样可以简单、直接地在
Map中新增/删除,不需要额外判断已有请求。
后续如果希望做“请求去重”(例如只保留最后一次搜索请求),可以调整 key 的生成逻辑,使其对特定接口产生可重复的 key。
4.2 优先使用 AbortController
1if (typeof AbortController !== 'undefined') {2 const controller = new AbortController();3 config.signal = controller.signal;4 pendingRequests.set(key, { controller });5}
AbortController是原生 Web 标准,主流浏览器都已支持。- 通过
config.signal = controller.signal,让 Axios 在内部使用signal来支持取消。 - 请求发起后,如果调用
controller.abort():- 客户端请求会立即被中断。
- Axios 会抛出一个标记为“被中止”的错误对象。
4.3 兼容老环境:Axios CancelToken
1else if (axios.CancelToken) {2 const source = axios.CancelToken.source();3 config.cancelToken = source.token;4 pendingRequests.set(key, { cancel: source.cancel });5}
- 对于不支持
AbortController的环境(或老版本 Axios),使用CancelToken方案。 - 调用
source.cancel(reason)会取消对应请求,Axios 会抛出一个取消错误。
4.4 统一登记到 pendingRequests
无论是 controller 还是 cancel,最终都会:
1pendingRequests.set(key, { controller });2// 或3pendingRequests.set(key, { cancel: source.cancel });
这一步是后续批量取消的基础——每个请求在被发出时就注册到“中控 Map”里。
5. 响应阶段:请求完成后的及时“下线”
代码位置:src/utils/request.js:234-235
1const key = response?.config?.__requestKey;2if (key && pendingRequests.has(key)) pendingRequests.delete(key);
5.1 为什么要删除
- 每个请求一旦“有结果”(成功/失败):
- 就不再需要可取消控制对象。
- 继续保留在
pendingRequests中会造成内存泄漏。
- 因此在响应拦截器中,根据
config.__requestKey将该请求从Map中删除。
5.2 错误场景也要删除
当前代码片段展示了成功响应场景中的删除逻辑,实际工程中通常还会在响应错误拦截器中同步删除:
- 否则被取消的请求、网络错误的请求可能会长期“挂在”
pendingRequests里。 - 建议在错误拦截器中也使用同样的
__requestKey删除逻辑。
6. 退出登录流程:在“身份切换”前先“清场”
退出登录逻辑位置:src/store/modules/user.js:201-218
1// user logout2logout({ commit, state, dispatch }) {3 return new Promise((resolve, reject) => {4 cancelAllPendingRequests('logout');5 logout(state.token)6 .then(() => {7 commit('RESET_USER_STATE');8 removeToken();9 removeCookieValue('access_token');10 resetRouter();11 obs.reset();12 resolve();13 })14 .catch((error) => {15 reject(error);16 });17 });18},
6.1 调用时机:退出动作的第一步
1cancelAllPendingRequests('logout');
- 在发起后端
logout(state.token)请求之前就调用。 - 表示:先清理掉所有“旧身份”下正在进行的请求,再进行退出逻辑。
这样做的好处:
- 即使有接口在“路上”(比如获取用户信息、权限、菜单等),也不会等它们返回,再去渲染页面。
- 避免出现如下场景:
- 用户点击退出 → 前端开始清理状态
- 之前发出的“获取用户信息”请求返回
- 把旧用户的信息又渲染到界面上
6.2 退出后前端侧的清理动作
1.then(() => {2 commit('RESET_USER_STATE');3 removeToken();4 removeCookieValue('access_token');5 resetRouter();6 obs.reset();7 resolve();8})
RESET_USER_STATE:重置 Vuex 中 user 相关状态。removeToken、removeCookieValue('access_token'):- 清除本地存储与 Cookie 中的访问令牌。
resetRouter:- 重置路由配置,通常用于清除基于权限动态添加的路由。
obs.reset():- 清理一些全局监听/订阅者(推测是某种 Observer 模式相关对象)。
整体顺序上是:
- 中止所有进行中的请求(旧身份)
- 发起后端登出
- 清理前端本地状态与路由
- 完成 Promise
这是一条“由外到内”清理的完整链路,逻辑清晰、安全性高。
7. 方案优点与实践价值
7.1 安全性显著提升
- 退出登录后不再有携带旧 Token 的请求继续执行。
- 避免“旧用户数据”在新会话中短暂露出。
7.2 可维护性与工程一致性
- 请求取消逻辑集中在:
src/utils/request.js中的拦截器与cancelAllPendingRequestssrc/store/modules/user.js中的调用点
- 组件层无需关心“这个请求怎么取消”,只要依赖于全局拦截器与统一接口即可。
- 避免在每个组件里手动保存取消函数、在
beforeDestroy/onUnmounted中一一处理的重复模式。
7.3 兼容性友好
- 在支持
AbortController的环境下采用标准方案。 - 在旧环境下自动回退到
axios.CancelToken:- 不强行要求升级部署环境。
- 让渐进式演进成为可能。
7.4 扩展能力强
在现有架构上,可以方便地扩展出:
- 每个路由一个 scope,取消某个路由对应的所有请求。
- 请求去重(同一资源仅保留最后一个请求)。
- 针对某些关键请求标记为“不可取消”等。
8. 潜在优化与扩展方向
在当前实现基础上,可以考虑进一步演进:
8.1 错误拦截器中“识别取消类型”
- 在响应错误拦截器中区分:
- 普通网络错误
- 用户手动取消(一般 reason 为
'canceled') - 退出登录导致的取消(reason 为
'logout')
- 对于因注销导致的取消:
- 不需要弹错误提示
- 不做重试
- 可以用于埋点统计(统计用户主动退出时中断的请求数量)
8.2 路由级别的精细化管理
当前 cancelAllPendingRequests 是全局“一刀切”。可以进一步:
- 在
pendingRequests中的 value 中增加scope字段,例如:{ controller, scope: routeName }{ cancel, scope: 'UserCenter' }
- 然后实现:
cancelRequestsByScope('UserCenter')<br> 在路由守卫中:离开某页面时只取消该页面相关请求,而不是全局全部取消。
8.3 封装组件级 Hook / 工具
例如封装一个 useCancelableRequest:
- 在组件内发起请求时自动注册到某个 scope。
- 在组件卸载时自动触发对应 scope 的取消。
- 对于频繁切换的列表、搜索类页面尤为实用。
8.4 监控与埋点
在 cancelAllPendingRequests 等关键函数中添加埋点(而非简单 console.log):
- 统计:
- 每次退出登录时被取消的请求数量
- 哪些接口被取消频率最高
- 哪些页面触发取消最多
- 便于发现:
- 重复请求过多的接口
- 弱网环境下用户操作模式
9. 总结
这套基于 AbortController 的请求管理方案,通过非常有限的代码改动,建立起了一层“请求生命周期管理”的基础设施:
- 在 请求拦截器 中:
- 为每个请求生成唯一 key
- 绑定
AbortController或CancelToken - 统一登记到
pendingRequests中
- 在 响应拦截器 中:
- 基于 key 将已完成请求从
pendingRequests中删除,防止泄漏
- 基于 key 将已完成请求从
- 在 退出登录等关键场景 中:
- 调用
cancelAllPendingRequests('logout')一键中止所有进行中的请求 - 再进行 Token 清理、状态重置、路由重置等操作
- 调用
这不仅提升了安全性和一致性,也为后续的精细化请求管理(按页面、按模块、按优先级等)打下了良好基础。 在实际项目中,可以在这个框架上继续演化出更丰富的能力,例如请求去重、路由级别取消、组件级 Hook 以及完善的中断原因处理和监控埋点等,使整个前端网络层更加可控、可观测、可维护。
<br>