后台同步技术实战解析与常见坑点避坑指南

长孙海霞 前端 阅读 894
赞 15 收藏
二维码
手机扫码查看
反馈

后台同步这玩意儿,真不是点个按钮就完事的

今天上线前测到一个诡异问题:用户在离线状态下编辑了三条待办,切回在线后,只有第一条同步成功,后两条“消失”了。不是报错,不是卡死,就是发了个请求,然后石沉大海——连 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 分片策略——欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论