12k
All articles

使用 Background Sync 实现离线表单提交

将表单提交存入 IndexedDB,用 Background Sync 和在线回退重放,并通过 Idempotency-Key 防止重复订单。

OpenReplay Team
OpenReplay Team
使用 Background Sync 实现离线表单提交

要使表单提交在离线或网络不稳定的情况下不丢失数据,需要在发起网络请求之前将提交内容写入 IndexedDB,注册一个 Background Sync 标签,并在网络恢复时通过 service worker 的 sync 事件重放已排队的请求——同时为不支持 Background Sync 的浏览器添加 online 事件回退方案。最后这一点不是可选项:Background Sync API 仅在 Chromium 浏览器中可用,因此 IndexedDB 队列加上 online 事件监听器才是适用于所有浏览器的基础方案,Background Sync 只是在此之上的增强功能。

本文通过一个完整示例——联系/订单表单——演示整个模式:持久化队列、同步注册、service worker 重放、通用回退方案、重复提交防护,以及一个鲜有人提及的确认状态问题。本文假设你已经注册了 service worker,并熟悉 Promise 和 fetch。通用缓存策略(cache-first、network-first 等)是前置知识,不在本文讨论范围内——请参阅 MDN 的缓存策略文档Workbox 策略模块

核心要点

  • 在发起网络请求之前,将提交内容连同客户端生成的请求 ID、queuedAt 时间戳和重试计数一起写入 IndexedDB,这样即使 POST 请求被中断,数据也已持久化,不会丢失。
  • 一次性 Background Sync(SyncManager)仅在 Chromium 浏览器中受支持——包括 Chrome、Edge、Opera 和三星浏览器——在 Firefox、Safari 和 iOS 中均不可用,这正是 2026 年仍必须提供 online 事件回退方案的原因。
  • 在 service worker 的 sync 处理函数中,对每个已排队的条目发起 POST 请求,成功后删除该条目,失败时抛出异常——抛出异常会通知浏览器保留注册状态并按浏览器管理的指数退避策略进行重试。
  • 将请求 ID 作为 Idempotency-Key 请求头发送,使服务器能够对重放的已接受提交进行去重,防止产生重复订单。
  • Workbox 的 BackgroundSyncPlugin 可自动完成请求的排队和重放,但它仅重试真正的网络失败——4xx 或 5xx 响应会被视为已送达,不会触发重放。

为什么提交时直接使用 fetch 在离线状态下会失败

在设备离线时,表单提交时直接调用 fetch 会静默失败:Promise 被拒绝,请求永远无法到达服务器,除非你编写了明确的错误处理逻辑,否则用户不会收到任何失败提示。Service worker 解决了资源缓存问题,但不会自动重试表单发起的新数据请求——一个失败的 POST 请求就此消失。

静默失败的离线 POST 是典型的”用户以为提交成功,数据实际未到达”的 Bug。它对后端监控也是不可见的,因为请求从未到达后端——没有任何日志记录。会话回放(Session Replay)技术正是用来填补这一可见性缺口的:回放能还原客户端的真实情况——点击提交按钮、乐观显示的”感谢”页面、随后的页面跳转——你可以将这些与服务器记录是否真实出现进行关联分析。这种关联正是发现本文所要解决的信任缺口的关键手段。

“提交即忘”对用户而言应该是即时的,对系统而言应该是持久可靠的:数据在用户点击提交的那一刻就已在本地捕获,送达是系统的责任,而非用户的负担。

第一步:在发起网络请求之前将提交内容排队写入 IndexedDB

先将提交内容持久化到 IndexedDB,再尝试网络传输。在任何网络调用之前完成存储,意味着即使 POST 请求失败、被中断或处于离线状态,数据已经持久化——你从存储中重放,而不是依赖易失的页面状态。为每个排队条目附加三个字段:客户端生成的请求 ID(用于后续去重)、queuedAt 时间戳和 retryCount

// db.js — a thin promise wrapper around IndexedDB
const DB_NAME = 'outbox';
const STORE = 'submissions';

function openDB() {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, 1);
    req.onupgradeneeded = () => {
      req.result.createObjectStore(STORE, { keyPath: 'id' });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export async function enqueue(payload) {
  const db = await openDB();
  const item = {
    id: crypto.randomUUID(),     // request ID for idempotency
    payload,
    queuedAt: Date.now(),
    retryCount: 0,
  };
  return new Promise((resolve, reject) => {
    const tx = db.transaction(STORE, 'readwrite');
    tx.objectStore(STORE).put(item);
    tx.oncomplete = () => resolve(item);
    tx.onerror = () => reject(tx.error);
  });
}

crypto.randomUUID() 在所有现代浏览器和 service worker 作用域中均可用,详见 MDN 的 Crypto.randomUUID() 参考文档。表单处理函数调用 enqueue,展示乐观的”已排队”状态,然后请求同步。

第二步:注册 Background Sync 并处理 sync 事件

Background Sync 允许你从页面注册一个具名标签;当浏览器判断网络连接已恢复时,会在你的 service worker 中触发 sync 事件,即使用户已经导航离开或关闭了标签页,该事件仍可被送达。这种”导航离开后延迟送达”的能力正是它比 online 监听器更强大的地方——online 监听器只在页面打开时才会触发。

在依赖 Background Sync 之前,需要同时对 serviceWorkerSyncManager 进行特性检测,任一缺失时立即降级:

// form handler, after enqueue()
async function requestSync() {
  if ('serviceWorker' in navigator && 'SyncManager' in window) {
    const reg = await navigator.serviceWorker.ready;
    try {
      await reg.sync.register('sync-forms');
      return;
    } catch {
      // registration failed — fall through to the fallback
    }
  }
  flushQueue(); // the everywhere fallback (Step 3)
}

register('sync-forms') 调用和 SyncManager 接口的文档详见 MDN 的 Background Synchronization API。在 service worker 中,匹配对应标签,将工作包裹在 event.waitUntil() 中以保持 worker 存活,对每个条目发起 POST 请求,成功后删除,失败时抛出异常,以便浏览器保留注册状态并进行重试:

// service-worker.js
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-forms') {
    event.waitUntil(replayQueue(event));
  }
});

async function replayQueue(event) {
  const items = await getAll();         // read from IndexedDB
  for (const item of items) {
    const res = await fetch('/api/orders', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': item.id,      // dedupe on the server
      },
      body: JSON.stringify(item.payload),
    });
    if (res.ok) {
      await remove(item.id);             // delete on success
    } else if (res.status >= 500) {
      if (event.lastChance) await notifyFailure(item);
      throw new Error('server error, retry');   // keep the registration
    }
    // 4xx: the payload is bad — don't retry blindly; surface it instead
  }
}

waitUntil 内部抛出异常是保持同步挂起状态的信号。支持该 API 的浏览器会代你按浏览器管理的间隔重放失败请求,重放间隔可能采用指数退避策略。重试次数和具体间隔由浏览器管理,没有正式文档约定,因此不要硬编码”重试三次”之类的假设。应改为检查 event.lastChance——详见 MDN 的 SyncEvent 参考文档——以检测最后一次尝试,并在提交最终失败时告知用户,而不是让它悄无声息地消失。

第三步:适用于所有浏览器的回退方案

由于 Background Sync 仅在 Chromium 中可用,online 事件回退方案不是附加说明——它是每个浏览器都能运行的基础方案。两个触发时机覆盖了非 Chromium 的场景:每当 online 事件触发时重新刷新队列,并在每次页面加载时刷新一次,以处理上一个会话中排队的提交。

// runs on the page, everywhere
export async function flushQueue() {
  if (!navigator.onLine) return;
  const items = await getAll();
  for (const item of items) {
    try {
      const res = await fetch('/api/orders', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': item.id,
        },
        body: JSON.stringify(item.payload),
      });
      if (res.ok) await remove(item.id);
    } catch {
      await bumpRetryCount(item.id);   // stays queued for the next trigger
    }
  }
}

window.addEventListener('online', flushQueue);
window.addEventListener('load', flushQueue);

回退方案有一个明显的局限性:它只在你的源下有页面处于打开状态时才能运行,因为它依赖页面级事件。它无法像 Background Sync 那样唤醒已关闭的标签页。这正是两者之间的权衡——覆盖范围更广,但送达保证更弱——这也是为什么要注册 Background Sync,降级到回退方案。

2026 年 Background Sync 的浏览器支持情况

截至 2026 年 6 月,一次性 Background Sync(SyncManager)仍是 Chromium 专属 API。MDN 将其归类为”有限可用”——尚未达到 Baseline 状态,因为它在部分主流浏览器中不可用。caniuse 数据证实了以下支持情况。

浏览器一次性 Background Sync(SyncManager
Chrome✅ 支持
Edge(Chromium 内核)✅ 支持
Opera✅ 支持
三星浏览器✅ 支持
Firefox❌ 不支持
Safari(macOS)❌ 不支持
Safari(iOS)❌ 不支持
Android WebView❌ 不支持

有两点值得特别注意。Microsoft Edge 在切换到 Chromium 内核后才获得支持,因此任何 2019 年前声称”Edge 不支持”的说法现已过时。另外,Android WebView——原生应用内嵌网页浏览所使用的组件——不暴露 SyncManager,因此将 PWA 包装在 WebView 外壳中会失去原生同步队列,降级为 online 事件路径。请注意,这里讨论的是一次性 Background Sync API;Periodic Background Sync 是另一个用于定期更新的实验性 API,不要将两者混淆。

幂等性:防止重放产生重复订单

任何重试的 POST 请求都有产生重复数据的风险:第一次请求可能已经到达服务器并完成提交,只是在连接断开前响应未能返回,导致客户端重放了一个服务器已经处理过的请求。解决方案是将客户端生成的请求 ID 作为 Idempotency-Key 请求头发送——即排队时存储的 item.id。服务器以此为键,对重复请求返回原始结果,而不是创建第二条记录。

Idempotency-Key 是一种由 Stripe 和 PayPal 推广的成熟 API 约定,目前已有 IETF 互联网草案 draft-ietf-httpapi-idempotency-key-header 对其进行规范。应将其视为进行中的约定,而非正式标准:HTTP Idempotency-Key 请求头字段可用于使 POST 或 PATCH 等非幂等 HTTP 方法具备容错能力,但该草案本身附有标准声明,不得以正式标准身份引用。服务端实现较为直接:对于幂等键已被处理过的重复请求,资源服务器应返回之前已完成操作的结果,无论是成功还是错误。

用户离开后的确认状态 UX 问题

棘手的 UX 问题在于:延迟送达往往在用户导航离开后才完成,因此需要一种机制,在请求真正落地后将乐观的”已排队”状态同步更新。以下三种处理方式可以覆盖这一场景:

  • 实时将”已排队”切换为”已发送”。 当 service worker 成功重放某个条目时,通过 postMessage 通知所有打开的客户端页面,使 UI 即时更新。使用 navigator.serviceWorker.addEventListener('message', ...) 进行监听。
  • 下次加载时进行状态核对。 启动时读取队列的清空状态:仍存在的条目表示待处理,条目不存在则表示已成功送达。从存储中渲染状态,而不是依赖提交时设置的标志位。
  • 暴露终态失败。sync 处理函数中使用 event.lastChance 触发通知(或持久化一个”失败”标记供 UI 在下次加载时读取),确保耗尽重试次数的提交不会悄无声息地消失。

确认状态 UX 是会话回放发挥第二个价值的地方:回放使用户侧的时间线变得可见——排队的提交、用户看到的页面、“已发送”确认是否最终渲染——这是唯一能够在请求从未到达服务器留下日志时,揭示信任缺口的视角。

使用 Workbox 作为生产环境的快捷方案

如果不想手动实现队列,Workbox(当前主版本为 7)提供了 BackgroundSyncPlugin,它将失败请求排队写入 IndexedDB,并在 sync 事件触发时进行重放。值得注意的是,它内置了回退机制:在不原生支持 BackgroundSync API 的浏览器中,Workbox Background Sync 会在 service worker 每次启动时自动尝试重放。

import { BackgroundSyncPlugin } from 'workbox-background-sync';
import { registerRoute } from 'workbox-routing';
import { NetworkOnly } from 'workbox-strategies';

const bgSync = new BackgroundSyncPlugin('order-queue', {
  maxRetentionTime: 24 * 60, // minutes; the documented example keeps items 24h
});

registerRoute(/\/api\/orders/, new NetworkOnly({ plugins: [bgSync] }), 'POST');

有两个行为容易让人踩坑。第一,BackgroundSyncPlugin 挂钩于 fetchDidFail 插件回调,而 fetchDidFail 仅在抛出异常时触发,通常是网络故障导致的,这意味着收到 4xx 或 5xx 响应的请求不会被重试。如果你希望 5xx 响应也被重新排队,需要添加一个 fetchDidSucceed 插件,在 response.status >= 500 时手动抛出异常。第二,在测试时,可以在 Chrome DevTools > Application > IndexedDB 中查看已排队的请求,并通过 DevTools > Application > Service Workers 强制触发重放。不要使用 DevTools 的”Offline”复选框来验证离线行为——它会阻断页面请求,但允许 service worker 请求通过,因此会掩盖你正在测试的那类 Bug。应改为直接断开真实网络连接进行测试。

无论是手动实现还是采用 Workbox,架构是相同的:

  1. 在发起网络请求之前先在本地捕获数据;
  2. 在支持的地方优先使用 Background Sync;
  3. 在其他所有地方降级为 online 事件;
  4. 使用幂等键对重放请求进行去重;
  5. 在送达完成后同步更新 UI 状态。

将这五个环节与你自己的表单结合起来,然后通过以下方式验证完整的重放流程:在离线状态下排队提交,重新连接网络,确认服务器只产生了一条记录。

常见问题

一次性 Background Sync 和 Periodic Background Sync 有什么区别?

一次性 Background Sync 使用 SyncManager 接口,在网络恢复后重放单个延迟任务(例如已排队的表单提交),浏览器触发的 sync 事件即使在用户导航离开后也能送达。Periodic Background Sync 使用独立的 PeriodicSyncManager 接口,按浏览器管理的计划执行定期更新,例如刷新内容。两者是不同的 API,且 Periodic Background Sync 目前仍处于实验阶段,在生产环境使用前请务必确认其兼容性。

为什么服务器返回 400 或 500 错误时,已排队的请求仍然不会重试?

Background Sync 和 Workbox 的 BackgroundSyncPlugin 只重试因真正网络错误而失败的请求,因为插件挂钩于 fetchDidFail 回调,该回调仅在抛出异常时触发。400 或 500 响应属于已收到的响应,被视为已送达,不会触发重放。如需对 5xx 响应进行重新排队,可添加一个 fetchDidSucceed 插件,在 response.status 为 500 或更高时抛出异常;或者在手动实现的 sync 处理函数中手动抛出异常。

Background Sync 在包装 PWA 的 Android WebView 中是否有效?

不支持。Android WebView 是原生应用内嵌网页浏览所使用的组件,它不暴露 SyncManager,因此包装在 WebView 外壳中的 PWA 会失去原生 Background Sync 队列,降级为 online 事件路径。online 事件只在你的源下有页面处于打开状态时才会触发,无法唤醒已关闭的标签页。正因如此,加上 Firefox、Safari 和 iOS 均不支持,带有 online 事件回退方案的 IndexedDB 队列必须作为基础方案保留。

如何可靠地测试离线 Background Sync 的行为?

应断开真实网络连接,而不是使用 DevTools 的"Offline"复选框。Offline 复选框会阻断页面请求,但允许 service worker 请求通过,因此会掩盖你正在测试的那类 Bug。可在 Chrome DevTools 的 Application > IndexedDB 中查看已排队的条目,并通过 Application > Service Workers 强制触发重放。要验证完整流程,需在离线状态下排队提交,重新连接网络,然后确认服务器只产生了一条记录。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.