如果你正在学 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 / fetch
axios 底层依赖 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 类型 | 同步版 | 异步版 | 适用场景 |
|---|---|---|---|
| 写入 | setStorageSync | setStorage | 大量数据、频繁写入 |
| 读取 | getStorageSync | getStorage | 大量数据、频繁读取 |
| 删除 | removeStorageSync | removeStorage | 单个 key 清理 |
| 清空 | clearStorageSync | clearStorage | 退出登录、重置应用 |
实际项目里,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,再跑一遍多端看效果。