# Web Push 与 PushManager.subscribe()

> PushManager.subscribe() 如何创建带 VAPID 密钥的 PushSubscription、endpoint、p256dh 与 auth 的用途、push 事件如何到达 Service Worker，以及拒绝错误。

import Figure from '@components/Figure.astro';
import pushFlowDiagram from '@assets/diagrams/push-flow.svg';

`registration.pushManager.subscribe()` 向浏览器的推送服务申请一个 `PushSubscription`，你的服务器可以向它的 `endpoint` POST 加密消息；浏览器为每条消息唤醒 Service Worker 并触发 `push` 事件，即使没有页面打开，worker 随后显示通知。它与 Notifications API 一起构成 Web Push，是服务器触达已关闭应用的 PWA 用户的唯一标准方式。

<Figure src={pushFlowDiagram} alt="Web Push 在网页、Service Worker、推送服务与应用服务器之间的时序图：页面请求通知权限，用 VAPID 公钥调用 pushManager.subscribe()，收到 PushSubscription 并发给服务器。服务器向 endpoint 发送带 VAPID 签名的加密请求，推送服务投递，浏览器在 Service Worker 内触发 push 事件，worker 调用 showNotification()。点按触发 notificationclick，pushsubscriptionchange 触发重新订阅。" caption="Web Push 从订阅到通知：谁与谁通信，哪个事件在哪里触发。" />

## 语法

```js
registration.pushManager.subscribe(options)
registration.pushManager.getSubscription()
registration.pushManager.permissionState(options)
subscription.toJSON()
subscription.unsubscribe()
```

`subscribe()` 以 `PushSubscription` 兑现；若该源已用同一密钥订阅，则返回现有订阅。`getSubscription()` 以当前订阅或 `null` 兑现。三者在窗口和 Service Worker 自身的 `self.registration` 上都可用。支持情况：Chrome 42、Firefox 44、Edge 17、macOS Ventura 上的 Safari 16、iOS 上的 Safari 16.4 仅限主屏幕 Web App（BCD `api.PushManager.subscribe`）。Firefox 72+ 要求 `subscribe()` 在用户手势内运行；Chromium 要求提供 `applicationServerKey`。

## 参数

| 参数 | 类型 | 含义 |
|---|---|---|
| `options.userVisibleOnly` | boolean | 承诺每次推送都产生可见通知。Chromium 与 WebKit 对 `false` 以 `NotAllowedError` 拒绝；WebKit 还会撤销 `push` 处理函数未显示通知的订阅（[Meet Web Push](https://webkit.org/blog/12945/meet-web-push/)，webkit.org）。Firefox 接受 `false`，改为对静默推送施加配额。 |
| `options.applicationServerKey` | `BufferSource` 或 base64url 字符串 | 你的 VAPID 公钥，65 字节的未压缩 P-256 点。服务器用对应的私钥为每次发送签名（[RFC 8292](https://datatracker.ietf.org/doc/html/rfc8292)，ietf.org）。Chromium 中必填；Firefox 中没有它的订阅无法在不退订的情况下迁移到 VAPID。 |

返回的 `PushSubscription` 经 `toJSON()` 序列化为 `{ endpoint, expirationTime, keys: { p256dh, auth } }`。`endpoint` 是浏览器厂商推送服务上的能力 URL（Chrome 为 `https://fcm.googleapis.com/fcm/send/…`，Safari 为 `*.push.apple.com` 主机）；持有它的任何人都能向该设备发送，所以它要像凭据一样传输和存储。`p256dh` 与 `auth` 是客户端用于 RFC 8291 载荷加密的公钥和认证密钥。`expirationTime` 在所有现行引擎中都是 `null`。

## 异常

| 异常 | 触发条件 |
|---|---|
| `NotAllowedError` `DOMException` | 通知权限为 `"denied"`，或用户关掉了 `subscribe()` 触发的提示；在 Chromium 或 WebKit 中 `userVisibleOnly` 为 `false`；在 Firefox 72+ 中调用不在用户手势内；Service Worker 的作用域不是安全上下文。 |
| `InvalidStateError` `DOMException` | 注册没有活跃 worker，或已存在使用不同 `applicationServerKey` 的订阅。更换密钥前先退订。 |
| `InvalidAccessError` `DOMException` | `applicationServerKey` 不是合法的 P-256 公钥（长度错误或不是未压缩点）。 |
| `InvalidCharacterError` `DOMException` | `applicationServerKey` 的 base64url 字符串无法解码。 |
| `AbortError` `DOMException` | 推送服务无法创建订阅，通常因为设备离线或服务不可达。稍后重试。 |

服务端的失败是推送服务返回的 HTTP 状态而不是异常：`404` 和 `410` 表示订阅已失效，必须删除；`413` 表示载荷超过 4 KB 上限；`429` 表示推送服务正在对应用服务器限流。

## 示例

页面负责订阅，Service Worker 负责接收，第三段代码在做这两件事之前先检查已有的订阅。

### 在点击中订阅并把订阅发给服务器

权限与订阅在同一个手势里完成。密钥以 base64url 字符串形式从服务器取得，规范允许 `subscribe()` 直接接受这种形式；旧教程里的 `urlBase64ToUint8Array()` 转换只在早于这一变化的引擎上才需要。

```js
button.addEventListener('click', async () => {
  const registration = await navigator.serviceWorker.ready;
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return;
  const { publicKey } = await (await fetch('/api/push/key')).json();
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: publicKey,
  });
  await fetch('/api/push/subscriptions', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(subscription.toJSON()),
  });
});
```

按用户和设备存储订阅，并在发送返回 `404` 或 `410` 时删除；持续向失效 endpoint 推送是投递静默失败最常见的原因。

### 接收消息并在 endpoint 变化时重新订阅

`push` 处理函数必须以 `showNotification()` 结束。推送服务轮换 endpoint 时触发 `pushsubscriptionchange`；worker 用同一密钥重新订阅并告知服务器。

```js
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {};
  event.waitUntil(
    self.registration.showNotification(data.title ?? '有新内容', {
      body: data.body ?? '',
      data: { url: data.url ?? '/' },
    })
  );
});

self.addEventListener('pushsubscriptionchange', (event) => {
  event.waitUntil((async () => {
    const key = event.oldSubscription?.options.applicationServerKey;
    const subscription = await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key });
    await fetch('/api/push/subscriptions', { method: 'POST', body: JSON.stringify(subscription.toJSON()) });
  })());
});
```

`pushsubscriptionchange` 在 Firefox 44（没有 `oldSubscription`，bug 1497429）、Safari 16 和 Chrome 138 中触发（BCD `api.ServiceWorkerGlobalScope.pushsubscriptionchange_event`）；Chrome 138 之前服务器只能通过收到的 `410` 察觉轮换。

### 检测支持并复用已有订阅

先检查注册上是否有 `PushManager`，再优先调用 `getSubscription()`，这样刷新页面不会创建第二个订阅。兜底是轮询或应用内消息。

```js
async function currentSubscription() {
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    return null; // 没有推送：应用打开期间轮询更新
  }
  const registration = await navigator.serviceWorker.getRegistration();
  if (!registration?.active) return null;
  return registration.pushManager.getSubscription(); // 已有订阅或 null
}
```

权限为 `"granted"` 却返回 `null`，说明用户还没有订阅，这正是显示第一个示例里那个启用按钮的时机。

:::observed
在 Chrome 中 `subscription.toJSON()` 返回的对象，其 `endpoint` 以 `https://fcm.googleapis.com/fcm/send/` 开头，`expirationTime` 为 `null`（[Push notifications overview](https://web.dev/articles/push-notifications-overview)，web.dev）；在 Safari 中 endpoint 主机位于 `push.apple.com` 之下，WebKit 要求服务器运营者把它加入白名单（[Meet Web Push](https://webkit.org/blog/12945/meet-web-push/)，webkit.org）。Chrome DevTools 的 Application > Service workers 在注册旁有一个 **Push** 文本框和按钮，无需任何服务器就能把输入的载荷作为 `push` 事件派发给 worker，这就是在 VAPID 密钥存在之前测试上面处理函数的方法。
:::

## 试一试

配套演示 [/demo/#push](/demo/#push) 调用 `Notification.requestPermission()`，再用为演示生成的 VAPID 公钥调用 `pushManager.subscribe()`，并打印得到的 `PushSubscription` JSON。它背后没有服务器：什么都不会发送，也不会有消息到达，因此是观察权限流程和订阅形状的安全场所。

## 另请参阅

- [Push API: subscribe() method](https://www.w3.org/TR/push-api/#dom-pushmanager-subscribe)（w3.org）
- [RFC 8292: Voluntary Application Server Identification (VAPID) for Web Push](https://datatracker.ietf.org/doc/html/rfc8292)（ietf.org）
- [Push notifications overview](https://web.dev/articles/push-notifications-overview)（web.dev）
- [Notifications API](/zh/reference/notifications/notifications-api/)
- [iOS 与 iPadOS 上的 Web Push](/zh/reference/notifications/ios-safari-push/)
- [Notification.requestPermission()](/zh/reference/notifications/permissions/)