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 };
}

补传成功后,记得刷新缓存
最后一点容易被忘:补传成功之后,界面上的列表、历史数据得同步刷新,不然用户看着列表没变,还以为没传成功,又手动点一遍。上面 useOnlineSync 的 onSuccess 里我已经 invalidateQueries 了几个 queryKey,React Query 会自动重新拉取,界面就更新了。
下面这张表,是我把上面几个决策点收敛成的一张"对照清单",各位可以照着自查:
| 环节 | 朴素做法(我一开始这么干) | 本文做法 | 解决的真问题 |
|---|---|---|---|
| 提交失败 | toast 报错让用户重试 | 失败即入 localStorage 队列,用户无感 | 弱网/电梯里提交完就走,回来数据没了 |
| 补传时机 | 等用户点"待补传"按钮 | 启动补一次 + 监听 networkStatusChange 自动补 | 用户根本不看那个 banner |
| 重复提交 | 重试就再发一次 | clientRequestId 客户端幂等,后端按 id 去重 | 网络事件连发 / 启动+联网双触发导致重复记录 |
| 缺必填字段 | 直接发,后端拒了就卡死 | 补传前校验,不齐弹窗让用户补全 | 离线没填完就关,记录永远失败循环 |
| 队列存储 | 放内存变量 | 放 localStorage,关 App 不丢 | App 被杀/重启后队列还在 |
| 补传成功 | 不刷新界面 | 失效 RQ 缓存,列表/历史同步 | 用户看到列表没更新以为没传成功 |
| 清缓存 | 只清登录态 | 明确提示会清空离线队列,已上送数据不受影响 | 误清导致待补传记录丢失的恐慌 |
| 幂等 id 时机 | 在补传时生成 | 在提交时生成并跟随整条记录 | 补传重试拿新 id,幂等彻底失效 |
| 脏数据兜底 | 解析失败抛错 | catch 里 removeItem 清空,队列自愈 | 一条坏 JSON 卡住整个队列 |
| 并发补传 | 允许多个 flush 同时跑 | mutation 串行 + 去重 | 同一条被两个事件各发一次 |
小结
离线优先这件事,说穿了就三句话:提交先落本地、联网自动补、每条带幂等 id。但真做起来,补传时机、重复提交、缺字段卡死这三个坑,我是实实在在踩过才长记性的。各位看官如果也在做移动端弱网类的应用,这套队列 + 客户端幂等的思路可以直接拿去用,代码我都贴了,改改字段名就能跑。
最后,如果本文对你有所增益,希望看官您用发财的小手点个小赞哈!你们项目里离线补传是怎么做的?欢迎在评论区交流。
相关阅读
- React 管理后台实战·前端请求层怎么写?401 静默刷新、token 并发竞争、强制改密拦截一次说清
- React 管理后台实战·同一份数据,列表更新了下拉却没动?React Query 多 key 缓存一致性踩坑
- Node 后端实战·Serverless 导出 CSV 总超时?用 Queue+R2 异步任务彻底解决
- Node 后端实战·Cloudflare Workers 限流总误伤?用内存固定窗口替代 KV 实战
- Node 后端实战·列表查询到底怎么写?一个通用 DSL 封装,过滤分页排序一次搞定
- Node 后端实战·敏感数据防泄露 PII 脱敏与审计日志
- Node 后端实战·多租户 SaaS 怎么防止租户串数据?接口门禁 + 租户隔离 + 行级权限三层实战
- React 管理后台实战·后端异步导出做好了,前端下载却总是失败?轮询进度 + 双形态下载一次搞定
- Node 后端实战·多租户数据隔离
- Node 后端实战·D1 那些坑

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

被折叠的 条评论
为什么被折叠?



