# 添加推送通知

> 用 VAPID 密钥把 Service Worker 订阅到推送服务，在点击中请求通知权限，并在 push 事件到达时显示消息。

完成本指南后，即使应用已关闭，你的服务器也能用一条消息唤醒用户的设备：页面把 Service Worker 订阅到
浏览器的推送服务，服务器向订阅 endpoint 发送请求，worker 把 `push` 事件变成一条系统通知。
这里涉及两个 API：推送是把消息送到 worker 的传输层，Notifications API 是用户看到的展示层。

你需要一个已激活的 Service Worker（没有的话先做[入门](/zh/guides/getting-started/)）、一个 HTTPS 源，
以及服务端的一对 VAPID 密钥。支持情况：Push API 自 2023-03 起在各浏览器可用；Safari 只向主屏幕 Web 应用
投递，始于 iOS 与 iPadOS 16.4，macOS Ventura 上的 Safari 16.1 同样支持。各浏览器的明细见
[Web Push 网页推送支持](/zh/compatibility/web-push/)。

## 1. 先确认支持，再提供按钮

`ServiceWorkerRegistration.pushManager` 是入口；浏览器没有推送能力时 `window` 上不存在 `PushManager`。
这种情况下隐藏订阅控件，沿用你已有的应用内渠道（站内收件箱、邮件摘要）。在 iOS 上，Safari 标签页里
这个检测同样不通过，只有用户把应用添加到主屏幕之后才通过，所以回退文案应当说明这一点。

```js
export function pushSupported() {
  return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
}
```

## 2. 在点击中请求权限

`Notification.requestPermission()` 解析为 `granted`、`denied` 或 `default`；把 `default` 当作拒绝处理。
在一个说明了通知用途的按钮的点击处理函数里调用它。Firefox 72 拒绝非用户手势触发的请求，Safari 同样要求
直接的用户交互，而 `denied` 在用户到浏览器设置里改回之前都是最终结果，所以页面加载时的一次冒失请求
可能让你永久失去这条渠道。

```js
subscribeButton.addEventListener('click', async () => {
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    subscribeButton.disabled = true;
    subscribeButton.textContent = '本站的通知已关闭';
    return;
  }
  await subscribe();
});
```

## 3. 用 VAPID 公钥订阅

仍在手势之内，调用 `pushManager.subscribe()`，传入 `userVisibleOnly: true` 与服务端的 VAPID 公钥作为
`applicationServerKey`。`userVisibleOnly` 不为 `true` 时 Chrome 与 Edge 拒绝该 Promise，并且它们要求提供
这个密钥（或在 manifest 里写旧式的 `gcm_sender_id`）。密钥是一个 ECDSA P-256 公钥，在服务端做 Base64url
编码；它不是用来加密载荷的 ECDH 密钥。

```js
function base64UrlToUint8Array(base64Url) {
  const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
  const base64 = (base64Url + padding).replace(/-/g, '+').replace(/_/g, '/');
  return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
}

async function subscribe() {
  const registration = await navigator.serviceWorker.ready;
  const existing = await registration.pushManager.getSubscription();
  const subscription =
    existing ||
    (await registration.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: base64UrlToUint8Array(VAPID_PUBLIC_KEY),
    }));
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify(subscription.toJSON()),
  });
}
```

:::observed
在 Chrome 中不带 `applicationServerKey`、manifest 里也没有 `gcm_sender_id` 时调用 `subscribe()`，
会以一个 `DOMException`（名称为 `AbortError`）拒绝，报错信息为
`Registration failed - missing applicationServerKey, and gcm_sender_id not found in manifest`。
Firefox 接受同样的调用，所以只在 Firefox 里测过的订阅流程，第一次碰到 Chrome 就会失败。
:::

## 4. 保存订阅，并把它当作会过期的东西

`subscription.toJSON()` 给出 `endpoint` 以及服务端加密与发送所需的 `p256dh` 与 `auth` 密钥。
endpoint 是一个能力 URL：持有它的任何人都能向该用户推送，所以只在服务端保存，并为订阅接口做好 CSRF 防护。
每个订阅只属于一个 Service Worker 注册，推送服务可能设置过期时间，到期时 worker 会收到
`pushsubscriptionchange`；在那里重新订阅并更新服务器。

```js
// sw.js
self.addEventListener('pushsubscriptionchange', (event) => {
  event.waitUntil(
    self.registration.pushManager
      .subscribe(event.oldSubscription.options)
      .then((subscription) =>
        fetch('/api/push/subscribe', {
          method: 'POST',
          headers: { 'content-type': 'application/json' },
          body: JSON.stringify(subscription.toJSON()),
        }),
      ),
  );
});
```

## 5. 在 worker 中显示通知

`push` 事件的 `event.data` 装着解密后的载荷。订阅时承诺过消息对用户可见，所以每条推送都显示一条通知；
Chrome 没有投递配额，Firefox 则限制不产生通知的推送数量，并在每次访问时刷新配额。处理 `notificationclick`
以聚焦已打开的窗口，或新开一个。

```js
// sw.js
self.addEventListener('push', (event) => {
  const data = event.data ? event.data.json() : { title: '新消息', body: '' };
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: '/icons/icon-192.png',
      data: { url: data.url || '/' },
    }),
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(
    self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
      const open = clients.find((client) => 'focus' in client);
      return open ? open.focus() : self.clients.openWindow(event.notification.data.url);
    }),
  );
});
```

## 6. 从 DevTools 发一条测试推送

写服务端代码之前，打开 Chrome DevTools，进入 **Application** > **Service workers**（英文界面），在 **Push**
按钮旁的输入框里填入载荷，点击 **Push**。worker 的 `push` 处理函数会带着这段文本运行；`event.data.json()`
遇到纯文本会抛错，所以用 `{"title":"Hi","body":"test"}` 来测。操作系统会弹出带你的图标的通知，
点击它会聚焦应用。随后用一个会签发 VAPID JWT、并用保存的密钥加密的 Web Push 库，从服务器发送同样的载荷。

## 另请参阅

- [Web Push：订阅、权限与投递](/zh/reference/notifications/web-push/)
- [Notifications API：系统通知](/zh/reference/notifications/notifications-api/)
- [通知权限模型](/zh/reference/notifications/permissions/)
- [iOS 与 Safari 的 Web Push（16.4+）](/zh/reference/notifications/ios-safari-push/)
- [PushManager: subscribe() method](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe)（developer.mozilla.org）
- [Web Push for Web Apps on iOS and iPadOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/)（webkit.org）
- [Notification: requestPermission() static method](https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission_static)（developer.mozilla.org）

← 返回[指南](/zh/guides/)总览。