# Background Sync API

> SyncManager.register() 如何把失败请求推迟到恢复连接后在 sync 事件中重放，以及它的异常、重试上限与检测方式。

import Figure from '@components/Figure.astro';
import backgroundSyncDiagram from '@assets/diagrams/background-sync.svg';

Background Sync API 让页面在 service worker 注册上登记一个带名字的 *sync*；当浏览器判定设备恢复连接时，它会在 service worker 中触发 `sync` 事件，即使发起登记的页面早已关闭。它是 Chromium 独有的能力（Chrome 49、Edge 79、Samsung Internet 5.0），Firefox 与 Safari 均未实现（BCD `api.SyncManager`），因此所有依赖它的重试都必须准备一条不依赖它的回退路径。

<Figure src={backgroundSyncDiagram} alt="Background sync 流程图：请求在离线时失败，页面把它存入 IndexedDB 并调用 sync.register('outbox')；浏览器保留该 tag 直到网络恢复，然后在 service worker 中触发 sync 事件，worker 在 event.waitUntil() 内重放队列；Promise 兑现则清除注册，lastChance 为 false 的拒绝会稍后按退避重试，lastChance 为 true 的拒绝则不再重试。" caption="Background sync：排队、注册、在 sync 事件中重放，以及 Promise 落定的三种结果。" />

## 语法

```js
// 页面或 service worker 中（Chromium 要求存在一个 window 客户端）
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
const tags = await registration.sync.getTags();

// service worker 中
self.addEventListener('sync', (event) => {
  event.waitUntil(doWork(event.tag, event.lastChance));
});
```

`register()` 返回 `Promise<void>`，在注册写入存储后兑现。对一个仍在等待的 tag 重复注册不会报错，只是无操作，所以页面可以在每次请求失败时直接调用，无需自己记账。`getTags()` 返回 `Promise<string[]>`，列出尚未成功触发的 tag。

## 参数

该方法接受一个字符串参数；事件对象暴露两个只读属性。

| 名称 | 类型 | 说明 |
|---|---|---|
| `tag` | `string` | 标识这次注册。每个 tag 同时只存在一条待处理注册；同一个 tag 会以 `SyncEvent.tag` 送达。Chromium 限制 tag 最长 10,240 个字符。 |
| `SyncEvent.tag` | `string` | 浏览器此次触发所对应的 tag。一个 worker 处理多条队列时据此分支。 |
| `SyncEvent.lastChance` | `boolean` | 为 `true` 表示若 `waitUntil()` 的 Promise 再被拒绝，浏览器不会再重试。此时应把失败告知用户，而不是继续重试。 |

## 异常

`register()` 以拒绝的 Promise 报错，不会同步抛出。

- `InvalidStateError`：注册上还没有处于 active 状态的 service worker。先 `await navigator.serviceWorker.ready` 再调用 `register()` 即可避免（规范 "register(tag)" 第 3 步）。
- `InvalidAccessError`：在 Chromium 中，从一个没有任何 window 客户端的 service worker 调用了 `register()`，或 tag 超出长度上限。Chromium 的报错原文是 `Attempted to register a sync event without a window or registration tag too long.`。
- `NotAllowedError`：站点的"后台同步"权限被屏蔽。Chromium 在 `chrome://settings/content/backgroundSync` 与站点权限面板中暴露该开关；拒绝信息为 `Permission denied.`。
- `TypeError`：Firefox 与 Safari 中 `registration.sync` 为 `undefined`，`registration.sync.register()` 在产生任何 Promise 之前就会抛出。用 `'sync' in registration` 做特性检测。

`waitUntil()` 的 Promise 被拒绝不会在页面侧表现为异常：浏览器会安排重试（Chromium：最多 3 次尝试，第二次在 5 分钟后，退避系数为 3），并在最后一次把 `lastChance` 置为 `true`。

:::observed
在 Chrome（英文界面）中，DevTools › Application › Background services › Background sync 面板在按下录制按钮（提示文字为 "Start recording events"）之前是空的；之后每一次注册、派发与完成都会以带时间戳的行出现，附带来源、service worker scope 与 tag，且录制在页面刷新与关闭标签页后仍保留，因此可以直接看到延迟后的重试。从没有任何打开的 window 客户端的 service worker 调用 `registration.sync.register('x')`，会以 `InvalidAccessError: Attempted to register a sync event without a window or registration tag too long.` 拒绝；这段文字来自 Chromium 的 `sync_manager.cc`（见"另请参阅"）。
:::

## 示例

两个示例都假定已在 `/sw.js` 注册了 scope 覆盖当前页面的 service worker。

### 把失败的 POST 入队，并在 `sync` 事件中重放

页面把待发送的请求写入 IndexedDB（这里假定有一个类似 `idb-keyval` 的极简封装）并注册 tag；事件触发后由 worker 清空队列。队列必须放在存储里而不是 worker 的变量中：从请求失败到 `sync` 事件触发之间，浏览器可能已经终止过这个 worker。

```js
// page.js
async function sendOrQueue(payload) {
  try {
    await fetch('/api/messages', { method: 'POST', body: JSON.stringify(payload) });
  } catch {
    await outbox.add(payload);                 // 基于 IndexedDB 的队列
    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('outbox');
  }
}

// sw.js
self.addEventListener('sync', (event) => {
  if (event.tag !== 'outbox') return;
  event.waitUntil(drainOutbox(event.lastChance));
});

async function drainOutbox(lastChance) {
  for (const item of await outbox.all()) {
    const response = await fetch('/api/messages', { method: 'POST', body: JSON.stringify(item.payload) });
    if (!response.ok && !lastChance) throw new Error(`replay failed: ${response.status}`);
    await outbox.remove(item.id);
  }
}
```

在 `drainOutbox()` 中抛错会让 `waitUntil()` 的 Promise 被拒绝，这正是向浏览器申请重试的方式。到了 `lastChance` 这一轮，上面的代码不再抛错并清空队列，让注册结束；生产环境的应用应把这些条目标记为失败，在下次启动时展示给用户。

### 检测支持并在不支持时改用别的方式重试

Firefox 与 Safari 的注册对象上没有 `sync` 属性。下面的回退在 `online` 事件（各浏览器均会触发）时重试一次，否则留到下次页面加载再处理。

```js
async function deferOrRetry(tag, retryNow) {
  const registration = await navigator.serviceWorker.ready;
  if ('sync' in registration) {
    return registration.sync.register(tag);
  }
  // 没有 Background Sync：页面一察觉网络恢复就立即重试。
  window.addEventListener('online', () => retryNow(), { once: true });
}
```

回退只在页面打开期间有效，这正是 Background Sync 所补上的能力缺口；应明确告知用户损失了什么（"下次打开应用时会发送你的消息"），而不是掩盖它。

## 另请参阅

- [Background Synchronization specification: register(tag) method](https://wicg.github.io/background-sync/spec/#dom-syncmanager-register)（wicg.github.io）
- [Introducing Background Sync](https://developer.chrome.com/blog/background-sync)（developer.chrome.com）
- [Chromium source: sync_manager.cc](https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/modules/background_sync/sync_manager.cc)（chromium.googlesource.com）
- [Background Sync 浏览器支持](/zh/compatibility/background-sync/)
- [Periodic Background Sync API](/zh/reference/service-worker/periodic-background-sync/)
- [Service worker 生命周期](/zh/reference/service-worker/lifecycle/)
- [IndexedDB](/zh/reference/storage/indexeddb/)