React 移动端实战 · 弱网下提交的数据说没就没?离线优先队列 + 客户端幂等,移动端补传一次说清

React 移动端实战 · 弱网下提交的数据说没就没?离线优先队列 + 客户端幂等,移动端补传一次说清

前言

各位看官,上篇我聊了怎么用 AI 把一个能上架的移动 App 从 0 到 1 做出来(就是 React + Capacitor 那套)。App 做出来只是第一步,真到了用户手里,麻烦才刚开始。

我这个 App 有个典型场景:一线同事在写字楼、地下车库、电梯里跑外勤,点一下就拨通电话,挂了之后要顺手记一条"这次联系的结果"。问题来了——这些地方信号一言难尽。你辛辛苦苦填完表单一点提交,结果转圈半天报个"网络错误",数据呢?没了对吧。更坑的是,很多人提交完就接着干下一件事去了,回头发现刚才那条记录根本没存上,还得重新找人重新填,体验直接炸裂。

在这里插入图片描述

所以这篇我想认真聊聊:移动端怎么把"弱网下提交"做成离线优先,再配一套客户端幂等,让数据不丢、不重、不卡。这套东西是我在这个项目里真刀真枪踩坑踩出来的,代码都还在,各位可以放心抄。

先说结论:把"提交失败"当成常态,而不是异常

误区:很多前端同学默认"网络基本是好的",提交失败就弹个 toast 让用户重试。在桌面端这勉强能忍,在移动端弱网场景这是灾难——用户根本不会守着那个错误提示。

正确的姿势是离线优先(offline-first):提交动作永远先保证"本地落盘成功",能不能立刻上送服务器是次要的。上送失败?没事,塞进一个本地队列,等网络好了自动补传。对用户来说,他点完提交那一刻数据就已经"存好了",至于什么时候真正到服务器,他不用管。

一个 localStorage 队列,是离线优先的最小骨架

核心就一个文件,我管它叫离线队列。它干四件事:读队列、写队列、入队、出队(补传)。先看读写和入队:

const QUEUE_KEY = 'app.offline-queue.v1'; // localStorage 键,关 App 也不丢

export function getPendingReports() {
  const raw = localStorage.getItem(QUEUE_KEY);
  if (!raw) return [];
  try {
    return JSON.parse(raw) as PendingReport[];
  } catch {
    localStorage.removeItem(QUEUE_KEY); // 脏数据直接清空,别让一条坏 JSON 卡死整个队列
    return [];
  }
}

function savePendingReports(reports: PendingReport[]) {
  localStorage.setItem(QUEUE_KEY, JSON.stringify(reports));
}

// 入队:按 clientRequestId 去重,同一份记录只存一次
export function queueCallReport(
  payload: CallReportPayload,
  meta: Pick<PendingReport, 'contactName' | 'phone'> = {},
) {
  const reports = getPendingReports();
  const exists = reports.some((r) => r.clientRequestId === payload.clientRequestId);
  if (!exists) {
    savePendingReports([
      ...reports,
      { ...payload, ...meta, queuedAt: new Date().toISOString() },
    ]);
  }
}

这里有个小细节我特意加了:解析队列 JSON 失败的时候,与其抛错让整个逻辑崩掉,不如直接 removeItem 清空,让队列自愈。一条损坏的数据不值得拖垮全部。

坑一:补传时机——别指望用户手动点

队列有了,下一步是"什么时候把队列里的内容发出去"。我第一版做得很天真:在界面顶部放了个"N 条待补传"的小黄条,让用户自己点。结果上线后一看,好多记录一直躺在队列里——用户根本不看那个 banner,他提交完就走了,黄条对他来说等于不存在。

正确的做法:把补传做成全自动。我用了 Capacitor 的 Network 插件监听网络状态,一连上网就触发补传;同时 App 每次启动也先补一次(上次关 App 前可能还有没发出去的)。

在这里插入图片描述

import { Network } from '@capacitor/network';

export function useOnlineSync() {
  const flushMutation = useMutation({
    mutationFn: flushPendingReports,
    onSuccess: (result) => {
      if (result.sent > 0) {
        // 补传成功,刷新相关缓存(下面会说)
        queryClient.invalidateQueries({ queryKey: ['records'] });
        queryClient.invalidateQueries({ queryKey: ['history'] });
      }
    },
  });

  useEffect(() => {
    void flushMutation.mutateAsync(); // 启动先补一次
    const setup = async () => {
      const handle = await Network.addListener(
        'networkStatusChange',
        (status) => {
          if (status.connected) void flushMutation.mutateAsync(); // 一连网就补
        },
      );
      return handle;
    };
    // ... 卸载时 remove 监听
  }, [flushMutation.mutateAsync]);

  return flushMutation;
}

坑二:重复提交——客户端幂等 clientRequestId

全自动补传带来一个新坑:补传会被多次触发。启动跑一次、网络状态变化在 iOS 上可能连着冒好几个事件、再加上用户手贱点一下黄条——同一条记录可能被发出去两三次。后端要是没有去重,列表里就会出现重复的联系结果,这比丢数据还难排查。

解法就是客户端幂等:每条记录在提交那一刻就生成一个 clientRequestId,然后它入队、补传、一路跟着走,后端拿这个 id 去重。

export function createClientRequestId() {
  if (typeof crypto.randomUUID === 'function') {
    return crypto.randomUUID(); // 现代 WebView 直接有
  }
  if (typeof crypto.getRandomValues === 'function') {
    const bytes = crypto.getRandomValues(new Uint8Array(16));
    bytes[6] = (bytes[6] & 0x0f) | 0x40;
    bytes[8] = (bytes[8] & 0x3f) | 0x80;
    const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0'));
    return [
      hex.slice(0, 4).join(''),
      hex.slice(4, 6).join(''),
      hex.slice(6, 8).join(''),
      hex.slice(8, 10).join(''),
      hex.slice(10, 16).join(''),
    ].join('-');
  }
  return `fallback-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}

提交时生成,全程跟随:

function handleSubmit(value) {
  reportMutation.mutate({
    contactId: contact.id,
    duration: value.duration,
    callResult: value.callResult,
    callRemark: value.callRemark,
    recordType: value.recordType,
    clientRequestId: createClientRequestId(), // 关键:提交那一刻生成
    startedAt, endedAt,
  });
}

补传时原样带上,后端按 id 去重,返回里还带了 idempotent: boolean 告诉我这条是新增还是被去重了。

这里有个我踩过的反直觉点:幂等 id 必须在提交时生成,绝不能在补传时生成。我一开始图省事,在 flush 里现生成 id,结果每次重试都是新 id,后端根本没法去重,重复记录照样出现。id 得跟着记录本身走,而不是跟着"发送动作"走。

坑三:缺必填字段的记录会卡死队列

还有个更隐蔽的坑。离线的时候,用户可能在反馈表单没填完就关掉了(比如电话刚挂,弹窗出来他顺手划掉),导致队列里躺着一条"缺少备注或分类"的记录。补传时后端一校验必填项不过,这条就永远失败、永远卡在队列里反复重试,连带其他能发的也发不出去。

所以补传前要先做必填项校验,不齐的别发,而是弹窗让用户补全:

export function getReportsMissingRemarks() {
  return getPendingReports().filter(
    (r) =>
      (r.callResult === 1 && !r.callRemark?.trim()) ||
      r.recordType === undefined,
  );
}

界面层在补传前先查一遍,有缺的就弹窗让用户把备注和分类补上,补全后再写回队列,最后才真正补传。发成功的出队,发失败的留着下次再试:

export async function flushPendingReports() {
  const reports = getPendingReports();
  const failed: PendingReport[] = [];
  let sent = 0;
  for (const pending of reports) {
    const remark = pending.callRemark?.trim();
    if ((pending.callResult === 1 && !remark) || pending.recordType === undefined) {
      failed.push(pending); // 必填不齐,先跳过
      continue;
    }
    try {
      await reportCall({ ...pending, clientRequestId: pending.clientRequestId });
      sent += 1;
    } catch {
      failed.push(pending); // 网络又挂了,留着
    }
  }
  savePendingReports(failed); // 只保留没发成功的
  return { sent, failed: failed.length };
}

在这里插入图片描述

补传成功后,记得刷新缓存

最后一点容易被忘:补传成功之后,界面上的列表、历史数据得同步刷新,不然用户看着列表没变,还以为没传成功,又手动点一遍。上面 useOnlineSynconSuccess 里我已经 invalidateQueries 了几个 queryKey,React Query 会自动重新拉取,界面就更新了。


下面这张表,是我把上面几个决策点收敛成的一张"对照清单",各位可以照着自查:

环节朴素做法(我一开始这么干)本文做法解决的真问题
提交失败toast 报错让用户重试失败即入 localStorage 队列,用户无感弱网/电梯里提交完就走,回来数据没了
补传时机等用户点"待补传"按钮启动补一次 + 监听 networkStatusChange 自动补用户根本不看那个 banner
重复提交重试就再发一次clientRequestId 客户端幂等,后端按 id 去重网络事件连发 / 启动+联网双触发导致重复记录
缺必填字段直接发,后端拒了就卡死补传前校验,不齐弹窗让用户补全离线没填完就关,记录永远失败循环
队列存储放内存变量放 localStorage,关 App 不丢App 被杀/重启后队列还在
补传成功不刷新界面失效 RQ 缓存,列表/历史同步用户看到列表没更新以为没传成功
清缓存只清登录态明确提示会清空离线队列,已上送数据不受影响误清导致待补传记录丢失的恐慌
幂等 id 时机在补传时生成在提交时生成并跟随整条记录补传重试拿新 id,幂等彻底失效
脏数据兜底解析失败抛错catch 里 removeItem 清空,队列自愈一条坏 JSON 卡住整个队列
并发补传允许多个 flush 同时跑mutation 串行 + 去重同一条被两个事件各发一次

小结

离线优先这件事,说穿了就三句话:提交先落本地、联网自动补、每条带幂等 id。但真做起来,补传时机、重复提交、缺字段卡死这三个坑,我是实实在在踩过才长记性的。各位看官如果也在做移动端弱网类的应用,这套队列 + 客户端幂等的思路可以直接拿去用,代码我都贴了,改改字段名就能跑。

最后,如果本文对你有所增益,希望看官您用发财的小手点个小赞哈!你们项目里离线补传是怎么做的?欢迎在评论区交流。

相关阅读

关注FungLeo 博客

本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FungLeo

您的鼓励,是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值