尧图精选

uni-app网络请求与数据缓存实战:从JavaScript基础到跨端封装

🕒 发布时间:2026/9/10 5:24:19 📁 来源:尧图网络
如果你正在学 JavaScript 基础又刚好接触了 uni-app这一讲应该能帮你把两个知识块串起来。上周我在群里帮一个学员排查问题他做的小程序页面每次进入都要转圈三秒数据才出来用户体验很糟糕。我扫了一眼代码发现他每次都直接发请求页面空数据就去请求接口完全没有用到本地缓存。这不是代码写错而是缺少一套“请求与缓存配合”的思路。这恰恰是 uni-app 开发里比单纯调接口更值钱的部分。本讲我会从 JavaScript 基础视角切入 uni-app 的网络请求与数据缓存为什么不能用你熟悉的 XMLHttpRequest / fetch怎么把 uni.request 封装成项目里好用的 request 模块数据缓存又该怎么设计才不算野代码。适合刚学完 JS 异步语法、准备做真实项目的读者也适合已经写过一些页面、但还没系统整理过请求层的开发者。1. 先从底层搞清楚uni-app 为什么非要自己包一层请求很多人的 JavaScript 基础课里发一个网络请求通常是这样写的fetch(https://api.example.com/user/list) .then(res res.json()) .then(data console.log(data)) .catch(err console.error(err))或者用 XMLHttpRequest 封装一套 ajax 函数。这套逻辑在浏览器里跑得挺好但到了 uni-app 里就崩了。原因很简单uni-app 编译出来的小程序端根本没有 XMLHttpRequest 对象App 端的原生环境也不完全等同于浏览器。所以 uni-app 自己封装了一套跨端网络请求 API叫uni.request。1.1 跨端框架为什么不能直接复用 axios / fetchaxios 底层依赖 XMLHttpRequest在小程序里这个对象不存在。fetch 是浏览器原生 API小程序和 App 端同样没有。那有人会问uni-app 不是支持条件编译吗能不能在 H5 用 axios、小程序用 uni.request理论上可以但你得维护两套请求代码还要处理返回结构不一致的问题项目一复杂就是灾难。uni.request是 uni-app 在编译阶段做的抽象层。你在代码里写的uni.request编译到小程序端会映射成微信或支付宝的 request API编译到 App 端会走原生网络模块编译到 H5 端则内部使用 XHR。也就是说你的业务代码只需要写一遍平台差异被框架吸收掉了。我在带学员的时候一直强调在 uni-app 项目里除非有特别强的理由否则不要自己引一套第三方请求库。uni.request 就是官方给你统一的标准学习成本低出了问题社区里也好查。1.2 从 fetch / axios 到 uni.request 的思维迁移如果你已经熟悉 JavaScript 的异步网络请求看 uni.request 其实不会太难因为它的回调结构和老式 XHR 很像uni.request({ url: https://api.example.com/user/list, method: GET, data: { page: 1, pageSize: 20 }, header: { Content-Type: application/json }, success: (res) { // 请求成功 }, fail: (err) { // 请求失败 } })这里的success和fail是两个回调函数不像 fetch 那样返回 Promise。不过别着急后面我会讲怎么把它快速封装成 Promise 风格继续用你最熟悉的async/await。细看 uni.request 的参数你会发现它比 fetch 要“啰嗦”一点但每个字段都很明确url是接口地址method是 HTTP 方法data是请求参数header是请求头success/fail/complete对应成功、失败、完成三个时机。这种设计最大的好处是你不需要像 fetch 那样手动拼 URL 查询串或判断res.ok框架已经帮你拆好了。1.3 这一讲和 JavaScript 基础课的关系到这里你可能已经感受到了uni-app 网络请求这块本质上就是 JavaScript 异步编程的一次实战应用。你之前学的 Promise、async/await、事件循环、错误处理在这里全部派得上用场。甚至可以说这一讲是检验你 JavaScript 异步基础扎不扎实的好机会。后续内容里我会默认你已经知道 Promise 是什么也知道async/await怎么用。如果你的基础还停留在只说“回调函数”的阶段建议先把 Promise 那几节课再翻一遍再往下看会顺畅很多。2. 从零封装一个 request 模块Promise 化是第一步我在前面的示例里用了回调写法但真实项目里很少有人直接把uni.request铺在页面里用。原因有三个每个页面都要写一遍success、fail代码大量重复接口地址散落在各个页面后端一旦迁移地址改起来要命错误提示、loading、token 注入这些横切逻辑你没法统一处理所以项目里的第一步是先封装一个项目级的 request 模块。2.1 基础封装把 uni.request 变成 Promise直接把uni.request包一层 Promise代码量不大但收益立竿见影。下面是一个最小可用的实现// utils/request.js const BASE_URL https://api.example.com export function request(options) { return new Promise((resolve, reject) { uni.request({ url: BASE_URL options.url, method: options.method || GET, data: options.data || {}, header: { Content-Type: application/json, ...options.header }, success: (res) { resolve(res.data) }, fail: (err) { reject(err) } }) }) }这里我把每个参数都给了默认值options.url必传其他都可以不传。BASE_URL单独抽出来方便以后按不同环境切换。在页面里用法就友好多了import { request } from /utils/request.js async function loadList() { try { const data await request({ url: /user/list, data: { page: 1 } }) console.log(data) } catch (err) { console.error(err) } }同样是一个网络请求从回调嵌套变成await之后代码结构清晰了一大截。后面要做统一的错误处理也只需要在这个模块里动刀。2.2 统一注入 token 与请求头真实项目的接口大部分都需要携带登录凭证。如果你在每个页面都手动加 header一旦 token 失效要换字段名或者从 header 改成别的传递方式就得全局搜索替换。正确做法是在封装层统一注入。上面那个最小实现里header 那段我留了...options.header就是让你做扩展的。把 token 注入补上之后是这样const token uni.getStorageSync(token) return new Promise((resolve, reject) { uni.request({ ... header: { Content-Type: application/json, token: token || , ...options.header }, ... }) })这里读取 token 用的是uni.getStorageSync它是 uni-app 提供的同步存储 API从本地缓存里取值。关于缓存我会在第 4 部分详细讲你现在只需要知道token 存在本地缓存里请求时从缓存取出放进 header。2.3 响应结构统一处理后端接口一般有两种返回风格一种是成功时直接返回业务数据失败时返回错误码另一种是永远返回一个 JSON 结构里面包含 code、message、data 三个字段。我建议新项目统一采用后一种因为它不管是成功还是失败HTTP 状态码都可以是 200业务错误码在 JSON 里体现。这样前端在封装层就能把“网络层错误”和“业务层错误”拆开处理success: async (res) { const { code, message, data } res.data if (code 0) { resolve(data) } else { uni.showToast({ title: message || 请求失败, icon: none }) reject(new Error(message)) } }, fail: (err) { uni.showToast({ title: 网络异常请稍后重试, icon: none }) reject(err) }这么一改页面里拿到的data直接就是业务数据不用再管code判断了。错误提示也统一在封装层弹出页面拿到 reject 之后只需要处理自己的局部逻辑。3. 状态码、loading、超时请求链路里最容易翻车的三处细节封装完基础请求模块项目能跑起来了。但真让你上线还有三个细节绕不开状态码怎么分类处理、loading 怎么避免重复弹、请求超时和并发问题怎么解决。这三个地方要是没处理好线上就是各种“页面一直转圈”和“数据没刷出来”的投诉。3.1 HTTP 状态码与业务状态码的区别HTTP 状态码是协议层的东西比如 200 表示响应成功404 表示资源不存在500 表示服务器出错。业务状态码是应用层自定义的通常放在响应体里比如 code 为 20000 表示“成功”40001 表示“用户未登录”。很多人犯错是把这两层混在一起判断。比如有的同学在success里拿到 res先判断res.statusCode 200再判断业务 code逻辑绕而且容易漏。我的习惯是网络层的 statusCode 在封装层判断只把 2xx 的响应通过resolve抛给页面非 2xx 或网络异常统一reject。业务层的 code 也放在封装层判断页面完全不感知这一层。这样分层清晰页面只管“成功拿到数据”和“失败了怎么办”。下面是一个比较完整的成功回调处理逻辑success: (res) { if (res.statusCode 200 res.statusCode 300) { const { code, message, data } res.data if (code 0) { resolve(data) } else if (code 401) { // 登录态失效跳转登录页 uni.removeStorageSync(token) uni.navigateTo({ url: /pages/login/index }) reject(new Error(登录已过期)) } else { uni.showToast({ title: message || 请求失败, icon: none }) reject(new Error(message)) } } else { // 这里可以按 401、403、404、500 分别提示 uni.showToast({ title: 请求错误(${res.statusCode}), icon: none }) reject(new Error(HTTP ${res.statusCode})) } }3.2 并发请求下 loading 的计数处理页面里常见一个操作要同时发好几个请求。如果在每个请求的success里调用uni.hideLoading()会出现第一个请求回来就把 loading 关了其他请求还在跑界面一闪而过体验很怪。解决思路是维护一个请求计数器。每次发请求前计数加一请求结束时计数减一直到计数归零才隐藏 loading。在 request 模块里可以这样写let requestCount 0 function showLoading() { requestCount uni.showLoading({ title: 加载中, mask: true }) } function hideLoading() { requestCount-- if (requestCount 0) { requestCount 0 uni.hideLoading() } }然后在请求开始时调showLoading()在complete回调里调hideLoading()。注意是complete不是success或fail因为成功和失败都要把计数器减掉。这个细节我见过太多次漏写计数器只减了一次后面所有请求都弹不出 loading 了。另外要说一下有些接口不需要全局 loading比如用户下拉刷新、滚动加载更多。这类请求最好在封装层加一个配置项比如options.loading false就不走 loading 逻辑。3.3 超时时间与网络异常提示uni.request 有一个timeout参数默认值在不同平台上不一致。你自己不设置它就用平台默认值这在弱网环境下很容易让用户等很久。建议在封装层统一设置uni.request({ ..., timeout: 10000, ... })超时有 10 秒足够了超过这个时间还没返回多半是网络不行或者接口异常。超时之后走fail回调通常是err.errMsg包含timeout字样。你可以在fail里判断一下针对不同错误类型给用户更具体的提示fail: (err) { if (err.errMsg err.errMsg.indexOf(timeout) ! -1) { uni.showToast({ title: 请求超时请检查网络, icon: none }) } else { uni.showToast({ title: 网络异常请稍后重试, icon: none }) } reject(err) }这里还有一个隐藏点错误提示不要每个请求弹一次。如果页面并发三个请求都超时会连续弹三个 toast体验很糟糕。更好的方案是全局只保留最后一次提示或者引入一个 toast 去重逻辑。简单做法是记录上一次 toast 的时间短时间内不重复弹。4. 数据缓存不想写成野代码就得先定好存储规范网络请求讲完了接下来是缓存。uni-app 的缓存 API 设计得很简单简单到很多人随手就写写着写着就出了“野代码”key 命名混乱、缓存没有过期时间、数据格式五花八门最后自己都懒得读缓存了。这一节我们先把基础 API 吃透再讨论怎么设计一套可维护的缓存规范。4.1 缓存 API 全家桶同步版与异步版uni-app 的本地缓存 API 一共就那么几个你先记同步版uni.setStorageSync(key, data)写入缓存data 可以是对象、数组、字符串、数字uni.getStorageSync(key)读取缓存读取时不需要再手动 JSON.parseuni.removeStorageSync(key)删除指定 keyuni.clearStorageSync()清空所有缓存uni.getStorageInfoSync()获取当前缓存占用情况这套 API 命名很直白基本看一眼就懂。关键是它帮你省了一个大麻烦存对象时自动序列化取出来时自动反序列化。用原生 localStorage 的时候你经常要写JSON.stringify和JSON.parse在 uni-app 里直接传对象就行。对应地还有一套异步版uni.setStorage、uni.getStorage、uni.removeStorage、uni.clearStorage。异步版支持回调或 Promise在数据量大的时候更推荐因为同步版会阻塞 JS 线程数据一多页面会卡顿。这里给一个简单的对比API 类型同步版异步版适用场景写入setStorageSyncsetStorage大量数据、频繁写入读取getStorageSyncgetStorage大量数据、频繁读取删除removeStorageSyncremoveStorage单个 key 清理清空clearStorageSyncclearStorage退出登录、重置应用实际项目里token、用户信息这类小字段我一般用同步版因为读取频繁且数据量小同步版逻辑直接还能保证页面渲染前拿到值。列表页的大数据缓存我倾向于异步版写入时不阻塞界面。4.2 缓存 key 的命名规范缓存写得多了最大的噩梦是几天后不知道某个 key 对应的数据结构是什么也不知道哪里在写入它。所以 key 命名一定要有规范。我的惯例是统一加项目前缀比如myapp_user_info避免和多端公共存储空间里的其他 key 冲突用下划线分隔语义不用驼峰因为 key 本质是一个扁平字符串一个 key 只存一种数据结构不做多用途复用我见过有人把用户信息、购物车列表、上一次请求时间塞进同一个 key 里图省事。真到排查问题的时候你想看购物车数据结果打印出来一堆无关字段又得去猜当初的存储结构非常痛苦。比较好的做法是在项目里单独建一个store/keys.js把所有缓存 key 集中管理export const STORAGE_KEYS { TOKEN: myapp_token, USER_INFO: myapp_user_info, CART_LIST: myapp_cart_list, }这样既能避免散落各处的魔法字符串也能在代码审查时一眼看出又新增了什么缓存。4.3 带过期时间的缓存封装uni-app 自带的存储 API 不支持设置过期时间。而实际业务里缓存“永久有效”往往不是我们想要的。比如首页公告、商品分类我们希望它几小时或一天内有缓存即可过期了再去拉新的。实现思路很简单在写入时额外存一个时间戳读取时判断当前时间是否超过过期时间超过了就删除并返回空。之前我在某个项目里已经封装好了这套逻辑这里直接放出来// utils/cache.js const PREFIX myapp_ export function setCache(key, value, expireSeconds 0) { const data { value, expire: expireSeconds 0 ? Date.now() expireSeconds * 1000 : 0 } uni.setStorageSync(PREFIX key, data) } export function getCache(key) { const data uni.getStorageSync(PREFIX key) if (!data) return null // expire 为 0 表示永不过期 if (data.expire Date.now() data.expire) { uni.removeStorageSync(PREFIX key) return null } return data.value } export function removeCache(key) { uni.removeStorageSync(PREFIX key) }用的时候这样写// 缓存商品列表 5 分钟 setCache(goods_list, list, 5 * 60) // 读取过期了会返回 null const list getCache(goods_list)这个封装看起来简单但解决了缓存方案里 80% 的痛点。实际开发时你可以把setTimeout定时清理也做进去不过正常情况下惰性删除就够了只有访问到过期 key 时才清理不会对性能造成影响。5. 请求与缓存三条协作套路先读缓存、后写缓存、token 单飞封装完请求模块和缓存模块接下来重点来了怎么把它俩配合起来。我见过很多新手的代码里缓存是缓存请求是请求两套代码各写各的结果缓存并没有真正减少请求次数。下面三种协作模式是我在实际项目里最常用的覆盖了绝大多数场景。5.1 先读缓存渲染再请求更新stale-while-revalidate这是最推荐的一种列表页模式。用户进入页面时先立即从缓存里拿旧数据渲染让页面不白屏同时后台发请求拉最新数据拿到之后更新缓存再刷新页面数据。好处很明显弱网环境下用户不用一直等 loading看到的是上一次的可用数据体验比白屏好得多。坏处也有数据可能不是最新的所以这种模式适合对实时性要求不高的内容比如公告、商品分类、非核心的用户列表。实现起来也不复杂async function loadGoodsList() { // 先读缓存 const cached getCache(goods_list) if (cached) { this.goodsList cached } // 再发起请求 try { const data await request({ url: /goods/list }) setCache(goods_list, data, 5 * 60) this.goodsList data } catch (err) { // 请求失败时如果缓存有数据还可以继续展示不至于空白 } }这里有个细节当缓存已经有数据时通常不建议再显示 loading不然页面会闪一下。你可以加一个isFromCache的判断缓存命中时静默刷新。5.2 请求成功后写缓存适合详情页详情页和列表页不一样用户通常希望看的是最新信息比如商品价格、库存、订单状态。这种场景就不适合“先读缓存渲染”了否则用户看到的价格可能是昨天的。正确做法是进页面直接发请求成功后把数据写进缓存同时渲染页面。缓存在这里的主要作用不是减少请求而是下一次进入同一个详情页时可以快速展示上一次的内容作为“底稿”然后后台刷新。async function loadGoodsDetail(goodsId) { const cacheKey goods_detail_ goodsId try { const data await request({ url: /goods/detail, data: { goodsId } }) setCache(cacheKey, data, 60 * 60) this.detail data } catch (err) { // 请求失败尝试读缓存兜底 const cached getCache(cacheKey) if (cached) { this.detail cached } } }注意这里缓存的 key 加上了goodsId让每个详情页都有自己的缓存。否则 A 商品的详情会串到 B 商品上这个问题新手特别容易踩。5.3 token 缓存与登录态管理缓存作为唯一数据源token 是一个特殊字段它不像列表数据那样需要过期时间控制但它需要严格的读写时机。我在项目里的做法是token 只存在缓存里以此作为唯一数据源。登录成功后uni.setStorageSync(token, res.token) uni.setStorageSync(user_info, res.userInfo)请求拦截时读取const token uni.getStorageSync(token)登出或 token 失效时删除uni.removeStorageSync(token) uni.removeStorageSync(user_info) uni.navigateTo({ url: /pages/login/index })初看这个流程很简单但有个隐藏问题多端环境下getStorageSync读取的是各端本地存储。小程序里用户清缓存、App 里用户在系统设置里清理数据都会导致 token 丢失。这时候你再发请求后端会返回 401所以请求层必须做好“登录态失效”的统一兜底就是我之前写的 code 401 那个分支。还有一点不要在同一份代码里既写uni.getStorageSync(token)又写localStorage.getItem(token)多端环境下 localStorage 在小程序端不一定会按你预期工作。统一走 uni 封装的 API框架才能帮你处理各端差异。6. 跨端实测踩坑H5、小程序、App 表现真的不一样理论聊完最后分享一些实际跨端编译中踩过的坑。这些坑靠看文档是发现不了的基本都是跑到真机或者说模拟器上才暴露。写在这里帮你省几个晚上的调试时间。6.1 小程序端对缓存大小和 key 的限制在 H5 端localStorage 一般能存 5MB 左右App 端原生存储更宽松但小程序端对单个 key 的缓存大小限制比你想的要严格。微信小程序本地缓存单个 key 最大 1MB所有 key 加起来不超过 10MB超过之后写入就会失败。表现是什么高分辨率图片转 base64 后塞进 storage存着存着就失败了。还有人在 console 里看到setStorageSync:fail报错第一反应是代码写错了其实是容量超了。解决方案是大文件、图片不要走 storage用本地文件系统或直接走接口缓存数据控制在可接受的体积内比如列表页只缓存 id、标题不缓存大段摘要。6.2 H5 端某些写法会让请求直接挂掉H5 端本质上跑在浏览器里所以跨域问题会出现。也就是说uni.request请求的接口地址必须和目标服务器配合 CORS否则后端接口明明在 Postman 里能通在 H5 上却报跨域错误。另外踩过一个印象很深的坑H5 端对 URL 里某些特殊字符的处理比小程序宽松比如带javascript:前缀的非法 URL。某个页面用web-view承载 H5 页面时链接写成了javascript:void(0)这类占位符小程序端没问题H5 端却直接报脚本异常。所以涉及链接跳转时尽量用 uni 的路由 API不要依赖浏览器端a标签的习惯写法。6.3 并发刷 token 的处理如果你的接口使用了较短的 token 有效期会出现一个高并发下的经典问题页面同时发三个请求三个都返回 token 过期401如果不加处理会触发三次跳转登录页体验非常差。一种常见方案是引入“单飞刷新”逻辑只有第一个请求收到 401 时去刷新 token其他请求先挂在等待队列里等 token 刷新完成后再重新发起。这个逻辑在 JavaScript 基础里涉及 Promise 的链式管理和队列实现稍微复杂我给你一个简化思路let isRefreshing false let waitQueue [] async function handleUnauthorized(callback) { if (isRefreshing) { // 已经在刷新中把回调丢进队列 waitQueue.push(callback) return } isRefreshing true try { const newToken await refreshToken() uni.setStorageSync(token, newToken) waitQueue.forEach(fn fn()) waitQueue [] } finally { isRefreshing false } }实际项目中这块要看后端是否支持 refresh_token 机制不支持的话就退化为每次 401 直接跳登录页配合 toast 提示“登录已过期”。虽然粗糙但至少不会重复跳转。6.4 调试网络请求的小技巧在封装层埋日志项目上线后最怕的不是报错而是“用户说数据不对但你复现不出来”。我习惯在 request 封装层加一个简单的日志打印开关平时开发环境打开线上环境关闭if (process.env.NODE_ENV ! production) { console.log(请求地址, BASE_URL options.url) console.log(请求参数, options.data) console.log(响应数据, res.data) }用 Chrome 或 HBuilderX 自带的调试工具时网络面板未必能完整看到小程序端的请求详情尤其是 App 端所以这个日志比想象中好用。如果你想更系统地看请求头、响应体也可以用 Charles 这类本地抓包工具配合模拟器配置代理能看到完整的请求往返数据。但多数情况下封装层日志已经够排查 90% 的问题了。另外提醒一句不要在页面里到处打console.log打印请求数据。统一放在封装层能打印的地方更少日志更集中找问题反而更快。回看这一讲核心其实就是两件事把uni.request封装成一个靠谱的 request 模块再用一套设计好的缓存规则去配合它。两者一旦协作起来你会发现页面加载速度快了代码也清爽了。我在实际项目里带了多轮学员每次看到他们从“请求回调写满页面”进化到“页面只写数据逻辑”都会觉得这套折腾是值得的。接下来你可以拿自己手头的项目练练手先从接口列表页开始加上二级缓存和统一 loading再跑一遍多端看效果。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →