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四端验证通过。

相关新闻

从“看起来真实”到“真的能行动”:PAI-Bench、WorldModelBench 与 DreamGen Bench 具身世界模型评测全景指南

从“看起来真实”到“真的能行动”:PAI-Bench、WorldModelBench 与 DreamGen Bench 具身世界模型评测全景指南

一、先给出全局结论 PAI-Bench、WorldModelBench 和 DreamGen Bench 都在回答同一个根本问题: 一个模型生成的视频很逼真,是否意味着它真的理解了物理世界,并能帮助机器人做出正确行动? 答案通常是:不一定。 三者分别从不同层次检验“世界模型”: 基准 核心问题 主要评…

2026/10/5 18:33:03 阅读更多 →
PTC加热器是什么,为什么工业采暖里离不开它

PTC加热器是什么,为什么工业采暖里离不开它

为什么你的工厂还在用易起火的传统电热管? 冬天一到,北方许多工业厂房、物流仓库就开始为采暖发愁:传统电热管一旦风机停转就可能干烧起火,能耗高得吓人,还经常在低温下启动失败。其实,一种更安全、更智能的…

2026/10/13 11:50:30 阅读更多 →
G1水流传感器全解析:从原理选型到物联网集成实战

G1水流传感器全解析:从原理选型到物联网集成实战

1. 项目概述:从“G1”的混淆到水流测量的核心最近在几个技术社区和电商平台上,看到不少朋友在讨论“G1水流传感器”。有趣的是,当你在搜索引擎里输入“G1”时,跳出来的结果五花八门,有聊Java虚拟机里G1垃圾回收器的&am…

2026/10/13 9:25:59 阅读更多 →

最新新闻

ManagedSpy实战:撬开.NET WinForms界面黑匣子的调试利器

ManagedSpy实战:撬开.NET WinForms界面黑匣子的调试利器

简介:ManagedSpy for .NET 4.5 是一款面向 .NET Framework 4.5 的托管代码调试与分析工具,旨在帮助开发者实时监控 .NET 应用运行时行为、内存分配与性能问题,无需重新编译或附加目标进程即可完成检查,适合需要深入理解运行时机制…

2026/10/14 7:02:34 阅读更多 →
Vue3 + VueFlow 企业级审批流程图设计器实战指南

Vue3 + VueFlow 企业级审批流程图设计器实战指南

做企业级审批流程设计器的时候,很多人第一反应就是“画节点连线条线”,但真上手之后才会发现,一个能落地到生产环境的流程图可视化,远不止画图这么简单。这篇文章我基于 Vue3 JavaScript 技术栈,用 VueFlow 这个开源库…

2026/10/14 7:02:34 阅读更多 →
OpenDriveVLA:统一视觉-语言-动作的自动驾驶认知模型

OpenDriveVLA:统一视觉-语言-动作的自动驾驶认知模型

1. 项目概述:当自动驾驶的“眼睛”开始“思考”与“规划”最近在刷论文预印平台时,看到一篇标题特别扎眼的 work:AAAI 2026 | 慕尼黑团队 OpenDriveVLA:多阶段训练范式,统一驾驶规划与 VQA 推理任务。光看标题里这几个…

2026/10/14 7:02:34 阅读更多 →
QT与QXlsx实现高效Excel操作的完整指南

QT与QXlsx实现高效Excel操作的完整指南

1. 项目概述:QT与QXlsx的Excel操作方案在数据处理领域,Excel文件的操作一直是刚需。传统方案依赖微软COM组件或Python库,但在C生态中一直缺乏优雅的解决方案。QT框架配合QXlsx库的出现,彻底改变了这一局面。这套组合不仅能实现基础…

2026/10/14 7:02:34 阅读更多 →
导师认可的AI论文写作软件综合榜(2026 优选)

导师认可的AI论文写作软件综合榜(2026 优选)

基于学术严谨性、写作辅助效率、界面友好度及用户反馈数据,以下是当前主流 AI 论文写作工具的权威测评榜单,按综合使用价值从高到低排序,并标注其核心功能与适用人群。🏆 第一梯队:全流程学术解决方案(★★…

2026/10/14 7:02:34 阅读更多 →
前端数学公式渲染实践:MathJax接入、配置与性能优化指南

前端数学公式渲染实践:MathJax接入、配置与性能优化指南

1. 从排版阵痛到第一行渲染:为什么前端数学公式绕不开MathJax大概每个做过在线教育、科研协作平台或者技术文档站点的前端开发者,都会有那么一段难熬的日子:需求方递过来一堆Word或者Markdown文档,里面全是排版好的数学公式&#…

2026/10/14 7:01:34 阅读更多 →

日新闻

AnyPS5通用适配层设计:兼容性与性能的工程取舍

AnyPS5通用适配层设计:兼容性与性能的工程取舍

1. 从"AnyPS5"这个名字说起:它到底想解决什么问题第一次看到"AnyPS5"这个标题,我脑子里冒出来的第一个念头是:这大概率是一个围绕"跨平台运行"或者"通用适配"做文章的项目。名字里的"Any"…

2026/10/14 0:00:41 阅读更多 →
Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

Flutter跨平台开发实战:二手交易应用与HarmonyOS适配

1. 项目概述:Flutter跨平台二手交易应用开发作为一名长期从事移动应用开发的工程师,我最近完成了一个基于Flutter框架的二手物品交易平台项目。这个项目最吸引我的地方在于它完美结合了Flutter的跨平台优势与HarmonyOS的生态特性,实现了"…

2026/10/14 0:00:41 阅读更多 →
DFS算法解决飞机降落调度问题实战

DFS算法解决飞机降落调度问题实战

1. 项目背景与核心思路飞机降落问题是一个经典的调度优化场景,也是算法竞赛和面试中的高频题目。这个题目要求我们合理安排多架飞机的降落顺序和时间,确保所有飞机都能安全着陆而不发生冲突。在实际航空管制中,这类问题直接关系到飞行安全和机…

2026/10/14 0:00:41 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/13 16:22:04 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 22:01:27 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 14:57:39 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →