9.双 Token 刷新与文件下载流封装
S - Situation (项目背景)
“在开发 MineAdmin 企业级管理后台时,我们面临两个典型的工程化挑战:
- 用户体验与安全的矛盾:为了安全,Access Token 有效期设置较短(如 2 小时),导致用户在使用过程中经常被强制登出,体验极差。
- 文件下载的复杂性:系统中有大量报表导出需求,后端统一返回二进制流。但在文件生成失败(如无数据)时,后端会返回 JSON 错误信息,而前端统一配置了
responseType: 'blob',导致无法直接读取错误提示,且文件名的解析也经常因为编码问题出现乱码。”
T - Task (主要任务)
“我的任务是优化网络请求层(Axios 封装),实现两个核心目标:
- 无感刷新:设计一套机制,在 Token 过期时自动静默刷新,且必须处理好并发请求带来的竞态问题(即避免多个请求同时触发刷新)。
- 健壮的下载流处理:封装一个通用的响应拦截器,能自动识别文件流,兼容处理 JSON 错误,并准确解析各种编码格式的文件名。”
A - Action (关键行动)
我在 [http.ts] 中通过拦截器实施了以下方案:
并发锁 + 请求队列解决 Token 刷新竞态:
- 双 Token 机制:维护
access_token和refresh_token。当接口返回 401 时,不立即登出,而是尝试刷新。 - 并发锁:我引入了一个
isRefreshToken互斥锁。当第一个 401 请求触发刷新时,锁闭合。 - 请求队列:后续并发到达的 401 请求,会被拦截并推入一个
requestList队列挂起(返回一个未决的 Promise)。 - 重放机制:一旦刷新成功,我更新本地 Token,然后遍历队列,将新 Token 注入 Header 并重新发起所有挂起的请求,用户完全无感知。
- 双 Token 机制:维护
智能文件流适配:
- 类型嗅探:在响应拦截中检查
responseType和content-type。如果预期是 Blob 但返回了application/json,说明下载出错。 - 格式转换:此时利用
FileReader将 Blob 转回文本,解析 JSON 里的错误信息并弹窗提示,避免了用户下载到一个写着错误信息的损坏文件。 - 文件名解码:编写了专门的正则
filename[^;=\n]*=((['"]).*?\2|[^;\n]*)从Content-Disposition中提取文件名,并统一做decodeURIComponent处理,完美解决了中文文件名的乱码问题。
- 类型嗅探:在响应拦截中检查
R - Result (最终结果)
“这套方案落地后:
- 用户零打扰:实现了真正的‘永久在线’体验,除非 Refresh Token 也过期(通常 7-30 天),否则用户无需重复登录。
- 并发零冲突:在高并发场景下(如页面初始化同时请求 5 个接口),也能稳定串行刷新,从未出现过多次调用刷新接口的问题。
- 开发零负担:业务开发人员只需调用
useHttp().post(),无需关心下载流的转换和错误处理,文件导出功能的开发效率提升了 50% 以上。”
详细版本
这段代码集中在 [src/utils/http.ts] 中,通过 Axios 拦截器实现了两大核心功能:双 Token 自动刷新机制 和 通用文件下载流处理。以下是深度分析:
1. 双 Token 无感刷新机制
这是解决“Token 过期导致用户被强制登出”痛点的经典方案。
- 核心逻辑:
- 触发时机: 当接口返回
401 Unauthorized(代码中对应ResultCode.UNAUTHORIZED) 时。 - 并发锁 (
isRefreshToken):- 使用一个
ref变量isRefreshToken作为互斥锁。 - 当第一个 401 请求触发刷新时,将锁置为
true。 - 并发处理: 后续并发的 401 请求会进入
else分支,被推入requestList队列挂起,等待刷新完成。
- 使用一个
- 刷新流程:
- 调用
/admin/passport/refresh接口,携带refresh_token。 - 成功:
- 更新本地存储的
token和refresh_token。 - 重放队列: 遍历
requestList,用新 Token 重新发送所有挂起的请求。 - 重放当前请求: 修改当前失败请求的 Header 并重新发起
return http(config)。
- 更新本地存储的
- 失败: 执行
logout()强制登出。
- 调用
- 队列机制:
requestList数组用于暂存并发请求的resolve函数。- 一旦刷新成功,队列中的函数被执行,挂起的 Promise 重新激活。
- 触发时机: 当接口返回
代码亮点:
typescript
// 并发锁控制
if (userStore.isLogin && !isRefreshToken.value) {
isRefreshToken.value = true
// ... 执行刷新逻辑 ...
} else {
// 挂起并发请求
return new Promise((resolve) => {
requestList.value.push(() => {
config.headers!.Authorization = `Bearer ${cache.get('token')}` // 注入新 Token
resolve(http(config)) // 重新请求
})
})
}2. 通用文件下载流处理
解决了前后端分离架构下,二进制文件流(Blob/ArrayBuffer)下载和文件名解析的难题。
- 智能识别:
- 通过
response.request.responseType === 'blob' || 'arraybuffer'自动识别是否为文件流请求。
- 通过
- JSON 错误兼容:
- 痛点: 后端在文件下载出错时(如文件不存在),往往返回 JSON 格式的错误信息,而不是 Blob。但前端设置了
responseType: 'blob',导致 JSON 也被转成了 Blob。 - 解决: 代码通过
FileReader尝试将 Blob 转回文本,解析 JSON 判断code。如果发现是错误信息,弹出 Message 提示并 reject。
- 痛点: 后端在文件下载出错时(如文件不存在),往往返回 JSON 格式的错误信息,而不是 Blob。但前端设置了
- 文件名解析:
- 优先从
content-disposition响应头中提取文件名。 - 使用正则
filename[^;=\n]*=((['"]).*?\2|[^;\n]*)兼容各种编码格式(如 URL 编码的中文文件名)。
- 优先从
- 统一返回:
- 将
data(Blob),fileName,headers封装成对象返回,供业务层直接调用下载工具。
- 将
代码参考:
typescript
// 处理 JSON 格式的错误响应(当 responseType 为 blob 但实际返回 json 时)
if (response.data instanceof Blob && response.data.type === 'application/json') {
// FileReader 读取 Blob 内容...
}
// 文件名解析
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (match && match[1]) {
fileName = decodeURIComponent(match[1].replace(/['"]/g, ''))
}总结
这段代码展现了成熟的工程化思维:
- 用户体验: 通过无感刷新,避免了 Token 短期过期导致的用户频繁登录。
- 健壮性: 考虑了并发请求的竞态问题(Race Condition),通过队列机制完美解决。
- 通用性: 统一封装了文件下载的边界情况(JSON 错误、文件名乱码),减轻了业务开发的负担。
