uni-app x网络请求封装实战:统一Request层与Token自动刷新方案 最近在 uni-app x 项目里做了一轮网络层重构起因非常现实业务接口从几十个涨到两百多个以后散落在各页面里的 uni.request 调用彻底失控了。项目本身用的 Vue3 TypeScriptUI 层已经组合式 API 化得很干净唯独网络请求这块还是原始社会——每个页面都要重复写 header、处理 statusCode、手动弹错误、再手动 catch。这次统一封装 request 的过程中把拦截器、登录失效处理、多域名切换、流式请求取消这些事捋了一遍踩了几个印象很深的坑。这篇把封装方案和实战经验完整整理出来给同样在 uni-app x 里做跨端项目的朋友一个可以直接抄作业的参考。内容覆盖从设计思路到核心代码再到后期扩展技巧适合已经会用 uni.request、但想在项目里建立统一 API 请求层的开发者。1. 不封装的后果直接裸用 uni.request 的痛苦清单1.1 每个页面都在重复造轮子错误处理口径完全不一致我接手项目的时候光 token 注入的代码就在十几个页面里见过七八种写法。有的页面在 onLoad 里从 storage 读 token有的在 onShow 里读还有的直接写死了 header。更麻烦的是错误提示有的页面弹 uni.showToast有的页面直接 console.log有的页面压根不处理。用户看到的反馈极其分裂同一个网络超时A 页面弹请求失败B 页面没有任何反应。这种重复代码的危害不只是看着乱而是后期改动成本指数级上升。比如后端要求统一在 header 里加一个设备指纹字段我得全局搜索所有 uni.request 调用点一个个改。漏掉任何一个就意味着那个接口在线上会静默失败而且找不到原因。封装之后这个改动只需要动一处请求拦截器所有接口立即生效。1.2 后端返回结构变化时的连锁改动我们后端的返回体结构经历过一次调整从原来的{ code, data, msg }改成了{ code, message, result }。裸用 uni.request 的项目里这个改动就是一场灾难。所有页面里读取res.data.data的地方全部报错团队花了整整两天做全局替换还漏了几个非主流程的接口上线后灰度用户才暴露出来。这个问题本质上不是因为后端调整了结构而是因为客户端缺乏统一的数据出口。如果所有请求都经过同一个响应拦截器返回体结构变化只需要在拦截器内部做一次适配比如res.data.result映射成data业务页面代码一行都不用动。这个对比让我彻底下定决心做统一封装。1.3 跨端环境下隐藏的差异点uni-app x 真正让人头痛的不是写法而是跨端差异。同一个 uni.requestH5 端会自动带上 cookieApp 端默认不发 cookie某些小程序端对 header 的 key 大小写有要求iOS 上Content-Type写成application/json;charsetUTF-8没问题但在个别安卓 webview 版本上就会出幺蛾子。这些差异如果不统一收口问题会被隐藏得很深。比如 H5 端测试环境登录正常到了 App 端就变成每次请求都 401排查半天发现是 token 没有显式注入 headerApp 端不会像浏览器那样自动带 cookie。把请求入口收拢到一个文件后这些跨端差异可以在拦截器里做条件编译处理业务代码完全无感。2. 封装前的四件事决定后续代码好坏的底层约定2.1 统一返回体与错误码约定封装 request 之前第一件事是跟后端对齐返回体结构。这不是客户端单方面能定的但没有约定就去写拦截器等于在沙滩上建房子。我们最终定的结构是{ code: 0, message: ok, data: {} }code为 0 表示成功非 0 表示业务失败。HTTP 状态码只要不是 2xx直接在拦截器里统一处理不往业务层抛。这里有个关键约定业务失败的 HTTP 状态码强制用 200 返回所有非 200 一律视为网络层或服务端异常。这样客户端拦截器的逻辑可以写得非常清晰HTTP 非 2xx走网络异常分支弹统一错误提示HTTP 2xx 但 code 非 0走业务错误分支调用方可以拿到完整错误信息决定怎么做HTTP 2xx 且 code 为 0走成功分支解出 data 返回业务层如果后端非要用 4xx/5xx 承载业务错误拦截器也不是不能处理但逻辑会复杂很多建议跟后端强烈沟通统一原则。2.2 全局唯一的请求入口与数据流设计封装的核心思想是全局只有一个请求入口。不管页面代码写的是request.get(/user/info)还是request.post(/order/create)最终都会汇聚到同一个底层函数。这带来几个直接好处token 注入只写一次错误处理只写一次loading 控制可以全局做日志上报可以全局做数据流设计上我建议保持单向流动页面调用 API 模块 - API 模块拼装参数调用 request 层 - request 层注入公共 header - 发起 uni.request - 响应拦截器检查状态 - 返回 Promise 给页面。页面永远不直接依赖 uni.request也不直接读取返回体里的原始结构。这样每一层职责单一出问题的时候定位范围非常小。2.3 Token 存储位置与刷新策略token 存哪、怎么刷新这个决策要前置。我们的方案是存uni.setStorageSync每次请求前同步读取。这个方案的优点是代码简单缺点是每次请求都有一次同步读 storage 的性能损耗但实测在业务项目里完全可以接受。刷新策略分两种场景简单场景401 后直接清 token 跳登录页复杂场景401 后自动调 refresh_token 接口拿新 token 重放原请求第二种场景实现复杂度高不少但体验提升明显。用户正在填写长表单token 过期了自动续期后请求继续用户完全无感。我在 3.4 节给出的是带请求队列的完整实现可以避免多个请求同时 401 时重复刷新 token 的问题。2.4 类型系统为什么泛型在这里是关键uni-app x 对 TypeScript 的支持比老版本 uni-app 好很多这是封装 request 的一个巨大红利。如果没有类型系统API 层返回的data是any页面里所有字段都只能靠运行时才知道对不对。有了泛型接口返回的数据结构在编译期就有完整提示。// 页面里调用 const user await getUserInfo(123) // user.name 有类型提示写错了会直接编译报错 console.log(user.name)这里关键是requestT方法返回PromiseT而不是PromiseApiResponseT。页面上直接拿到解包后的 data不用再写一遍.data。这个设计在响应拦截器里统一解包。我建议封装时一定把这一步做好体验差异非常大。3. 核心请求层代码实现从创建实例到拦截器挂载3.1 request 类的基本骨架先放一个完整的核心实现。这个类不依赖额外第三方库直接用 uni.request 封装在 uni-app xVue3 TS环境下可以直接跑。// utils/request/index.ts import { checkTokenAndRefresh } from ./auth type HttpMethod GET | POST | PUT | DELETE export interface RequestOptions { url: string method?: HttpMethod data?: Recordstring, any params?: Recordstring, any header?: Recordstring, string timeout?: number loading?: boolean loadingText?: string skipAuth?: boolean } export interface ApiResponseT any { code: number message: string data: T } export class Request { private baseURL: string private token constructor(baseURL: string) { this.baseURL baseURL } setToken(token: string) { this.token token } clearToken() { this.token } private buildQuery(url: string, params: Recordstring, any) { if (!params) return url const query Object.keys(params) .filter((key) params[key] ! undefined params[key] ! null) .map((key) ${encodeURIComponent(key)}${encodeURIComponent(params[key])}) .join() return query ? ${url}?${query} : url } requestT any(options: RequestOptions): PromiseT { const { url, method GET, data, params, header {}, timeout 15000, loading false, loadingText 加载中..., skipAuth false, } options if (loading) { uni.showLoading({ title: loadingText, mask: true }) } return new PromiseT((resolve, reject) { const requestHeader: Recordstring, string { Content-Type: application/json, ...header, } if (!skipAuth this.token) { requestHeader[Authorization] Bearer ${this.token} } const finalUrl this.buildQuery(${this.baseURL}${url}, params) uni.request({ url: finalUrl, method, data, header: requestHeader, timeout, success: (res) { this.handleSuccessT(res, resolve, reject) }, fail: (err) { this.handleFail(err, reject) }, complete: () { if (loading) { uni.hideLoading() } }, }) }) } private handleSuccessT any( res: UniApp.RequestSuccessCallbackResult, resolve: (value: T) void, reject: (reason?: any) void ) { const statusCode res.statusCode if (statusCode 200 || statusCode 300) { // 非 2xx 统一走网络异常 this.notifyError(网络异常请稍后重试) reject({ code: statusCode, message: Network Error }) return } // 兼容后端可能返回字符串的场景 let data: ApiResponseT res.data as ApiResponseT if (typeof res.data string) { try { data JSON.parse(res.data) as ApiResponseT } catch (e) { reject({ code: -1, message: 响应解析失败 }) return } } if (data.code 0) { resolve(data.data) } else { this.notifyError(data.message || 业务处理失败) reject({ code: data.code, message: data.message }) } } private handleFail(err: any, reject: (reason?: any) void) { // uni.request fail 分支通常是网络中断、超时等 const message err.errMsg?.includes(timeout) ? 请求超时 : 网络连接失败 this.notifyError(message) reject({ code: -1, message }) } private notifyError(message: string) { uni.showToast({ title: message, icon: none }) } getT any(url: string, params?: Recordstring, any, options?: PartialRequestOptions) { return this.requestT({ url, method: GET, params, ...options }) } postT any(url: string, data?: Recordstring, any, options?: PartialRequestOptions) { return this.requestT({ url, method: POST, data, ...options }) } putT any(url: string, data?: Recordstring, any, options?: PartialRequestOptions) { return this.requestT({ url, method: PUT, data, ...options }) } deleteT any(url: string, params?: Recordstring, any, options?: PartialRequestOptions) { return this.requestT({ url, method: DELETE, params, ...options }) } }代码看起来不长但每个分支都是真实项目里反复踩坑后沉淀下来的。下面拆开说几个关键设计。3.2 请求拦截动态注入 token 与公共参数请求拦截的逻辑在request方法里核心是每次调用 uni.request 前把当前 token 注入 header。这里有个容易忽略的点token不是每次都从uni.getStorageSync读而是启动时读一次内存里维护一份后续通过setToken更新。为什么不每次读 storage我在真机上对比过频繁同步读 storage 在低端安卓机上会有可感知的卡顿尤其在页面初始化时多个请求并发发出的场景。内存读取几乎没有成本但要注意 token 更新时机。我们项目里登录接口返回新 token 后调用request.setToken()同步内存其他接口如果触发了 401 自动刷新也要在刷新成功后同步更新内存。时序上稍微乱一点但逻辑是清晰的。还建议加一个platform公共参数。在请求拦截器里统一注入X-Platform: h5 | app | mp-weixin。这个字段在排查线上问题的时候非常有用同一套后端代码不同端的表现可能天差地别有了这个 header后端联调定位会快很多。3.3 响应拦截HTTP 状态、业务码与统一错误提示响应拦截在handleSuccess和handleFail里。重点说两个设计第一非 2xx 的 HTTP 状态码必须走统一错误分支。很多封装只判断 success 回调因为 uni.request 在 HTTP 500 时也会进 success此时 res.statusCode 是 500。如果不在 success 里判断 statusCode页面拿到一个 code 是 500 的返回还得自己做分支。统一处理之后页面永远不用关心 HTTP 层。第二统一错误提示收敛到notifyError。这里有一个弹窗节流问题如果页面同时发出 3 个请求全部失败uni.showToast会被覆盖最终只显示最后一个提示。如果需要更精细的控制可以在notifyError里做一次去重比如同一错误 message 在 3 秒内只弹一次。这个细节在请求量大时很影响用户体验。3.4 登录失效自动处理刷新 token 与请求队列重放这是整个封装里最复杂的部分也是体现拦截器价值的地方。方案设计如下所有请求在响应拦截器里遇到code 401检查当前是否已经有正在进行的刷新请求没有则发起 refresh_token 请求有则等待同一个刷新 Promise刷新成功后把等待队列里的原请求用新 token 重新发出刷新失败则清 token跳登录页核心代码// utils/request/auth.ts type PendingTask { resolve: (value: unknown) void reject: (reason?: any) void } let isRefreshing false let pendingQueue: PendingTask[] [] export async function handleTokenRefresh(request: Request, originalOptions: RequestOptions { retry?: boolean }) { if (isRefreshing) { // 已有刷新请求在进行中把当前请求挂起 return new Promise((resolve, reject) { pendingQueue.push({ resolve, reject }) }) } isRefreshing true try { const refreshToken uni.getStorageSync(refresh_token) const res await request.post(/auth/refresh, { refresh_token: refreshToken, }, { skipAuth: true, loading: false }) const newToken (res as any).token request.setToken(newToken) uni.setStorageSync(token, newToken) // 重放等待队列 pendingQueue.forEach((task) task.resolve(undefined)) pendingQueue [] return undefined } catch (e) { pendingQueue.forEach((task) task.reject(e)) pendingQueue [] request.clearToken() uni.removeStorageSync(token) uni.reLaunch({ url: /pages/login/index }) throw e } finally { isRefreshing false } }然后在request方法里当响应拦截器遇到 401 时做一次重试// 伪代码展示重试逻辑 if (data.code 401) { if (options.skipAuth || options.retry) { reject({ code: 401, message: 登录失效 }) return } const refreshResult await handleTokenRefresh(this, options) if (refreshResult ! undefined) { // 刷新成功重放原请求 this.request({ ...options, retry: true }) .then(resolve) .catch(reject) } return }这个方案最核心的价值是并发场景下的只刷新一次 token。如果没有 Promise 队列三个请求同时 401会触发三个 refresh 请求后端如果对 refresh_token 有频率限制第二个请求大概率失败导致用户莫名其妙被登出。4. API 接口的模块化管理告别散落的接口字符串4.1 按业务域拆分的接口模块封装好 request 层之后下一步是业务层的 API 管理。我见过有些项目把所有接口写在一个api.ts里几百个方法堆在一起找起来非常崩溃。更好的做法是按业务域拆分目录src/ ├── api/ │ ├── index.ts │ ├── modules/ │ │ ├── user.ts │ │ ├── order.ts │ │ ├── payment.ts │ │ └── message.ts每个模块只导出与自身业务相关的方法页面只 import 自己需要的模块。这样不仅找接口方便更重要的是模块之间天然隔离不会出现 A 业务误改 B 业务请求的问题。// api/modules/user.ts import { request } from /utils/request export interface UserInfo { id: string name: string avatar: string phone: string } export const getUserInfo (id: string) request.getUserInfo(/user/info, { id }) export const updateUserInfo (data: PartialUserInfo) request.putUserInfo(/user/info, data)页面里直接import { getUserInfo } from /api/modules/user调用体验跟调本地方法一样完全不用关心 URL、method、header 这些细节。4.2 接口方法统一返回 Promise 类型这里再说一下泛型的好处。getUserInfo返回的是PromiseUserInfo页面拿到的是UserInfo对象res.id、res.name都有完整类型推导。如果 API 返回的数据结构有变化比如phone改成了mobile编译阶段就会报错而不是线上运行才发现。这个红利需要后端配合。最好的方式是后端提供 OpenAPISwagger文档前端写个小脚本把接口定义解析成 TS 类型。我们没有这么先进目前是手工维护类型定义但即便如此收益也远大于成本。手工维护时注意一个规律凡是类型定义清楚的模块后期 bug 数量明显低于那些用any糊过去的模块。// 反面教材不推荐 export const getUserInfo (id: string) request.getany(/user/info, { id })any一旦出现类型提示全部失效等于又退回到裸用 uni.request 的状态。4.3 多个后端域名共存的配置方案项目里除了主业务接口还有独立的文件上传服务、数据报表服务域名不同baseURL 也不同。封装的时候不能把 baseURL 写死成一个而是做成一个域名映射表。// utils/request/config.ts export const API_DOMAINS { main: https://api.example.com, upload: https://upload.example.com, report: https://report.example.com, } as const export type ApiDomainKey keyof typeof API_DOMAINS请求方法支持传入 domain 参数export const uploadFile (filePath: string) request.upload(API_DOMAINS.upload, filePath)还有一种更隐蔽的多域名场景H5 端页面部署在 A 域名接口服务在 B 域名开发环境下需要跨域代理预发环境又可能指向 C 域名。这种情况下建议把域名配置跟环境变量绑定而不是写死在代码里。vite 项目可以用import.meta.env.VITE_API_BASE_URL注意不同端对环境变量的读取方式有差异App 端需要打包时静态注入。5. 实战踩坑记录header 过大、流式输出与主动取消5.1 request header is too large一场与本地存储的拉锯战上线后陆续有用户反馈部分接口报错后端日志显示request header is too large或者 HTTP 431。最初我以为是后端 Nginx 限制太严格查了一圈发现真正的原因很狗血Authorization 里带的 token 越来越大加上我们服务端在 header 里塞了太多自定义字段整体 header 大小超过了几台伙伴的默认限制。排查过程是倒序的先看后端 Nginx 配置large_client_header_buffers默认是4 8k我们并没有超过抓包看真实 header 大小发现请求头差不多 12KB远超默认限制发现 token 本身就有 3KB 多JWT 里塞了一些不必要的声明而业务代码里又往 header 加了一个巨大的用户画像对象修复方案token 精简、去掉冗余 header 字段、后端放宽large_client_header_buffers这个坑给封装层的启发是拦截器里不要往 header 塞跟鉴权无关的大对象。有段时间我图省事把用户偏好设置整个塞进了 header后果就是 header 膨胀。规范做法是header 只放身份认证和客户端标识类数据其余业务参数全部走 data 或 params。5.2 多域名场景下 baseURL 的切换陷阱我做多域名配置时踩过一个很隐蔽的坑登录接口走的是主域名但 token 刷新接口在测试环境用的是另一个域名。结果 401 自动刷新逻辑上线后刷新接口一直请求失败所有用户被登出。原因是Request类构造函数里把baseURL固化成了主域名刷新请求复用了同一个实例但它应该走认证域的地址。修复很简单request方法支持domainKey覆盖 baseURLrequestT(options: RequestOptions { domainKey?: ApiDomainKey }): PromiseT { const baseURL options.domainKey ? API_DOMAINS[options.domainKey] : this.baseURL // ... }这个教训说明多域名不是简单地在 API 层做区分底层 request 也要保留覆盖 baseURL 的能力否则遇到跨域场景只能干瞪眼。5.3 大模型流式输出SSE如何配合 abort 主动中断项目里接了大模型问答功能需要流式实时展示回答。这跟普通请求差别很大不是一次性拿到整个响应而是要持续接收内容片段。uni.request 默认不支持流式需要开启enableChunked参数H5 和 App 端可用小程序部分版本支持然后监听onChunkReceived。const task uni.request({ url: API_DOMAINS.main /ai/chat, method: POST, data: { prompt: 你好 }, enableChunked: true, success: (res) { // 流式结束后的最终回调 }, }) // 持续收到的数据块 task.onChunkReceived((response) { const bytes new Uint8Array(response.data as ArrayBuffer) const text decodeURIComponent(escape(String.fromCharCode(...bytes))) // 将 text 追加到页面 }) // 用户点击停止按钮时主动中断 uni.showModal({ title: 停止生成, success: (res) { if (res.confirm) { task.abort() } }, })这里最容易踩的坑是onChunkReceived拿到的数据可能是乱序的也可能是拼包。实际项目中后端返回的每一段文本可能是半个 UTF-8 字符直接解码会出现乱码。我用了一个简单的队列累积 Buffer在拼接字符时判断是否完整再进行渲染有效解决了乱码问题。主动取消这块用的是task.abort()。注意 abort 之后success回调不会执行也不会走fail而是直接触发complete。如果要区分用户取消和正常结束两种状态建议在调用 abort 前用一个 flag 标记在 complete 里判断这个 flag 决定后续 UI 更新。6. 请求层扩展技巧轮询、并发合并与请求缓存6.1 基于封装层的轮询与心跳检测有些业务场景需要周期性请求比如订单状态轮询、在线状态心跳。直接在页面里写setIntervaluni.request也能跑但脱离封装层之后会有几个隐患页面隐藏时 timer 不清理、token 刷新后轮询请求没有感知。我的做法是在封装层提供一个简单的轮询辅助方法// utils/request/polling.ts export function startPollingT( requestFn: () PromiseT, interval 5000, options: { immediate?: boolean; maxCount?: number } {} ) { let timer: ReturnTypetypeof setInterval | null null let count 0 const stop () { if (timer) { clearInterval(timer) timer null } } const start async () { stop() if (options.maxCount count options.maxCount) { return } if (options.immediate) { await requestFn() } timer setInterval(async () { count try { await requestFn() } catch (e) { // 单次轮询失败不中断 } }, interval) } return { start, stop } }页面 onShow 里调用 startonHide 里调用 stop配合生命周期管理避免页面不可见时还在空转请求。6.2 多个请求的并发合并策略页面初始化经常需要同时拉用户信息和订单列表。如果分开请求页面要处理两个异步状态还要分别处理错误。封装层可以提供一个并发工具export function allSettledT extends readonly unknown[]( promises: [...{ [K in keyof T]: PromiseT[K] }] ): Promise{ [K in keyof T]: { status: fulfilled; value: T[K] } | { status: rejected; reason: any } } { return Promise.all( promises.map((p) p.then( (value) ({ status: fulfilled as const, value }), (reason) ({ status: rejected as const, reason }) ) ) ) }这个工具解决的核心问题是一个请求失败不应该阻塞另一个成功的渲染。比如用户信息拉取成功但订单列表因为后端问题失败了页面至少可以先渲染用户信息订单区域再单独做重试。另外还有个并发合并的场景表格页面的 tab 快速切换时前面 tab 的请求还没回来就被切换走了返回后可能干扰当前 tab 的渲染。这种竞态问题需要缓存 latest 标记只有最新一次请求的结果才允许更新页面状态。6.3 接口缓存与请求去重对于短时间内相同参数的重复请求缓存是关键优化。比如用户进入商品详情页时滚动到底部触发推荐位请求用户快速上滑又触发一次函数节流可以解决一部分但更彻底的是请求层去重相同 url 相同参数在短时间内只发一次后面的请求直接复用前一个 Promise。const pendingMap new Mapstring, Promiseany() export function requestDedupeT(key: string, requestFn: () PromiseT): PromiseT { if (pendingMap.has(key)) { return pendingMap.get(key) } const promise requestFn().finally(() { pendingMap.delete(key) }) pendingMap.set(key, promise) return promise }用法const data await requestDedupe(getUserInfo:123, () request.get(/user/info, { id: 123 }) )这个方案同样可以处理页面 onShow 反复触发的问题。在页面 onShow 里调用接口返回后 onHide再 onShow 又调用一次如果上一次请求还没完成直接返回同一个 Promise避免重复请求。请求完成后删除缓存下一次 onShow 会重新发请求保证数据新鲜度。另外还可以加一层更激进的缓存相同接口短时间内的成功响应直接走内存缓存不重新发请求。这个要看业务场景如果对数据新鲜度要求高比如订单金额不建议使用长时间缓存如果是商品分类这种基本不变的数据可以缓存几分钟。我把上面这些能力都收口在封装层之后页面的网络代码变得非常干净。拿我的经验来说封装 request 不是多此一举而是项目规模到达一定程度之后的必然选择。核心思路就是把公共能力向上收拢把业务差异向下透出每一层都做纯粹的事。如果你正在 uni-app x 项目里为散落的请求调用头疼照着这套方案改造很快就能感受到差别。