当前位置: 首页 > news >正文

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

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

一、问题场景还原

小雅所在团队维护一个跨端电商SaaS产品,覆盖微信小程序、支付宝小程序、App(iOS+Android)和H5四端。项目初期直接使用uni.request,未做统一封装。随着业务从C端扩展到B端(商家后台、数据看板),网络层问题集中爆发:

事故时间线

时间事件根因
第1个月用户反馈"登录后闪退回登录页"Token过期后未自动刷新,多接口并发时各自触发401跳转
第2个月商家后台数据看板首次加载需12秒页面同时发起23个请求,小程序端10并发限制触发排队
第3个月iOS端偶发"创建订单"请求body为空uni.requestdata参数在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 │ │ │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 重试机制 │ │ 缓存层 │ │ 并发控制 │ │ │ │ 指数退避 │ │ TTL+LRU │ │ 限流器 │ │ │ └──────────┘ └──────────┘ └──────────┘ │ ├─────────────────────────────────────────────────────┤ │ uni.request (平台原生) │ │ wx.request │ plus.net.XMLHttpRequest │ fetch │ └─────────────────────────────────────────────────────┘

四、核心实现

4.1 主类骨架与JSDoc标注

/** * @typedef {Object} RequestConfig * @property {string} url - 请求地址(相对路径或绝对路径) * @property {string} [method='GET'] - 请求方法 * @property {Object} [data] - 请求参数 * @property {Object} [header] - 自定义请求头 * @property {number} [timeout=15000] - 超时时间(ms) * @property {number} [retry=0] - 失败重试次数 * @property {number} [retryDelay=1000] - 重试间隔(ms) * @property {boolean} [cache=false] - 是否启用缓存 * @property {number} [cacheTTL=60000] - 缓存有效期(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 {Map<string, Promise>} 请求去重Map */ this.pendingMap = new Map() /** @type {Map<string, {data: *, timestamp: number}>} 响应缓存Map */ this.cacheMap = new Map() this._setupDefaultInterceptors() } // ... 方法见下文 } /** @type {HttpRequest} 单例导出 */ const http = new HttpRequest() export default http

4.2 Token无感刷新 —— 请求队列挂起机制

这是整个封装中最关键的部分。当多个接口同时返回401时,必须保证只发起一次Token刷新请求,其他401请求挂起等待,刷新成功后统一重放。

时间线(3个请求同时触发401): ────────────────────────────────────────────────── 请求A → 401 → 发起刷新Token(isRefreshing=true) 请求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 {Promise<Array>} */ getList(params) { return http.get('/order/list', params, { cache: true, cacheTTL: 30000 }) }, /** * 创建订单 —— 关键业务,开启2次重试 * @param {Object} data - 订单数据 * @returns {Promise<Object>} */ create(data) { return http.post('/order/create', data, { retry: 2, retryDelay: 1500 }) } }

五、踩坑清单

以下8个坑来自团队3个月的实际调试记录:

坑1:iOS端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

坑3:Token刷新接口本身返回401导致死循环

现象:refresh_token也过期时,_handleTokenExpired中调用刷新接口再次返回401,触发递归调用,浏览器标签页卡死。

修复:刷新接口增加_skipAuth标记,跳过拦截器中的401处理(见4.2节代码中的_skipAuth: true标记)。

坑4:uni.request超时后success回调仍可能执行

现象:设置timeout: 5000,5秒后触发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) } } }) }) }

坑5:uni.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 })

坑6:H5端setTimeout在页面后台时被浏览器节流

现象:H5端用户切换到其他标签页后返回,发现大量请求堆积。原因是Token刷新失败后的"等待重试"逻辑依赖setTimeout,而Chrome在后台标签页中将setTimeout最小间隔节流到1000ms。

修复:重试逻辑使用Date.now()计时 +setTimeout配合,不依赖setTimeout的精确计时。

坑7:并发控制未考虑上传请求

现象:并发限流器只限制了uni.request,未限制uni.uploadFile。商家后台批量上传商品图片(一次20张)时,uploadFile绕过并发控制,低端机型内存溢出。

修复:将uploadFiledownloadFile纳入统一并发管理。

坑8:Android 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 12,iOS 16.5,网络环境4G,重复测试10次取均值:

指标原生uni.requestluch-request自研封装提升(vs原生)
100次GET请求完成时间18.2s19.1s16.4s+9.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管理逻辑。
  • SSE(Server-Sent Events):服务端推送事件流,uni.request无法支持,需使用原生EventSource(H5)或socketTask的流式模式。
  • 大文件分片上传:需要断点续传、分片并发、进度聚合等能力,与普通请求的"发完即忘"模型差异大,应独立封装。

7.2 离线优先策略

对于B端数据看板等场景,可考虑在缓存层之上实现"缓存优先 + 后台更新":

用户请求 → 立即返回缓存数据(如有) → 同时发起网络请求 → 网络响应到达后更新缓存 + 通知UI刷新

这种模式在弱网环境下可让用户立即看到上次的数据,避免长时间白屏等待。


八、总结

一个健壮的uniapp网络层封装需要解决的核心问题,按优先级排序:

  1. Token无感刷新 + 并发401队列挂起:用户体验的底线。
  2. 统一的错误处理和提示:避免业务代码散落try/catch+showToast
  3. 请求去重:减少无效网络请求,降低服务端压力。
  4. 弱网重试:移动端网络环境多变,2次指数退避重试可将成功率从71%提升到97%。
  5. 并发控制:小程序端有硬性限制,忽略会导致请求排队超时。
  6. 缓存策略:对字典类、配置类数据非常有效,实时性要求高的数据不应缓存。

封装不是一次性工作。建议每个迭代结束后审视请求日志,发现新的边界case后持续完善。上述代码已在微信小程序(基础库2.24+)、支付宝小程序、App(iOS 12+/Android 8+)和H5四端验证通过。

http://www.cnnetsun.cn/news/3809369.html

相关文章:

  • 【Bug已解决】Bug in accelerator.unwrap_model 解决方案
  • LayUi表格下拉框卡顿优化:从DOM爆炸到虚拟滚动的性能调优实战
  • 【Bug已解决】Feature request: FSDP2 QLoRA 解决方案
  • 百度网盘提取码智能获取:5分钟从零到精通的完整指南
  • 降AIGC新时代来临!全网工具实测雷达图与智能选型助手
  • SpringBoot构建校园二手交易平台架构与优化实践
  • Keepalived 高可用集群部署与配置实践
  • OpenStack核心架构与生产环境部署实战指南
  • 局域网监控工具全解析:从基础到进阶实战
  • 网盘直链下载助手终极教程:让8大网盘下载速度提升10倍的秘密武器
  • ECM与MEMS麦克风选型指南:从原理到实战避坑
  • 基于SwiftUI与Python混合架构的Mac端AI音频工具开发实战
  • 前沿技术借鉴研讨-2026.7.30(妊娠自杀未遂风险的性别差异/妊娠期高血压共病风险)
  • Android源码Aosp环境搭建
  • 射频电路设计:0-360°连续可调反射型移相器实现与调试指南
  • SCD41三合一环境传感器:NDIR原理、Arduino驱动与物联网应用实战
  • SFTPGo部署与配置全攻略:从Docker到系统包安装
  • UHF RFID技术在电动车智能管理中的应用与实践
  • AI写作优化:去除机械感提升内容流量的实用技巧
  • 颠覆认知!无需篡改请求,仅拦截响应即可实现验证码劫持(附仿真实验)
  • 基于开源LLM与TTS技术搭建AI内容直播流:从Claude FM到本地模拟实现
  • 5分钟快速上手Ship of Harkinian:在现代PC上重温塞尔达时之笛的终极指南
  • 天津 GEO 优化是做什么的?面向本地企业的 AI 生成式引擎优化落地解析
  • 3个技术突破:如何用GHelper轻量级工具解决华硕笔记本硬件控制痛点
  • RAGflow 深度实践:从零构建私有知识库的完整指南
  • Codex Agent 进阶指南:线程上下文与 Skill 机制解锁 AI 编程助手
  • Python实战临床预测模型:三天掌握数据清洗、逻辑回归与模型评估全流程
  • 上下文管理——Agent 的「工作记忆」
  • 使用Cheat Engine修改《植物大战僵尸》游戏数据的完整指南
  • AIGC检测到底准不准?2026年主流检测系统深度实测