前阵子在做一个项目管理小程序。现场的人反馈手机信号经常说没就没。
一开始打算把「没网」当成异常:toast 一下,让用户等会再试。结果现场人员说,他刚才填了二十分钟的单,点提交,没了。
那一刻我才意识到,对现场应用来说,断网不是边角情况,是默认情况。
于是我们在请求层做了一套很土、但挺好用的东西:读走短缓存,写走进离线事务池。我把它叫前端离线容灾。
先把问题说清楚 #
我们要的不是 Service Worker 把整个 App 缓存下来,也不是把小程序做成「能离线打开壳」。用户已经打开了页面,只是下一秒请求发不出去。
更具体一点:
- 读:列表、详情,断网时最好还能看见刚才看过的东西
- 写:上报、新增、审批,断网时不能把用户填的数据扔掉
读写的失败代价完全不一样。读失败,最多是空列表;写失败,是二十分钟的现场记录。所以默认策略也不该对称。
两层开关 #
全局有两个开关,放在环境配置里:
enableCache: true, // 读接口的短缓存兜底
enableOfflineQueue: true, // 写接口的离线事务池关掉任意一个,对应能力全项目停掉。方便排查,也避免「某个环境不该离线」时还在偷偷入池。
单接口再开一层:
// GET:默认不缓存,显式打开才兜底
get('/project/ledger/list', params, { listCache: true })
// POST:默认入池,不想进池就关掉
post('/project/ledger', data, {
queueOffline: false,
})读默认关、写默认开。这个不对称是故意的。
列表一旦默认缓存,下拉刷新、刚提交完再进列表,都可能看到过期数据,排查起来很烦。所以缓存必须「我知道自己在做什么」才打开。
写则相反。绝大多数表单提交,断网时你都希望它被接住。忘记加配置,应该是进池,而不是丢数据。
读:五分钟的体面 #
缓存很简单。key 由 url 和排序后的参数拼出来,TTL 五分钟,塞进 uni.setStorage。数据太大就落到文件,避免把小程序的 storage 配额一次性打满。
有网时照常请求,成功就刷新缓存。完全断网、或者请求失败但本地还有没过期的缓存时,把缓存当响应返回,并提示:
当前网络异常,展示数据为缓存,时效 5 分钟,请稍后刷新
有一个 fresh: true。下拉刷新会带上它,意思是「我就要最新的」。这时即使有缓存也不用,直接告诉用户现在拿不到。语义比「偷偷给你一份旧的」干净得多。
写成功之后,默认会按 url 前缀把相关列表缓存清掉。不然刚上报一条隐患,列表还是上一分钟的。这个也可以按接口关。
写:不要自动提交 #
断网,或弱网导致 uni.request 失败(超时、network fail 这类可重试错误),写请求不会直接死掉。payload 会被深拷贝一份,丢进本地事务池。
池子本身也有边界:
- 最多 50 条
- 单条不超过 1MB
- 只活 12 小时,超时丢掉
- 相同
method + url + data且还在 pending / processing,不重复入池
入池后 toast 一句「已加入离线事务池,请在首页手动提交」,请求以 offline: true reject。页面不用假装成功,但数据已经在。
一开始我差点做成「联网了就自动全推」。后来否了。工地信号是抽风的:连上三秒,又断。自动提交会把失败、重试、重复提交搅成一团,用户也看不懂刚才到底成没成。
所以首页有一块事务池,只有池子里真有东西才显示。联网才能点提交。失败变重试。剩余不到一小时标「即将过期」。
把控制权交回去,比聪明的后台同步安心。
照片怎么办 #
隐患上报几乎必拍。断网时文件还在本地,不能先传 OSS。
做法是扫描 payload 里的数组,把 pendingUpload: true 的项抽出来,原位置换成占位:
value[index] = { __pendingUpload: true }真正提交时先逐个 uni.uploadFile,成功再回填 fileName / fullPath,再发业务请求。上传失败就停在池里,用户可以重试,不会变成「业务单过了、图没了」。
删事务或过期清理时,顺手 removeSavedFile。本地临时图很容易漏,12 小时后再扫一遍孤儿文件。
一次请求的路径 #
把 request.js 里的分支摊开,大概是这样:
if (!online && isWrite && queueOffline)
return enqueue() // 完全断网,写 → 入池
if (!online && isGet && listCache && !fresh)
return cached or fail // 完全断网,读 → 五分钟缓存
uni.request({
success: (res) => {
if (ok) {
maybeSetCache()
maybeInvalidateList()
}
},
fail: (err) => {
if (isWrite && retryable(err))
return enqueue() // 弱网失败,写 → 入池
if (isGet && listCache && !fresh)
return cached or fail
fail()
},
})业务错误(校验失败、401)不入池。入池的只是「现在这条路走不通,但用户的意图还在」。
我学到的 #
离线容灾听起来很大,落到前端,往往就是请求层的几个分支。难的不是存储,是默认值:
- 读默认不缓存,避免脏列表
- 写默认入池,避免丢单
- 联网后不自动冲,避免连上三秒又写炸
- 文件先占位,提交时再传,避免半成功
现场的人不会说「请为我做 PWA」。他们只会说:刚才那单呢?
希望你下次碰到类似场景时,这套拆法能派上用场。