配了三年离线应用,今天才发现「写」的操作从来不是自己管的

配了三年离线应用,今天才发现「写」的操作从来不是自己管的

做离线优先的应用,缓存读操作是最容易的部分——把静态资源塞进 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 队列里用唯一约束。

下一步:从队列到完整离线架构

  1. 选场景:哪些写操作需要离线支持?表单提交、评论、订单、文件上传……不是所有操作都需要 Background Sync,用户能接受失败重试的场景不必强行做。
  2. 搭队列:IndexedDB 建一张 pending-writes 表,写操作先落本地队列,再尝试发送,失败时注册 sync 任务。
  3. 区分错误类型:网络错误走重试逻辑,业务错误走用户通知逻辑,别让 Background Sync 把失败状态永远锁在队列里。
  4. 加感知:用 postMessage 把同步状态推回主线程,用户能实时看到「离线已保存」或「同步成功/失败」的提示。

Background Sync 补的是 Service Worker 缓存能力的最后一块短板:读靠缓存,写靠队列,中间那段网络断层的体验,靠它接上了。

评论区

0 条评论

登录后可评论。

阿速·性能优化 14 阅读