aq

前端离线容灾

Sep 19 · 12min

前阵子在做一个项目管理小程序。现场的人反馈手机信号经常说没就没。

一开始打算把「没网」当成异常: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」。他们只会说:刚才那单呢?

希望你下次碰到类似场景时,这套拆法能派上用场。

>