<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>aqi</title>
        <link>/</link>
        <description>aqi's Blog</description>
        <lastBuildDate>Tue, 29 Sep 2026 01:12:58 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <copyright>2026 © aqi</copyright>
        <atom:link href="/feed.xml" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[前端离线容灾]]></title>
            <link>/posts/frontend-offline-resilience-zh</link>
            <guid isPermaLink="true">/posts/frontend-offline-resilience-zh</guid>
            <pubDate>Sat, 19 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[工地现场经常没网。与其让请求直接失败，不如把读写拆开，让前端自己撑一会儿。]]></description>
            <content:encoded><![CDATA[<p>[[toc]]</p>
<p>前阵子在做一个项目管理小程序。现场的人反馈手机信号经常说没就没。</p>
<p>一开始打算把「没网」当成异常：toast 一下，让用户等会再试。结果现场人员说，他刚才填了二十分钟的单，点提交，没了。</p>
<p>那一刻我才意识到，对现场应用来说，断网不是边角情况，是默认情况。</p>
<p>于是我们在请求层做了一套很土、但挺好用的东西：读走短缓存，写走进离线事务池。我把它叫前端离线容灾。</p>
<h2>先把问题说清楚</h2>
<p>我们要的不是 Service Worker 把整个 App 缓存下来，也不是把小程序做成「能离线打开壳」。用户已经打开了页面，只是下一秒请求发不出去。</p>
<p>更具体一点：</p>
<ul>
<li><strong>读</strong>：列表、详情，断网时最好还能看见刚才看过的东西</li>
<li><strong>写</strong>：上报、新增、审批，断网时不能把用户填的数据扔掉</li>
</ul>
<p>读写的失败代价完全不一样。读失败，最多是空列表；写失败，是二十分钟的现场记录。所以默认策略也不该对称。</p>
<h2>两层开关</h2>
<p>全局有两个开关，放在环境配置里：</p>
<pre><code class="language-js">enableCache: true,          // 读接口的短缓存兜底
enableOfflineQueue: true,   // 写接口的离线事务池
</code></pre>
<p>关掉任意一个，对应能力全项目停掉。方便排查，也避免「某个环境不该离线」时还在偷偷入池。</p>
<p>单接口再开一层：</p>
<pre><code class="language-js">// GET：默认不缓存，显式打开才兜底
get('/project/ledger/list', params, { listCache: true })

// POST：默认入池，不想进池就关掉
post('/project/ledger', data, {
  queueOffline: false,
})
</code></pre>
<p>读默认关、写默认开。这个不对称是故意的。</p>
<p>列表一旦默认缓存，下拉刷新、刚提交完再进列表，都可能看到过期数据，排查起来很烦。所以缓存必须「我知道自己在做什么」才打开。</p>
<p>写则相反。绝大多数表单提交，断网时你都希望它被接住。忘记加配置，应该是进池，而不是丢数据。</p>
<h2>读：五分钟的体面</h2>
<p>缓存很简单。key 由 url 和排序后的参数拼出来，TTL 五分钟，塞进 <code>uni.setStorage</code>。数据太大就落到文件，避免把小程序的 storage 配额一次性打满。</p>
<p>有网时照常请求，成功就刷新缓存。完全断网、或者请求失败但本地还有没过期的缓存时，把缓存当响应返回，并提示：</p>
<blockquote>
<p>当前网络异常，展示数据为缓存，时效 5 分钟，请稍后刷新</p>
</blockquote>
<p>有一个 <code>fresh: true</code>。下拉刷新会带上它，意思是「我就要最新的」。这时即使有缓存也不用，直接告诉用户现在拿不到。语义比「偷偷给你一份旧的」干净得多。</p>
<p>写成功之后，默认会按 url 前缀把相关列表缓存清掉。不然刚上报一条隐患，列表还是上一分钟的。这个也可以按接口关。</p>
<h2>写：不要自动提交</h2>
<p>断网，或弱网导致 <code>uni.request</code> 失败（超时、network fail 这类可重试错误），写请求不会直接死掉。payload 会被深拷贝一份，丢进本地事务池。</p>
<p>池子本身也有边界：</p>
<ul>
<li>最多 50 条</li>
<li>单条不超过 1MB</li>
<li>只活 12 小时，超时丢掉</li>
<li>相同 <code>method + url + data</code> 且还在 pending / processing，不重复入池</li>
</ul>
<p>入池后 toast 一句「已加入离线事务池，请在首页手动提交」，请求以 <code>offline: true</code> reject。页面不用假装成功，但数据已经在。</p>
<p>一开始我差点做成「联网了就自动全推」。后来否了。工地信号是抽风的：连上三秒，又断。自动提交会把失败、重试、重复提交搅成一团，用户也看不懂刚才到底成没成。</p>
<p>所以首页有一块事务池，<strong>只有池子里真有东西才显示</strong>。联网才能点提交。失败变重试。剩余不到一小时标「即将过期」。</p>
<p>把控制权交回去，比聪明的后台同步安心。</p>
<h2>照片怎么办</h2>
<p>隐患上报几乎必拍。断网时文件还在本地，不能先传 OSS。</p>
<p>做法是扫描 payload 里的数组，把 <code>pendingUpload: true</code> 的项抽出来，原位置换成占位：</p>
<pre><code class="language-js">value[index] = { __pendingUpload: true }
</code></pre>
<p>真正提交时先逐个 <code>uni.uploadFile</code>，成功再回填 <code>fileName</code> / <code>fullPath</code>，再发业务请求。上传失败就停在池里，用户可以重试，不会变成「业务单过了、图没了」。</p>
<p>删事务或过期清理时，顺手 <code>removeSavedFile</code>。本地临时图很容易漏，12 小时后再扫一遍孤儿文件。</p>
<h2>一次请求的路径</h2>
<p>把 <code>request.js</code> 里的分支摊开，大概是这样：</p>
<pre><code class="language-js">if (!online &amp;&amp; isWrite &amp;&amp; queueOffline)
  return enqueue()          // 完全断网，写 → 入池

if (!online &amp;&amp; isGet &amp;&amp; listCache &amp;&amp; !fresh)
  return cached or fail     // 完全断网，读 → 五分钟缓存

uni.request({
  success: (res) =&gt; {
    if (ok) {
      maybeSetCache()
      maybeInvalidateList()
    }
  },
  fail: (err) =&gt; {
    if (isWrite &amp;&amp; retryable(err))
      return enqueue()      // 弱网失败，写 → 入池
    if (isGet &amp;&amp; listCache &amp;&amp; !fresh)
      return cached or fail
    fail()
  },
})
</code></pre>
<p>业务错误（校验失败、401）不入池。入池的只是「现在这条路走不通，但用户的意图还在」。</p>
<h2>我学到的</h2>
<p>离线容灾听起来很大，落到前端，往往就是请求层的几个分支。难的不是存储，是默认值：</p>
<ul>
<li>读默认不缓存，避免脏列表</li>
<li>写默认入池，避免丢单</li>
<li>联网后不自动冲，避免连上三秒又写炸</li>
<li>文件先占位，提交时再传，避免半成功</li>
</ul>
<p>现场的人不会说「请为我做 PWA」。他们只会说：刚才那单呢？</p>
<p>希望你下次碰到类似场景时，这套拆法能派上用场。</p>
]]></content:encoded>
            <author>aqi</author>
        </item>
    </channel>
</rss>