请求request流程
6号,7号,8号,9号,10号
拦截器思路:
- 基础数据
- 一个“待处理请求”的记录表(Map)
pendingRequests,key是请求唯一标识,value是取消函数 - 唯一标识函数对参数键排序再序列化,
JSON.stringify(data,Object.keys(data).sort())
- 一个“待处理请求”的记录表(Map)
- 每次请求进来先检查记录表有没有“相同标识”的请求
- 如果有,说明上一次请求还没完成,直接调用对应的取消函数把它取消掉
- 给当前的请求注入 CancelToken,并登记在 pendingRequests 里。CancelToken 只能使用一次,如果这个请求被取消并重新发起,旧的token 已经失效,需要重新生成。
- 请求完成后(无论成功或失败),在响应拦截器里把它从记录表里删除
问题:当用户切换页面时,怎么把页面上所有还在飞的请求一次性全取消掉?
在哪里取消?axios还是组件?可以利用记录表pendingRequests吗?
给页面一个唯一的标志,并存放到 pendingRequests 的value里。
取消所有请求思路:
- 给请求打标签:发送请求时,用组件的唯一表示(比如路由路径)请求分组
- 在路由守卫里,把当前页面的标志主动注入到所有请求中
axios.defaults.pageId = to.path
- 在路由守卫里,把当前页面的标志主动注入到所有请求中
- 在组件挂载时记录:把“当前页面标志
pageId”存起来,或提供一个方法,能把请求注册到当前页面下。- 从
axios.defaults.pageId获取pageId - 给当前的请求注入 CancelToken,和
pageId,并登记在 pendingRequests 里
- 从
- 在组件卸载时取消:离开页面时,遍历记录表
pendingRequests,把属于这个页面的所有请求取消并清理。- 在路由守卫的 from 离开时自动取消
问题:如何取消指定的请求
思路:给请求再添加一个requestType标签,这样既能按页面取消,也能精准取消
- pendingRequests 的 value 结构
{cancel:Function,pageId:string,requestType?:string// 可选标签}- 新增按页面+类型取消函数
- 在遍历记录表
pendingRequests判断value.pageId === pageId(参数) && value.requestType === requestType(参数),如果是的话就取消请求和删除记录表里的请求
- 在遍历记录表
- 在组件里使用取消指定的请求
// 导出请求axios.get('/api/export',{requestType:'export'});// 列表刷新请求axios.get('/api/list',{requestType:'list-refresh'});// 用户重新点击导出,只取消上一次导出,不影响列表刷新cancelRequestsByType(currentPageId,'export');问题:当我们主动取消了请求,axios会抛出一个 Cancel 错误。在拦截器里,我们要怎么区分“正常的取消”(不需要报错提示)和“真正的接口错误”(需要弹窗提示)?
思路:
- 先用
axios.isCancel(error)判断错误对象是否为取消请求生产的Cancel类型。 - 进一步检查该请求是否在我们维护的 pendingRequests Map 中,即是否为我们主动取消的。我们主动取消的请求前一定还在表中(因为是我们调用
cancel()后才抛出的错误),而其他来源的取消(如浏览器中断、第三方库取消)则不会在表中 - 双重判断:isCancel 为 true 且 pendingRequests.has(requestKey) 为 true → 我们主动取消,静默处理(不弹提示)。否则,可能是其他取消或真实错误,按真实错误处理。
补充:对于真实错误返回Promise.reject(error),让上层捕捉;对于主动取消,返回Promise.resolve()吞掉错误。
问题:智能重试机制
场景:用户正在后台填一个复杂的表单,点保存时,恰好网络抖动了一下,接口返回 500 或者超时。如果直接弹个“保存失败”的提示,用户心态炸裂,前面填的东西也可能丢了。
我们想要的效果是:
- 遇到网络错误或 5xx 错误,自动静默重试 2 次,不弹错误提示
- 遇到 4xx 错误(参数错误、无权限),不重试,直接提示
- 重试间隔逐次递增(1秒、2秒、4秒)
- 重试次数用完后才弹出“保存失败,请稍后重试”
设计:
- 重试逻辑:放在
axios.interceptors.response.use的第二个参数(错误回调) - 重试判断:网络错误/超时/5xx -> 重试;4xx(参数错、无权限)->不重试,直接抛
- 配置方式:全局默认+单个请求覆盖
- CancelToken: 每次重试都要创建新的,旧的已经失效了
思路:
- 基础数据
- 全局默认配置含:重试次数,基础延迟(毫秒),重试条件(网络错误/超时/服务端错误 返回 true,其余返回 false)
- 重置配置:合并默认的配置和config的重置配置参数
- 初始化重置次数:(
config.__retryCount || 0)
- 不满足重试条件,直接抛
return Promise.reject(error) - 重试次数用完:重置次数大于等于重置配置的次数,直接抛
return Promise.reject(error) - 计算延迟
- 等待后重新发送请求
await new Promise(resolve => setTimeout(resolve, delay));- 因为前面有 await,所以当前拦截器函数会暂停 delay 毫秒,等 Promise 完成后再继续执行后面的代码(创建新 CancelToken、增加计数、重发请求)。
- 本质:它是一个异步的“等待”,而不是阻塞整个 JavaScript 线程。
- 等待结束后,检查是否页面已切换
- 比较
config.__pageId和axios.defaults.pageId - 如果不相等,页面已切换,终止重试,清理登记表
- 返回一个特殊错误,不让他触发业务层的错误
return Promise.reject({ __canceledByNavigation: true, message: '页面已切换,重试终止', });
- 比较
- 创建新的 CancelToken,更新 pendingRequests
- 要先清理旧登记
- 记录重试次数(
config.__retryCount + 1) - 重新发送请求
return axios(config)
问题:Token 无感刷新
什么是“无感”?
用户正在填一个复杂表单,点提交。此时 token 恰好过期(通常只有2小时有效期),后端返回 401。
- 有感:弹出一个登录框,“请重新登录”。用户心态炸裂,填的东西全丢了。
- 无感:前端自动用 refresh_token 去换一个新的 access_token,然后把刚才那个失败的请求重新发一遍。整个过程用户完全没有感知。
核心思路:
- 请求拦截器(主动检查):发请求前,先检查
access_token是否只剩5分钟。如果是,且没在刷新中,就先刷新再发 - 响应拦截器(被动兜底):收到401时,触发刷新,重试原请求
- 刷新锁(核心机制):用一个 Promise 队列,保证即使10个请求同时401,也只有一个刷新请求飞出去,其他都排队
- 退出登录:刷新失败(refresh_token 也过期)时,清除token,跳转登录页
token思路:
- 基础数据
- Token 相关工具函数
1. 从 store 获取 accessToken函数(可以改为localStorage存储,更解耦)
2. 从 store 获取 refreshToken函数(可以改为localStorage存储,更解耦)
3. 保存 Token 函数到 store ,参数为accessToken, refreshToken(可以改为localStorage存储,更解耦)
4. 清除权限函数clearAuth(),清除accessToken, refreshToken,跳转到登录页
5. 检查 token 是否快过期函数(提前 5 分钟)isTokenExpiringSoon()
6. 是否正在刷新refreshing
7. 排队等待的请求refreshSubscribers = []
8. token 刷新完成后执行的函数onRefreshed(newToken),遍历refreshSubscribers ,用新的token重试所有请求,重置refreshSubscribers = []
9. token 刷新失败函数onRefreshFailed(error),遍历refreshSubscribers ,返回报错,重置refreshSubscribers = []
- 执行刷新
refreshTokens()- 如果没有刷新 token,refreshToken,就清除权限,返回
Promise.reject(new Error('无 refresh token')) - 调刷新接口,注意:用 axios 原生方法,不走我们自己封装的 service,避免死循环
- 获取新的accessToken, newRefreshToken,并保存,返回accessToken
- 失败就
clearAuth(),throw 错误
- 如果没有刷新 token,refreshToken,就清除权限,返回
- 修改请求拦截器(在原有基础上追加放在最后面,新增:Token 主动刷新)
- 基础数据:
tokenAttached布尔值,标记是否已经在排队/刷新逻辑里挂过 token - 如果 token 快过期
isTokenExpiringSoon(),且当前请求不是刷新请求本身- 如果没有在刷新,则触发步骤2刷新。获取新的getAccessToken,设置 config.headers.Authorization =
Bearer ${newToken},刷新逻辑里挂过 tokentokenAttached = true,执行函数onRefreshed(新的getAccessToken)。失败就调用刷新失败函数onRefreshFailed(error),finally执行refreshing = false; - 否则正在刷新中,当前请求排队等待。
await new Promise((resolve, reject) => {refreshSubscribers.push((token, error) => {...}},成功时token也要挂上,刷新逻辑里挂过 tokentokenAttached = true,resolve(token);失败时reject(error);。失败时这样写,有个bug,当clearAuth() 里触发了 router.push(‘/login’),页面开始跳转。在跳转完成前,那些排队请求的报错可能会短暂触发页面上的错误提示。
- 如果没有在刷新,则触发步骤2刷新。获取新的getAccessToken,设置 config.headers.Authorization =
- 正常 token:直接挂上
config.headers.Authorization =Bearer ${token}; - 返回 config
- 基础数据:
- 修改响应拦截器(在原有 onRejected 最前面插入,新增:被动 401 刷新)
- 基础数据
const config = error.config;;config.__isRetryRequest:标记,避免循环 - 如果 error.response?.status === 401,且当前请求不是刷新请求本身
config.url !== '/api/auth/refresh'- 如果
config.__isRetryRequest为 true,刷新成功后重试仍 401 ,为避免死循环,直接清除认证并跳转,并返回错误 - 第一次遇到 401
!refreshing,- 抢到刷新锁,
refreshing = true;; - 执行刷新
refreshTokens(); - 成功:
- 唤醒所有排队请求
onRefreshed(newToken);; - 重试当前请求设置
config.headers.Authorization =Bearer ${newToken}`` ; - 标记,避免循环
config.__isRetryRequest = true; - 注意:重试前不用手动清理 pending,因为 service(config) 会再次进入请求拦截器登记
- 返回
service(config); - 失败:
- 刷新失败,通知所有排队请求 reject
onRefreshFailed(refreshError); - 清除认证
clearAuth(); - 删除队列里的值
pendingRequests.delete(key); - 返回报错
return Promise.reject(refreshError);
- 抢到刷新锁,
- 已有刷新正在进行,排队等待
- 返回 Promise,
refreshSubscribers.push({}),push内容如下 - resolve 方法:参数(token),设置请求头,
config.__isRetryRequest设置为true,resolve(service(config)); - reject方法:参数(err),从请求队列里删除,reject(err);
- 返回 Promise,
- 如果
- 基础数据
问题:如果确实需要两次请求或多次相同请求发出怎么办?
场景:
- 并发文件上传:同时上传两个相同的文件(不同目录、不同用途)
- 并发创建订单:业务允许用户同时创建两笔内容相同的订单
- 并发导出:同时导出两个相同参数的报表(可能格式不同、用途不同)
方案 1:请求级别豁免(推荐)
在请求 config 上加一个自定义参数allowDuplicate,拦截器看到这个标记就跳过重复取消逻辑。
请求拦截器修改
如果请求标记了 allowDuplicate,跳过重复取消逻辑
- 在取消重复请求那里添加判断条件,
!config.allowDuplicate
方案 2:请求类型级别豁免
如果某一类请求(比如所有上传)都需要允许重复,可以在配置里预设:
- 添加白名单;
- 在取消重复请求那里添加判断条件,和非白名单的接口
方案 3:用 requestType 来区分“同类但不同目的”的请求
如果请求参数完全一样,但业务含义不同,可以给 requestType 加后缀来区分:
- 因为 requestType 没有被纳入 getRequestKey 的生成逻辑(目前只用了 method + url + params + data),所以如果你希望 requestType 也参与去重判断,需要把它加入 getRequestKey
流程
登录接口 (store:存token和个人信息数据)
⬇️
请求axios
|—请求拦截器,失败了就Promise.reject(error),成功了如下:
|—|—判断token,没有就设置
|—|—判断config.signal,没有就设置
|—|—判断是否过期,如果过期了就返回
|—|—加入请求队列
|—响应拦截器,成功就移除请求队列,返回响应,失败如下
|—|—如果过期
|—|—|—中断请求,创建新的AbortController,返回Promise.reject(error)
|—|—如果请求状态401,且返回的信息为“token无效”
|—|—|—过期状态设置为 true
|—|—|—中断请求,创建新的AbortController,给出页面提示
|—|—|—返回 清理数据store
|—|—|—|—路由重定向,路由重定向.then过期状态重置置为 false
⬇️
路由
|—全局前置守卫 router.beforeEach
|—|—设置页面标题
|—|—1. 白名单放行
|—|—2. 获取token
|—|—3. 未登录跳转登录页
|—|—4. 获取全局配置(若未加载),500回退500的页面,其他错误视为未登录,回退登录
|—|—5. 若目标是 /login,已登录重定向到首页
|—|—6. 判断路由是否已加载
|—|—|—已加载:检查目标是否匹配(有匹配说明已授权,没有就返回404页面)
|—|—|—未加载:请求动态路由(从store)并添加 router.addRoute(routes[i]), 重定向到当前路径,触发重新匹配(替换历史记录)。报错500回退500的页面,他错误可取消导航或跳转登录
|—|—|—|—store:请求动态路由 getAccessRoutes,先 getPermissions 获取权限,然后同时获取远程路由和动态路由
|—全局后置钩子 router.afterEach
|—|—取消所有请求
|—重置路由,将 router 变量重新赋值,并返回新实例供外部更新。用于退出等场景
动态路由与权限
21号
前端权限体系 │ ├── 一、动态路由生成 │ ├── 核心问题:不同角色看到不同菜单,无权限页面即使输URL也无法访问 │ ├── 数据来源 │ │ ├── 后端返回菜单列表(扁平或树形) │ │ │ ├── 字段:id, parentId, name, path, permission, icon, type, hidden │ │ │ └── 格式:扁平结构(靠parentId关联)或 树形结构(直接嵌套) │ │ └── 登录成功后获取,存入 store / sessionStorage │ │ │ ├── 扁平数据 → 路由树 │ │ ├── 算法:两次遍历 + Map │ │ │ ├── 第一轮:所有节点放入 Map<id, route> │ │ │ └── 第二轮:根据 parentId 挂到父节点的 children │ │ ├── 组件映射:path → component 的配置表 │ │ │ ├── 独立配置文件 routeConfig.js │ │ │ └── 新增页面只需改这一处 │ │ └── 生成 Vue Router 可用格式(path, name, meta, component, children) │ │ │ ├── 添加时机 │ │ ├── 路由守卫 beforeEach 中 │ │ ├── 标记位 dynamicRoutesAdded 防重复 │ │ └── 添加后 next({ ...to, replace: true }) 重定向,确保新路由生效 │ │ │ ├── 刷新恢复 │ │ ├── 动态路由存于内存,刷新后丢失 │ │ ├── 解决方案:重新请求菜单接口 │ │ └── 用 dynamicRoutesAdded 判断是否需要重新获取 │ │ │ └── 完整流程 │ ├── 用户访问 → 路由守卫 │ ├── 未登录 → 跳转登录 │ ├── 已登录 且 dynamicRoutesAdded === false │ │ ├── 显示 loading(避免白屏) │ │ ├── 请求菜单 → 生成路由 → addRoute │ │ └── 标记完成 → 关闭 loading → 放行 │ └── 已登录 且 dynamicRoutesAdded === true │ ├── 检查 to.meta.permission → 调用 hasPermission │ ├── 无权限 → 跳转 /404 │ └── 有权限 → 放行 │ ├── 二、按钮级权限 │ ├── 核心问题:同一页面内,不同角色看到不同按钮 │ ├── 权限数据 │ │ ├── 后端返回权限对象 { "PERM_KEY": true/false } │ │ ├── 存入 Vuex store │ │ └── 异步加载完成前需要 loading 保护 │ │ │ ├── 判断函数 hasPermission(permissionName) │ │ ├── 从 store 读取权限对象 │ │ ├── 返回布尔值 │ │ └── 权限未加载时,返回 true 避免闪烁 │ │ │ ├── 实现方案对比 │ │ ├── 方案A:自定义指令 v-permission │ │ │ ├── 优点:使用简洁,适合大量按钮 │ │ │ ├── 缺点:直接操作 DOM,与 Vue 响应式存在冲突风险 │ │ │ └── 注意:必须移除 DOM 而非隐藏(display:none 可被控制台恢复) │ │ │ │ │ └── 方案B:封装组件 <Permission> │ │ ├── 优点:v-if 控制渲染,安全可靠 │ │ ├── 缺点:需要引入组件 │ │ └── 适用:需要“禁用+提示”的场景 │ │ │ ├── 多权限联合判断 │ │ ├── 指令支持字符串或数组 │ │ └── 用 Array.every() 判断所有权限都为 true 才显示 │ │ │ └── 用户体验细节 │ ├── 无权限按钮:默认隐藏(安全) │ └── 特殊场景:禁用 + 悬浮提示,让用户知道功能存在但无权使用 │ └── 三、综合边界处理 ├── 用户手动输入 URL → 路由守卫判断权限 → 无权限跳 404 ├── 用户修改本地权限数据 → 无意义,后端接口仍有权限校验 ├── 权限接口慢 → loading 遮罩 + 权限未加载时按钮默认显示 └── 菜单获取失败 → 跳转登录页,不关遮罩避免白屏闪烁-