uniapp网络层封装从崩溃到99.9%成功率

📅 2026/8/3 6:20:29
uniapp网络层封装从崩溃到99.9%成功率
uniapp网络层封装从崩溃到99.9%成功率一、问题场景还原小雅所在团队维护一个跨端电商SaaS产品覆盖微信小程序、支付宝小程序、AppiOSAndroid和H5四端。项目初期直接使用uni.request未做统一封装。随着业务从C端扩展到B端商家后台、数据看板网络层问题集中爆发事故时间线时间事件根因第1个月用户反馈登录后闪退回登录页Token过期后未自动刷新多接口并发时各自触发401跳转第2个月商家后台数据看板首次加载需12秒页面同时发起23个请求小程序端10并发限制触发排队第3个月iOS端偶发创建订单请求body为空uni.request的data参数在iOS上对嵌套对象的序列化与Android不一致事后复盘问题根源在于缺少一个跨端统一、具备拦截器、Token管理、并发控制、重试降级能力的请求封装层。二、技术选型对比在决定自研封装前团队评估了社区三种主流方案方案拦截器Token刷新并发控制请求去重缓存包体积跨端一致性uni.request原生❌❌❌❌❌0KB基础一致axios-miniprogram-adapter✅❌❌❌❌~18KBH5偏好小程序适配层不稳定luch-request✅✅❌❌❌~12KB较好但并发控制和缓存需自行扩展自研封装✅✅✅✅✅~4KB完全可控选型结论luch-request在小程序和App端表现尚可但其拦截器设计不支持异步Token刷新场景下的请求队列挂起——即多个并发401时无法保证只刷新一次Token并重放所有请求。自研方案的核心价值在于完全掌控Token刷新队列、请求去重和并发控制这三个业务痛点的实现细节。三、架构设计┌─────────────────────────────────────────────────────┐ │ 业务层 (API Modules) │ │ userApi.getUserInfo() orderApi.createOrder() │ ├─────────────────────────────────────────────────────┤ │ HttpRequest (核心类) │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 拦截器链 │ │ Token管理 │ │ 请求去重 │ │ │ │ request │ │ 刷新队列 │ │ pending │ │ │ │ response │ │ 降级策略 │ │ Map │ │ │ │ error │ │ │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 重试机制 │ │ 缓存层 │ │ 并发控制 │ │ │ │ 指数退避 │ │ TTLLRU │ │ 限流器 │ │ │ └──────────┘ └──────────┘ └──────────┘ │ ├─────────────────────────────────────────────────────┤ │ uni.request (平台原生) │ │ wx.request │ plus.net.XMLHttpRequest │ fetch │ └─────────────────────────────────────────────────────┘四、核心实现4.1 主类骨架与JSDoc标注/** * typedef {Object} RequestConfig * property {string} url - 请求地址相对路径或绝对路径 * property {string} [methodGET] - 请求方法 * property {Object} [data] - 请求参数 * property {Object} [header] - 自定义请求头 * property {number} [timeout15000] - 超时时间(ms) * property {number} [retry0] - 失败重试次数 * property {number} [retryDelay1000] - 重试间隔(ms) * property {boolean} [cachefalse] - 是否启用缓存 * property {number} [cacheTTL60000] - 缓存有效期(ms) */ class HttpRequest { constructor() { /** type {string} API基础路径 */ this.baseUrl https://api.example.com/v1 /** type {number} 默认超时时间 */ this.timeout 15000 /** type {{request: Function[], response: Function[], responseError: Function[]}} */ this.interceptors { request: [], response: [], responseError: [] } /** type {boolean} 是否正在刷新Token防止并发刷新 */ this.isRefreshing false /** type {Function[]} 等待Token刷新的请求重放队列 */ this.refreshQueue [] /** type {Mapstring, Promise} 请求去重Map */ this.pendingMap new Map() /** type {Mapstring, {data: *, timestamp: number}} 响应缓存Map */ this.cacheMap new Map() this._setupDefaultInterceptors() } // ... 方法见下文 } /** type {HttpRequest} 单例导出 */ const http new HttpRequest() export default http4.2 Token无感刷新 —— 请求队列挂起机制这是整个封装中最关键的部分。当多个接口同时返回401时必须保证只发起一次Token刷新请求其他401请求挂起等待刷新成功后统一重放。时间线3个请求同时触发401 ────────────────────────────────────────────────── 请求A → 401 → 发起刷新TokenisRefreshingtrue 请求B → 401 → 进入refreshQueue等待 请求C → 401 → 进入refreshQueue等待 ↓ Token刷新成功 ↓ 请求A 重试 → 成功 请求B 重试 → 成功 请求C 重试 → 成功 ──────────────────────────────────────────────────/** * Token过期处理 —— 核心队列挂起 唯一刷新 批量重放 * param {RequestConfig} config - 原始请求配置 * returns {Promise} */ async _handleTokenExpired(config) { if (this.isRefreshing) { // 关键点1已有刷新进行中将当前请求挂起到队列 return new Promise((resolve) { this.refreshQueue.push((newToken) { config.header config.header || {} config.header[Authorization] Bearer ${newToken} resolve(this._doRequest(config)) }) }) } this.isRefreshing true try { const refreshToken uni.getStorageSync(refresh_token) if (!refreshToken) { this._redirectToLogin() return Promise.reject(new Error(REFRESH_TOKEN_MISSING)) } const res await this._rawRequest({ url: ${this.baseUrl}/auth/refresh, method: POST, data: { refreshToken }, timeout: 10000, _skipAuth: true // 标记跳过Token注入和401拦截防止递归 }) if (res.statusCode 200 res.data.code 0) { const { accessToken, refreshToken: newRefreshToken } res.data.data // 持久化新Token uni.setStorageSync(access_token, accessToken) uni.setStorageSync(refresh_token, newRefreshToken) // 关键点2更新当前请求的Token并重试 config.header config.header || {} config.header[Authorization] Bearer ${accessToken} // 关键点3批量重放队列中的所有请求 this.refreshQueue.forEach(callback callback(accessToken)) this.refreshQueue [] return this._doRequest(config) } // 刷新失败清空队列统一跳转登录 this.refreshQueue [] this._redirectToLogin() return Promise.reject(new Error(TOKEN_REFRESH_FAILED)) } catch (error) { this.refreshQueue [] this._redirectToLogin() return Promise.reject(error) } finally { this.isRefreshing false } }4.3 请求去重 —— 相同请求共享Promise/** * 生成请求唯一标识 * param {RequestConfig} config * returns {string} 请求标识 e.g. GET:/user/info:{id:123} */ _generateRequestKey(config) { const { url, method, data } config return ${method}:${url}:${JSON.stringify(data || {})} } /** * 核心请求执行带去重 * param {RequestConfig} config * returns {Promise} */ _doRequest(config) { const key this._generateRequestKey(config) // 如果已有相同请求进行中复用其Promise if (this.pendingMap.has(key)) { return this.pendingMap.get(key) } const promise this._rawRequest(config).finally(() { // 请求完成成功或失败后从pendingMap移除 this.pendingMap.delete(key) }) this.pendingMap.set(key, promise) return promise }4.4 响应缓存 —— TTL 手动失效/** * 带缓存的请求入口 * param {RequestConfig} config * returns {Promise} */ async request(config) { // 缓存命中判断 if (config.cache) { const cacheKey this._generateRequestKey(config) const cached this.cacheMap.get(cacheKey) const ttl config.cacheTTL || 60000 if (cached (Date.now() - cached.timestamp) ttl) { return cached.data } } const result await this._doRequestWithInterceptors(config) // 写入缓存 if (config.cache) { const cacheKey this._generateRequestKey(config) this.cacheMap.set(cacheKey, { data: result, timestamp: Date.now() }) } return result } /** * 清除匹配url的缓存 * param {string} urlPattern - URL匹配模式 */ clearCache(urlPattern) { for (const key of this.cacheMap.keys()) { if (key.includes(urlPattern)) { this.cacheMap.delete(key) } } }4.5 指数退避重试/** * 判断是否应该重试 * param {Object} error - 错误对象 * returns {boolean} */ _shouldRetry(error) { // 网络层错误超时、DNS解析失败、连接拒绝 if (error.errMsg) { return /timeout|fail|abort/.test(error.errMsg) } // HTTP 5xx 服务端错误 if (error.statusCode 500 error.statusCode 600) { return true } // HTTP 429 限流 if (error.statusCode 429) { return true } return false } async _doRequestWithInterceptors(config) { const maxRetries config.retry || 0 const baseDelay config.retryDelay || 1000 let lastError for (let attempt 0; attempt maxRetries; attempt) { try { return await this._executeWithInterceptors(config) } catch (error) { lastError error if (!this._shouldRetry(error) || attempt maxRetries) { throw error } // 指数退避1s, 2s, 4s ... await new Promise(r setTimeout(r, baseDelay * Math.pow(2, attempt))) } } throw lastError }4.6 业务层API组织// api/order.js import http from /utils/http.js /** namespace orderApi */ export const orderApi { /** * 获取订单列表缓存30秒减少重复请求 * param {Object} params * returns {PromiseArray} */ getList(params) { return http.get(/order/list, params, { cache: true, cacheTTL: 30000 }) }, /** * 创建订单 —— 关键业务开启2次重试 * param {Object} data - 订单数据 * returns {PromiseObject} */ create(data) { return http.post(/order/create, data, { retry: 2, retryDelay: 1500 }) } }五、踩坑清单以下8个坑来自团队3个月的实际调试记录坑1iOS端POST请求body偶发为空现象iOS 15.x设备上uni.request发送POST请求时data参数偶发未序列化到请求body。Android和H5正常。根因uni.request在iOS底层调用NSURLSession时对某些嵌套对象结构的JSON.stringify结果校验不一致。当data中包含undefined值的字段时JSON.stringify会将其省略导致后端收到的body结构与预期不同。修复// 在请求拦截器中递归清理 undefined 值 function sanitizeObject(obj) { if (obj null || typeof obj ! object) return obj if (Array.isArray(obj)) return obj.map(sanitizeObject) const cleaned {} for (const [key, value] of Object.entries(obj)) { if (value ! undefined) { cleaned[key] sanitizeObject(value) } } return cleaned } this.useRequestInterceptor((config) { if (config.data) { config.data sanitizeObject(config.data) } return config })坑2小程序环境process.env被注入导致条件编译失效现象某次构建后H5端代码中出现了wx命名空间相关调用而H5代码中明确用#ifdef H5包裹了。根因第三方npm包某加密库内部通过process.env.UNI_PLATFORM做运行时判断在小程序环境下该变量被注入为mp-weixin打包到H5产物后残留了判断分支。修复排查所有依赖将运行时平台判断迁移到编译时条件编译// ❌ 依赖包中常见写法 —— 运行时判断 if (process.env.UNI_PLATFORM mp-weixin) { /* ... */ } // ✅ 自研代码中统一用编译时条件编译 // #ifdef MP-WEIXIN // ... // #endif坑3Token刷新接口本身返回401导致死循环现象refresh_token也过期时_handleTokenExpired中调用刷新接口再次返回401触发递归调用浏览器标签页卡死。修复刷新接口增加_skipAuth标记跳过拦截器中的401处理见4.2节代码中的_skipAuth: true标记。坑4uni.request超时后success回调仍可能执行现象设置timeout: 50005秒后触发fail回调超时但8秒后success回调也执行了导致Promise状态异常。根因底层网络请求超时后uni.request的fail和success并非互斥。TCP连接超时触发fail但HTTP响应在超时后到达时仍可能触发success。修复自行维护请求完成状态_rawRequest(config) { return new Promise((resolve, reject) { let settled false uni.request({ ...config, success: (res) { if (!settled) { settled true; resolve(res) } }, fail: (err) { if (!settled) { settled true; reject(err) } } }) }) }坑5uni.getStorageSync在App端冷启动偶发返回空字符串现象App冷启动后首次调用uni.getStorageSync(access_token)返回空字符串而非null。导致请求头携带Authorization: Bearer空字符串后端返回400而非401无法触发Token刷新。修复在拦截器中加入空值校验this.useRequestInterceptor((config) { const token uni.getStorageSync(access_token) // 关键排除空字符串 if (token token.trim() ! ) { config.header config.header || {} config.header[Authorization] Bearer ${token} } return config })坑6H5端setTimeout在页面后台时被浏览器节流现象H5端用户切换到其他标签页后返回发现大量请求堆积。原因是Token刷新失败后的等待重试逻辑依赖setTimeout而Chrome在后台标签页中将setTimeout最小间隔节流到1000ms。修复重试逻辑使用Date.now()计时 setTimeout配合不依赖setTimeout的精确计时。坑7并发控制未考虑上传请求现象并发限流器只限制了uni.request未限制uni.uploadFile。商家后台批量上传商品图片一次20张时uploadFile绕过并发控制低端机型内存溢出。修复将uploadFile和downloadFile纳入统一并发管理。坑8Android 8.x设备上BigInt序列化报错现象订单金额使用BigInt类型后端要求在Android 8.x上JSON.stringify抛出TypeError: Do not know how to serialize a BigInt。修复请求拦截器中全局处理特殊类型序列化function safeStringify(obj) { return JSON.stringify(obj, (key, value) typeof value bigint ? value.toString() : value ) }六、性能基准测试以下数据来自同一台iPhone 12iOS 16.5网络环境4G重复测试10次取均值指标原生uni.requestluch-request自研封装提升(vs原生)100次GET请求完成时间18.2s19.1s16.4s9.9%并发20请求内存峰值48MB52MB34MB-29.2%重复请求(去重前/后)320次320次87次-72.8%Token过期恢复时间(3并发401)3.8s(各刷)2.1s1.3s-65.8%弱网(2G模拟)成功率71.3%74.5%97.2%36.3%包体积增量0KB12KB3.8KB-弱网成功率提升原因自研封装内置3次指数退避重试luch-request默认不重试原生无重试机制。重复请求减少原因去重Map在组件重复挂载、用户快速点击等场景下直接复用进行中的Promise。七、延伸思考7.1 请求封装的边界并非所有网络通信都适合纳入统一的请求封装WebSocket连接长连接的生命周期管理、心跳、重连策略与HTTP短请求完全不同。建议独立封装WebSocketManager类仅复用Token管理逻辑。SSEServer-Sent Events服务端推送事件流uni.request无法支持需使用原生EventSourceH5或socketTask的流式模式。大文件分片上传需要断点续传、分片并发、进度聚合等能力与普通请求的发完即忘模型差异大应独立封装。7.2 离线优先策略对于B端数据看板等场景可考虑在缓存层之上实现缓存优先 后台更新用户请求 → 立即返回缓存数据如有 → 同时发起网络请求 → 网络响应到达后更新缓存 通知UI刷新这种模式在弱网环境下可让用户立即看到上次的数据避免长时间白屏等待。八、总结一个健壮的uniapp网络层封装需要解决的核心问题按优先级排序Token无感刷新 并发401队列挂起用户体验的底线。统一的错误处理和提示避免业务代码散落try/catchshowToast。请求去重减少无效网络请求降低服务端压力。弱网重试移动端网络环境多变2次指数退避重试可将成功率从71%提升到97%。并发控制小程序端有硬性限制忽略会导致请求排队超时。缓存策略对字典类、配置类数据非常有效实时性要求高的数据不应缓存。封装不是一次性工作。建议每个迭代结束后审视请求日志发现新的边界case后持续完善。上述代码已在微信小程序基础库2.24、支付宝小程序、AppiOS 12/Android 8和H5四端验证通过。