后台同步技术实战解析与常见坑点避坑指南
后台同步这玩意儿,真不是点个按钮就完事的
今天上线前测到一个诡异问题:用户在离线状态下编辑了三条待办,切回在线后,只有第一条同步成功,后两条“消失”了。不是报错,不是卡死,就是发了个请求,然后石沉大海——连 Network 面板里都看不到第二、第三个 fetch。
我第一反应是“是不是接口限流了?”,赶紧翻后端日志,结果发现后端压根没收到那两个请求。再查前端控制台,console 里干干净净,连 error 都没有。这时候我就知道,问题不在接口,而在“发出去”这个动作本身——它根本没发出去。
这里我踩了个坑:以为用 fetch + await 就万事大吉,没意识到浏览器对“页面即将卸载/切换到后台”的请求有硬性限制。特别是当用户切到其他标签页、锁屏、或者直接关掉浏览器(还没完全退出)时,Chrome 和 Safari 会直接 abort 掉那些还没 resolve 的 fetch 请求。不是 reject,是 silent abort。你 catch 不到,log 也打不出,Network 里连个灰影子都没有。
折腾了半天发现,这个问题在 PWA 场景下特别典型——比如你用 Workbox 做缓存,但没配 background sync,光靠 service worker 里监听 fetch 是拦不住“未发出”的请求的。它只管已发出的,不管“正打算发但被系统咔嚓了”的。
后来试了下发现,真正靠谱的解法只有一个:用 registration.sync.register('sync-todos'),配合 service worker 里的 sync 事件。别想着用 setTimeout 模拟、别想着用 localStorage + 定时轮询——前者在后台会被冻结(尤其 iOS),后者既耗电又不准,还容易重复触发。
下面是我最终落地的完整方案,核心就三块:前端注册、service worker 处理、失败重试兜底。
核心代码就这几行
先看前端注册逻辑(React 函数组件里):
// 在用户提交编辑后调用
const registerBackgroundSync = async (data) => {
if (!('serviceWorker' in navigator) || !('sync' in ServiceWorkerRegistration.prototype)) {
console.warn('Background Sync not supported');
return;
}
try {
const registration = await navigator.serviceWorker.ready;
// 注意:这里 key 名必须是字符串,且不能含空格/特殊字符
await registration.sync.register('sync-todos');
// 把待同步数据存进 indexedDB(不能存在 localStorage!因为 SW 无法访问)
await savePendingTodos(data);
console.log('Background sync registered for todos');
} catch (err) {
console.error('Failed to register sync', err);
}
};
然后是 service worker 文件(sw.js)里的处理逻辑:
// sw.js
const PENDING_TODOS_STORE = 'pending-todos';
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-todos') {
event.waitUntil(
handlePendingTodos()
.catch((err) => {
console.error('Sync failed, will retry later:', err);
// 失败不 throw,让浏览器自动重试(默认最多 5 次,间隔递增)
})
);
}
});
async function handlePendingTodos() {
const db = await openDB(); // 自定义 indexedDB 打开逻辑
const tx = db.transaction(PENDING_TODOS_STORE, 'readwrite');
const store = tx.objectStore(PENDING_TODOS_STORE);
const pending = await store.getAll();
if (pending.length === 0) return;
// 逐条发请求(避免并发太多失败)
for (const item of pending) {
try {
const res = await fetch('https://jztheme.com/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.data),
});
if (!res.ok) throw new Error(HTTP ${res.status});
// 成功后从 indexedDB 删除
await store.delete(item.id);
} catch (err) {
console.warn('Sync item failed, keeping in queue:', item.id, err);
// 不删,留给下次重试
}
}
await tx.done;
}
最后补了个兜底:如果用户长时间没联网,indexedDB 里积压太多,我们加了个手动清空入口(藏在设置页底部,写了个小提示:“同步异常?点击重试”):
// 前端触发重试(比如用户点了“重试同步”按钮)
const triggerSyncManually = async () => {
const registration = await navigator.serviceWorker.ready;
// 强制触发一次 sync 事件(即使没注册过也会注册并立即触发)
await registration.sync.register('sync-todos');
};
踩坑提醒:这三点一定注意
- indexedDB 必须在 service worker 里操作:别想着在页面 JS 里存好数据,SW 里去读 localStorage——SW 根本读不到。必须用 indexedDB,而且 openDB 要在 SW 里完成,不能复用页面的 db 实例。
- sync 事件不会立刻触发:注册后浏览器只记个账,真正执行要等“网络可用 + 设备空闲 + 后台活跃”三个条件同时满足。iOS 上可能等几分钟才跑第一次。所以别在注册后立刻 expect 数据已同步,得给用户明确反馈(比如“已加入同步队列”)。
- 不要在 sync 里做 UI 操作:event.waitUntil 里只能跑纯 JS 逻辑。想通知用户成功?得用
self.clients.matchAll()找到打开的页面,再用client.postMessage发消息过去。我一开始直接在 sync 里调alert,结果控制台报错说 “window is not defined” —— 笑死,SW 里哪来的 window。
顺带提一句,目前 Firefox 还不支持 Background Sync API,所以我在检测不支持时 fallback 到了一个简陋但有效的方案:页面可见时(document.visibilityState === 'visible')每 30 秒检查一次 indexedDB 里有没有 pending 数据,有就挨个发。虽然不够优雅,但至少保住了基本功能。
改完之后上线跑了两天,离线编辑+切回在线的场景下,100% 同步成功。不过还有个小问题:如果用户在 sync 执行中突然关机,那条 pending 数据会卡在 indexedDB 里,下次启动时得靠手动触发重试才能清理。但这已经比之前“丢数据”强太多了——至少数据没丢,只是晚点同步。
以上是我踩坑后的总结,希望对你有帮助。这个方案不是最优的(比如没做冲突合并),但足够简单、稳定、可维护。如果你有更好的方案——比如用 Web Push 做状态通知,或者用更精细的 indexedDB 分片策略——欢迎评论区交流。

暂无评论