用好 Background Sync 让离线操作更可靠的实战经验分享
我的写法,亲测靠谱
Background Sync 这玩意儿,说实话我一开始也没太当回事。直到有次用户反馈:离线发消息,回来发现没同步上,数据丢了——这下炸了。客户直接打电话来问,是不是我们前端代码写错了?折腾了半天才发现,不是逻辑问题,是 sync 的时机和重试机制没设计好。
后来我在几个项目里都上了 Background Sync,现在基本成了标配。但真要写对,有几个坑必须避开。先上核心代码,这是我现在标准的注册方式:
// 注册 service worker 并监听 sync 事件
if ('serviceWorker' in navigator && 'SyncManager' in window) {
navigator.serviceWorker.register('/sw.js').then(reg => {
document.getElementById('send-btn').addEventListener('click', async () => {
const data = { text: document.getElementById('msg-input').value };
// 先存到 IndexedDB
await saveToQueue(data);
// 尝试立即发送
if (navigator.onLine) {
await sendNow(data).catch(() => {
// 发送失败,也走正常流程,靠 sync 补偿
});
}
// 最后触发 sync(即使在线也注册一次,防万一)
reg.sync.register('sync-messages');
alert('已加入同步队列');
});
});
}
注意这里我没有只依赖 navigator.onLine 来判断是否需要 sync。这玩意儿不可靠,有时候返回 true 实际上网络还是不通。所以我不管三七二十一,只要用户点了发送,就先入库,再尝试发,最后再注册 sync——宁可多一次 sync 调用,也不能丢数据。
然后是 sw.js 里的处理:
self.addEventListener('sync', event => {
if (event.tag === 'sync-messages') {
event.waitUntil(
syncMessages()
);
}
});
async function syncMessages() {
let queue;
try {
queue = await getPendingRequests(); // 从 IndexedDB 取出待发数据
} catch (err) {
console.error('读取本地队列失败', err);
return; // 失败不 throw,避免 sync 无限重试
}
if (!queue.length) return;
for (const req of queue) {
try {
const res = await fetch('https://jztheme.com/api/messages', {
method: 'POST',
body: JSON.stringify(req.data),
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
await removeFromQueue(req.id); // 成功才删
} else {
throw new Error(HTTP ${res.status});
}
} catch (err) {
console.warn('发送失败,保留到下次', req.id, err);
// 不清空队列,等下次 sync 再试
break; // 中断循环,避免后续请求误判成功
}
}
}
这段代码最关键是:失败不抛异常,也不清队列。以前我图省事,失败直接 throw,结果浏览器疯狂重试,每分钟跑好几次 sync,用户体验极差。后来改成静默失败 + 保留在队列,靠下次页面打开时主动触发 sync 才缓解。
另外,我用了 break 而不是 continue。因为一旦有一个失败,后面的很可能也通不过(比如 token 失效、服务器挂了),没必要全试一遍。等下次网络恢复再说。
这几种错误写法,别再踩坑了
- 只在 offline 时注册 sync —— 我最早就这么干的。问题是,有些时候设备显示“在线”,但实际发不出去请求(比如弱网、DNS 慢、公司代理限制)。这时候你以为安全了,其实数据卡住了。正确做法是:无论是否在线,都注册 sync,让它作为兜底机制。
- sync 里直接发请求,不查本地队列 —— 有人图方便,在 sync 事件里直接重新执行页面上的发送逻辑。大错特错!页面上下文已经没了,变量、状态全丢,很可能报 undefined。必须通过 IndexedDB 或 Cache 存储原始数据,由 SW 独立处理。
- sync 失败后清空队列 —— 更致命的操作。有一次我清理队列放在 try 前面,结果一失败数据就没了,用户再也找不回来。现在我的原则是:只有收到 2xx 响应,并且明确删除记录,才算完成。
- 用 setTimeout 模拟 sync —— 别笑,真有人这么干。页面里开个定时器轮询网络,说是“轻量级方案”。问题是页面关闭后定时器就停了,等于白做。Background Sync 的意义就在于页面关了也能运行,你用 setTimeout 完全违背初衷。
实际项目中的坑
第一个坑是兼容性。iOS Safari 直到现在都不支持 Background Sync。我有个内部系统,安卓同事测试没问题,结果销售拿 iPhone 一用,离线消息全丢。后来只能降级处理:iPhone 上改用 periodic background fetch 提醒用户手动刷新,体验差很多。
第二个坑是频率限制。Chrome 对 sync 的触发是有节制的,不会你一注册就立刻执行。它要看设备状态、电量、网络情况。所以不要指望 sync 是实时的。我测试过,最慢的时候等了七八分钟才触发。因此一定要给用户提示:“已暂存,网络恢复后自动同步”。
第三个坑是调试困难。devtools 里的 Application → Service Workers 可以手动 dispatch sync 事件,但不代表真实环境行为一致。特别是手机端,后台进程可能被系统杀掉。建议上线前用飞行模式 + 强刷页面多测几轮。
还有一个小细节:我原来在 reg.sync.register() 后面加了 .catch(),想着捕获注册失败的情况。结果发现,如果用户第一次拒绝了通知权限,sync 也会注册失败?后来查文档才知道,某些浏览器把 sync 和 notification 权限混在一起管理。现在我不再 catch register 错误,而是通过 feature detection 提前判断是否支持。
最后提一句性能。每次 sync 都会唤醒 service worker,如果队列太大(比如几百条消息),处理时间长了会被系统中断。我现在的做法是每次最多发 10 条,发完就停下,等下次 sync 继续。这样更稳妥。
总结一下我现在的套路
我现在做 Background Sync 的流程很固定:
- 用户操作 → 数据写入 IndexedDB
- 尝试立即发送(即使在线)
- 调用
sync.register注册后台任务 - SW 触发时,拉取队列逐条发送
- 成功则删,失败则留,下次继续
- 页面加载时检查是否有未完成队列,有就主动 trigger sync
这个方案不是最优的,比如没有做指数退避重试,也没有合并请求。但它简单、稳定、不容易出事。改完之后到现在没再收到数据丢失的反馈,对我来说就是胜利。
以上是我踩坑后的总结,希望对你有帮助。有更好的实现方式欢迎评论区交流。毕竟这种底层功能,谁也不敢说百分百完美。

暂无评论