配了三年离线应用,今天才发现「写」的操作从来不是自己管的
配了三年离线应用,今天才发现「写」的操作从来不是自己管的
做离线优先的应用,缓存读操作是最容易的部分——把静态资源塞进 Cache Storage,Network-First 兜个底,用户打开页面就能看到内容。
但写操作呢?用户填了一张表单,网断了,提交失败。关掉页面走人,数据就丢了。
这个问题,Service Worker 的缓存策略根本解决不了。解决它的是另一个 API:Background Sync。
缓存策略解决不了的问题
缓存的逻辑是「读取」:把服务器数据存在本地,下次请求直接返回。GET 请求可以缓存,POST 请求没法缓存——POST 是写操作,结果是幂等的还是重复的,只有服务器知道。
用户离线时点了「提交」,请求根本发不出去。传统的解法是「等网络恢复再重试」——但用户已经把页面关了。
Background Sync 的思路不一样:把写操作先存到本地(IndexedDB),然后告诉浏览器「帮我盯着,网络恢复的时候自动重发」。用户可以关掉页面,数据不会丢。
三行代码注册一个后台同步
用户点提交时注册一个 sync 任务:
// 用户提交表单时
navigator.serviceWorker.ready.then(registration => {
registration.sync.register('sync-form-submission');
});
这行代码的核心意思是:浏览器把这个任务持久化到磁盘上,不用 JS 进程持续运行,网络恢复时浏览器会自动唤醒 Service Worker 触发 sync 事件。
Service Worker 里监听这个事件:
self.addEventListener('sync', event => {
if (event.tag === 'sync-form-submission') {
event.waitUntil(syncPendingForms());
}
});
async function syncPendingForms() {
const db = await openDB('my-app', 1);
const pending = await db.getAll('pending-forms');
for (const form of pending) {
try {
const response = await fetch('/api/forms', {
method: 'POST',
body: JSON.stringify(form)
});
if (response.ok) {
await db.delete('pending-forms', form.id);
}
} catch (err) {
// 重抛错误,浏览器会自动重试
throw err;
}
}
}
关键细节:如果 syncPendingForms() 返回的 Promise 被 reject,浏览器会以指数退避策略自动重试,不需要自己写重试逻辑。这是浏览器原生的离线重试能力。
完整的离线写操作架构
实战中,离线写操作需要三条线的配合:
第一条线:IndexedDB 持久化队列
用户提交时,先把数据写入 IndexedDB,再尝试立即发送。发送失败了,队列里已经有了:
async function handleSubmit(formData) {
const record = { id: uuid(), data: formData, timestamp: Date.now() };
// 1. 先持久化到本地
await db.add('pending-forms', record);
// 2. 尝试立即发送
try {
await fetch('/api/forms', {
method: 'POST',
body: JSON.stringify(record)
});
// 发送成功,删掉本地记录
await db.delete('pending-forms', record.id);
return { success: true };
} catch (err) {
// 网络断了,交给 Background Sync
if ('serviceWorker' in navigator && 'SyncManager' in window) {
navigator.serviceWorker.ready.then(sw => {
sw.sync.register('sync-form-submission');
});
}
return { success: true, offline: true };
}
}
第二条线:Background Sync 自动重发
网络恢复时,浏览器触发 sync 事件,Service Worker 从 IndexedDB 读出所有 pending 的写操作,逐一重发。
第三条线:用户感知反馈
用户提交后,即使离线也要给明确的反馈,不能让用户以为数据丢了:
const result = await handleSubmit(formData);
if (result.offline) {
showToast('已保存,网络恢复后自动提交');
} else {
showToast('提交成功');
}
Periodic Sync:不用用户操作的后台更新
Background Sync 是「被动」同步——网络恢复时触发。Periodic Sync 则是「主动」检查——浏览器按固定间隔在后台运行任务,用户不用打开应用。
// 注册每 24 小时执行一次的后台同步
navigator.serviceWorker.ready.then(registration => {
registration.periodicSync.register('update-feed', {
minInterval: 24 * 60 * 60 * 1000
});
});
// Service Worker 监听
self.addEventListener('periodicsync', event => {
if (event.tag === 'update-feed') {
event.waitUntil(refreshFeed());
}
});
Periodic Sync 的权限比 Background Sync 更严:需要用户在当前站点授予通知权限,浏览器还会根据设备电量、网络状况决定是否真正执行。不能依赖它做关键操作。
适用场景:新闻 Feed 预加载、邮件同步、通知检查——提前把新内容拉到本地,用户打开应用时已经是最新的。
三个坑,真实项目必踩
坑一:Background Sync 会无限重试
当 sync 返回的 Promise reject 时,浏览器不会放弃,会一直重试直到成功。如果服务器因为业务逻辑返回错误(比如「库存不足」),这个写操作会永远重试,直到用户清掉站点数据。需要区分「网络错误」(应该重试)和「业务错误」(应该停止并通知用户):
async function syncPendingForms() {
const pending = await db.getAll('pending-forms');
for (const form of pending) {
const response = await fetch('/api/forms', { method: 'POST', body: JSON.stringify(form) });
if (response.status >= 400 && response.status < 500) {
// 4xx 业务错误,不重试,删掉并通知用户
await db.delete('pending-forms', form.id);
notifyUser('提交失败,请重新填写');
} else {
throw err; // 网络错误/5xx,重试
}
}
}
坑二:iOS Safari 支持有限
Background Sync 在 iOS Safari 上的支持一直是残缺的。Chrome/Edge/Samsung Internet 支持良好,但 Safari 的 Background Sync 实现不稳定,Periodic Sync 基本不支持。解决方案:结合 online 事件监听作为兜底——网络恢复时主动触发一次同步:
window.addEventListener('online', () => {
syncPendingForms();
});
这个兜底在所有浏览器都有效,只是需要页面开着才能触发,不够「后台」。
坑三:Sync 任务不跨标签页
同一个站点开了两个标签页,A 标签页注册的 sync,在 B 标签页也会触发。但如果用户同时在两个标签页操作,sync 时可能会重复提交相同的写操作。解法是幂等设计:后端对相同 ID 的重复提交做去重处理,或者在 IndexedDB 队列里用唯一约束。
下一步:从队列到完整离线架构
- 选场景:哪些写操作需要离线支持?表单提交、评论、订单、文件上传……不是所有操作都需要 Background Sync,用户能接受失败重试的场景不必强行做。
- 搭队列:IndexedDB 建一张
pending-writes表,写操作先落本地队列,再尝试发送,失败时注册 sync 任务。 - 区分错误类型:网络错误走重试逻辑,业务错误走用户通知逻辑,别让 Background Sync 把失败状态永远锁在队列里。
- 加感知:用
postMessage把同步状态推回主线程,用户能实时看到「离线已保存」或「同步成功/失败」的提示。
Background Sync 补的是 Service Worker 缓存能力的最后一块短板:读靠缓存,写靠队列,中间那段网络断层的体验,靠它接上了。
评论区
登录后可评论。