# 03 — 请求层与数据流 > 参考:vue-vben-admin(RequestClient + refresh 队列)、soybean-admin(五钩子契约 + flat 返回)、vue-pure-admin(PureHttp 请求类)、ant-design-pro(ErrorShowType 契约);补充:gin-vue-admin(loading 防抖、401 事件总线) --- ## 一、统一响应契约 ### 1.1 契约即协议 请求层的第一件事不是封装 axios,而是**和后端签一份响应契约**。两种主流格式: ```js // 格式 A:Go Gin / 多数国内后端惯例(gva 等) { code: 0, data: {...}, msg: 'success' } // code === 0 成功 // 细分扩展:0 成功 / 200 成功 / 2 MFA 待验证 / 3 强制改密 // 格式 B:adp(含错误展示声明) { success: true, data: {...}, errorCode?: number, errorMessage?: string, showType?: ErrorShowType } ``` ### 1.2 adp 的杀手锏:错误展示分级 ```ts // ant-design-pro: src/requestErrorConfig.ts enum ErrorShowType { SILENT = 0, // 静默:调用方自行处理 WARN_MESSAGE = 1, // 轻提示 message.warning ERROR_MESSAGE = 2, // 错误提示 message.error NOTIFICATION = 3, // 通知栏 notification REDIRECT = 9, // 重定向到错误页 } ``` - 后端在响应里**声明这个错误该怎么展示**,前端按枚举分发——错误处理策略从「前端猜」变成「契约说」 - 前端保留逃生口:`request(url, { skipErrorHandler: true })` 单请求关闭全局处理 > **推荐**:在 `{code, data, msg}` 上加可选字段 `show_type`,前端默认 `ERROR_MESSAGE`,登录页/轮询接口传 `SILENT`。改动只在请求层一处。 --- ## 二、请求层结构:从单例到客户端类 ### 2.1 三档演进 ``` 档1 单例 + 双拦截器(gva,230 行单文件) 适合:单应用、单一鉴权方式 档2 工厂五钩子(soybean:RequestOption 契约 + createFlatRequest) 适合:错误契约复杂、想要扁平返回与错误码 env 化 档3 客户端类 + 预设拦截器(vben RequestClient / pure PureHttp) 适合:多实例(业务 client / 裸 client)、上传下载 SSE 一等公民 ``` ### 2.2 vben 的 RequestClient 精髓 ```ts // vben: packages/effects/request/src/request-client/request-client.ts class RequestClient { private instance: AxiosInstance addRequestInterceptor / addResponseInterceptor // 拦截器不写死,外部组装 upload / download / postSSE // 传输形态一等公民 } // 预设拦截器(preset-interceptors.ts)按需装配: defaultResponseInterceptor({ codeField, dataField, successCode }) // 解包策略可配置 authenticateResponseInterceptor // 401 + refresh errorMessageResponseInterceptor // status→文案 ``` 应用组装处(`apps/web-antd/src/api/request.ts`)导出两个实例: - `requestClient`:`responseReturn: 'data'`,业务用(自动剥壳) - `baseRequestClient`:`responseReturn: 'raw'`,刷新 token 等裸接口用(**刷新接口自己不能被自己的拦截器拦**) ### 2.3 soybean 的五钩子契约与 flat 返回(★★★★☆ 工厂化范式) soybean 把请求协议下沉为 workspace 包 `packages/axios`,业务侧只实现五个钩子: ```ts interface RequestOption { transform: (res) => ApiData // 解包 onRequest: (config) => config // 注入 token isBackendSuccess: (res) => boolean // 业务 code 判定 onBackendFail: (res, instance) => Promise // 失败处理(可重发) onError: (error) => void // 错误提示 } // createRequest:失败抛异常(传统风格) // createFlatRequest:返回 { data, error, response },业务层 if (!error) —— Rust 风格扁平返回 ``` 另两条可迁移细节:**错误码策略 env 化**(`VITE_SERVICE_LOGOUT_CODES` 直接登出 / `MODAL_LOGOUT_CODES` 模态登出 / `EXPIRED_TOKEN_CODES` 刷新重发,三级以配置区分);**错误消息栈去重**(errMsgStack 防并发轰炸)。pure 的 `PureHttp` 类(196 行单文件)则是轻量级范本:qs 序列化数组参数、请求前本地判断 expires **提前刷新**(而非 401 后补救)、per-request 回调可覆盖全局拦截器。 --- ## 三、Token 与 401:单飞刷新是铁律 ### 3.1 问题:并发 401 的竞态 ``` 请求A ──401──▶ 刷新token(旧1) ──▶ 重放A ✔ 请求B ──401──▶ 刷新token(旧1) ──▶ 后端拒(旧1已消费)──▶ B 被踢出登录 ✘ ``` ### 3.2 正确姿势:单飞标志 + 等待队列(vben) ```ts // vben: authenticateResponseInterceptor if (client.isRefreshing) { return new Promise((resolve) => client.refreshTokenQueue.push((token) => { config.headers.Authorization = `Bearer ${token}` resolve(client.instance.request(config)) // 拿新 token 后重放 })) } client.isRefreshing = true try { const newToken = await doRefresh(...) } finally { client.isRefreshing = false } config.__isRetryRequest = true // 防无限循环 ``` pure 用同构的静态成员实现(`isRefreshing` + `requests[]` 队列,刷新成功 `requests.forEach(cb => cb(token))` 重放);soybean 用共享 Promise 单飞(`state.refreshTokenPromise`,1 秒后置 null)。**三家殊途同归:并发 N 个过期请求只触发一次刷新。** pure 的补充思路:**提前刷新**——token 三元组(accessToken/expires/refreshToken)存 Cookie,请求前本地判断 `expires - now <= 0` 主动刷新,把过期处理从「响应后补救」变成「请求前预防」(白名单短路 `/refresh-token`、`/login` 防死循环)。 ### 3.3 刷新失败后的三种落法 | 落法 | 出处 | 适用 | |------|------|------| | 全局错误对话框确认后清 token 跳登录(事件总线单例防重复弹) | gva `emitter.emit('show-error')` | 多请求并发过期时体验最好(只弹一个框) | | 登录过期 Modal(原页面保留,重登后继续) | vben `loginExpiredMode: 'modal'` | 长表单场景,避免填一半全丢 | | 防抖单次跳转(最简可靠) | 常见轻实现 | 跳转加防抖标志;错误救援页(500/403)只清不跳,防按钮失效 | > 共同底线:**并发 N 个 401 只产生一次副作用**(一个弹窗/一次跳转);登录页与轮询接口静默(`SILENT`)。 --- ## 四、全局 loading 防抖(补充模式:gva) ```js // gva: utils/request.js(要点提炼) let activeAxios = 0 // 并发计数器 let timer = null const showLoading = (persist) => { if (timer) clearTimeout(timer) activeAxios++ if (persist) { ... return } // 长任务:立即显示且不自动关 timer = setTimeout(() => showFullScreenLoading(), 400) // 400ms 防闪烁 } const closeLoading = () => { activeAxios-- if (activeAxios <= 0) { clearTimeout(timer); hide(); } // 全部结束才隐藏 } // 兜底:30s 强制关闭;beforeunload 重置;单请求 donNotShowLoading 关闭 ``` 三个数字都是经验值:**400ms**(快请求不闪 loading)、**30s**(loading 不可能永远转)、**计数器**(并发请求只显示一个 loading)。若引入全局 loading,按此方案。 --- ## 五、取消与防错序 ### 5.1 为什么列表页必须取消旧请求 ``` 用户输入 "a" → 请求1发出 用户补输入 "ab" → 请求2发出 请求2先回 → 渲染 "ab" 结果 ✔ 请求1后回 → 覆盖渲染 "a" 结果 ✘ ← 错序覆盖(stale response) ``` ### 5.2 双重防护(列表页标准实现) ```js // composables/usePagedQuery.js —— 取消 + 版本号双保险 let abortController = null let requestVersion = 0 const fetchList = async () => { abortController?.abort() // ① AbortController 取消旧请求 const currentVersion = ++requestVersion // ② 版本号令牌 const res = await fetcher(params) if (currentVersion !== requestVersion) return // 过期响应直接丢弃 tableData.value = res.results } ``` 这是列表页的**强制标准**:所有带筛选/分页的列表页必须走「AbortController + 版本号」双重防护,**禁止**页面手写裸请求循环。 注:vben v5 并未内置请求去重(取消能力委托 axios 原生 config,`axios.isCancel` 静默透传)——**不要假设大而全的框架替你解决了这个问题**。soybean 另有 `cancelAllRequest`(nanoid 请求 ID + AbortControllerMap)作页面级批量取消。 ### 5.3 重试与缓存策略 - **重试**:仅幂等 GET 且网络类错误(timeout / network error)自动重试,1-2 次指数退避(soybean 内置 axios-retry;需要时引入 axios-retry,**禁止**手写循环重试)。**禁止**对 POST / PUT / DELETE 自动重试 - **缓存**:字典 / 枚举类走缓存型 store(gva `dictionary.js` 范式:缓存 key 生成 + 参数校验,避免重复请求);列表页交给组件态 + 取消,不做全局缓存;React 端用 React Query 的 `queryKey` 失效模型(adp 范式:mutation 成功 → `invalidateQueries`) - **判定**:同一数据被两个页面重复请求同一接口 = 该进缓存层,或该提升到全局 store 了 --- ## 六、状态分层:什么数据进 store ### 6.1 判定表 | 数据 | 归属 | 标杆范式 | |------|------|----------| | token / 用户信息 / 权限码 | 全局 store,**持久化 pick 白名单**(只挑必要字段) | vben `persist.pick`;pure Cookie 三元组 + localStorage 用户信息 | | 服务端菜单 / 动态路由表 | store(内存),**不落盘**(重登重拉,防止旧菜单闪现) | vben access store;pure 可选缓存需显式配置 | | 列表数据 / 详情 | **不进全局 store**:组件内 usePagedQuery / React Query | adp:`queryKey:[资源名]`,mutation 成功 `invalidateQueries` | | 字典 / 短枚举 | 缓存型 store,带缓存 key 去重请求 | gva `dictionary.js`、soybean dict | | 主题 / UI 偏好 | 独立于业务 store 的偏好系统 | vben PreferenceManager(见 06 章) | | 筛选 / 分页 / Tab 状态 | **URL query**,永远不复制进 store | 通用 | ### 6.2 登录编排(vben 范式) ```ts // store/auth.ts —— 登录是一个编排函数,不是散落的事件 async function authLogin(params) { const { accessToken } = await loginApi(params) setAccessToken(accessToken) const [userInfo, accessCodes] = await Promise.all([fetchUserInfo(), getAccessCodesApi()]) router.push(homePath) } ``` 配套要求:**禁止**在生产日志打印 token 片段;`isLoggedIn` 以内存态 + token 存在性为准,不依赖 localStorage 残留字段。pure 的 `useXxxStoreHook()` 模式保证拦截器/守卫等 setup 外代码也能安全取 store。 --- ## 七、双端一致性要求(迁移期) 同产品双栈并存时,请求层**必须**满足: | 项 | 要求 | |----|------| | token key | 两端同名(推荐 `.auth.token`;迁移期「读旧写新」一个版本) | | refresh 机制 | 两端同构(单飞 + 队列重放),副端不允许退化成「401 直接跳登录」 | | 响应契约 | 同一份 code/data/msg 约定与错误分级 | | mock | **显式模式**(dev 开关/页面级声明),**禁止**运行时因网络错误静默回退假数据(adp:mock 是构建模式不是运行时兜底) | --- ## 八、最佳实践清单 ✅ **要做** 1. 组件永远 import `api/<域>` 函数,不直接碰 axios 实例(**禁止** store 绕过 API 层裸调) 2. 每个请求函数带注释(用途/参数/返回)+ 类型(`request` 或 JSDoc) 3. 刷新 token 的请求用裸 client,绕过业务拦截器 4. 401 副作用全局单例(一个弹窗/一次跳转),登录页与轮询接口静默 5. 列表请求一律可取消(AbortController)+ 版本号防错序 ❌ **不要** 1. 不要在拦截器里 `return error` 而非 `Promise.reject(error)`(gva 现存 bug,调用方 catch 不到) 2. 不要在组件里 try/catch 后吞错不提示(错误要么展示要么显式静默) 3. 不要把 token 存两套 key 4. 不要在请求层 import 具体页面组件(保持依赖单向) 5. 不要对同一资源做无缓存的重复并发请求 --- ## 九、AI 检查清单(对比目标工程逐项核对) **P0** 1. 是否存在组件/store 绕过 API 层直接调 axios 实例的代码? 2. 401 刷新是否单飞(isRefreshing/队列/共享 Promise 任一实现)?并发过期是否只产生一次副作用? 3. 是否有「网络错误静默回退 mock」类逻辑在生产路径上? 4. 拦截器错误分支是否 `Promise.reject`?是否有 token 打印类调试日志残留? **P1** 5. 列表页是否全部走「取消 + 版本号」方案?手写裸请求循环的页面列出迁移清单 6. 错误提示是否分级(showType / silent 逃生口)?登录页与轮询是否静默? 7. token/用户信息持久化是否 pick 白名单?菜单/路由是否落盘(不应落盘)? 8. 字典/枚举是否有缓存层?重复并发请求同一资源的情况列出 **P2** 9. 是否值得升级到档 2/3(五钩子工厂 / RequestClient 类)?判断标准:多实例需求或错误契约复杂度 10. 是否引入 axios-retry(幂等 GET)与错误码 env 化? --- > 下一篇:[04-路由与权限控制](04-路由与权限控制.md) —— meta 是路由的灵魂。