用好 Background Sync 让离线操作更可靠的实战经验分享

东方宁宁 移动 阅读 1,714
赞 19 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

Background Sync 这玩意儿,说实话我一开始也没太当回事。直到有次用户反馈:离线发消息,回来发现没同步上,数据丢了——这下炸了。客户直接打电话来问,是不是我们前端代码写错了?折腾了半天才发现,不是逻辑问题,是 sync 的时机和重试机制没设计好。

用好 Background 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 的流程很固定:

  1. 用户操作 → 数据写入 IndexedDB
  2. 尝试立即发送(即使在线)
  3. 调用 sync.register 注册后台任务
  4. SW 触发时,拉取队列逐条发送
  5. 成功则删,失败则留,下次继续
  6. 页面加载时检查是否有未完成队列,有就主动 trigger sync

这个方案不是最优的,比如没有做指数退避重试,也没有合并请求。但它简单、稳定、不容易出事。改完之后到现在没再收到数据丢失的反馈,对我来说就是胜利。

以上是我踩坑后的总结,希望对你有帮助。有更好的实现方式欢迎评论区交流。毕竟这种底层功能,谁也不敢说百分百完美。

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

暂无评论