# Notifications API：系统通知

> PWA 如何显示操作系统级通知：权限模型、持久与非持久通知之分，以及为何移动端要用持久通知。

**一句话：** Notifications API 让网页得以控制向最终用户显示**系统**通知。Web 通知是一个由操作
系统自身的原生通知系统渲染的消息框，因此它的外观与该平台上任何其他应用的通知完全一致——并且
正因为由操作系统渲染，它位于顶层浏览上下文视口*之外*，即使用户切换了标签页或转到了另一个
应用，它也能被显示出来。它是通知的*显示*那一半；**Web Push** 是*投递*那一半，会在页面关闭时
唤醒你的 service worker。

该 API 仅在安全上下文（HTTPS）中可用（在部分或全部支持它的浏览器中如此），并且可在
Web Worker 中使用。

## Notifications API 对比 Web Push

- **Notifications API** —— 创建并显示一条通知。该接口同时暴露给 `Window` 与 `Worker`，而其
  构造函数仅在 `ServiceWorkerGlobalScope` 中被禁止，因此一条非持久通知可以来自页面，也可以
  来自其他 worker 上下文；service worker 则改用 `showNotification()`。
- **Web Push** —— 让服务器在站点关闭时也能向用户设备投递消息；浏览器唤醒 service worker，
  后者随即调用 Notifications API 显示内容。要记住的是这层分工：Notifications API 负责显示，
  而 Web Push 让服务器能在应用关闭时主动发起投递。

会话内提醒直接用 Notifications API；必须在应用未打开时送达的消息，则与
[Web Push](/zh/reference/notifications/web-push/) 搭配。

## 持久通知与非持久通知

规范是按 service worker 注册来划界的：**非持久（non-persistent）**通知是其 service worker
注册为 *null* 的通知，**持久（persistent）**通知则是其注册*非 null* 的通知。这一个区分几乎
决定了通知行为的其余一切。

| | 非持久 | 持久 |
|---|---|---|
| 创建方式 | `new Notification(title, options)` | `registration.showNotification(title, options)` |
| 创建位置 | 任何允许使用该构造函数的作用域——`Window` 或非 service worker 的 `Worker`；MDN 把常见情形描述为浏览上下文，例如网页或标签页 | 具有 `ServiceWorkerRegistration` 的 `Window` 或 `Worker` 上下文 |
| 生命周期 | 与创建它的上下文绑定；MDN 的表述是：页面一旦关闭，通知便无法再被交互 | 可以在单个页面的生命周期之外继续保持可交互 |
| 事件 | `show`、`click`、`close` 在 `Notification` 对象上触发 | `notificationclick` 与 `notificationclose` 在 `ServiceWorkerGlobalScope` 上触发 |
| 通知中心 | 用户代理**不应**将其显示在平台的“通知中心”中（若平台提供） | 用户代理**应当**将其显示在那里 |
| 操作按钮 | 若 `options["actions"]` 非空，构造函数抛出 `TypeError` | 支持（参见下方的逐浏览器表格） |
| 由什么表示 | 恰好一个 `Notification` 对象 | 零个或多个 `Notification` 对象 |

其中两个后果值得明说：

- **非持久通知在设计上就是转瞬即逝的。** 用户代理*应当*在非持久通知创建后数秒就执行其关闭
  步骤，且*应当*把它排除在通知中心之外；而持久通知才是用户代理*应当*显示在那里的那一类。这些
  是 `should` 级别的建议而非绝对要求，因此请把它们当作设计意图来理解：如果一条通知需要留存到
  用户之后回头查看，持久通知这条路径才是为此而写的。
- **在移动端，持久通知是可移植的选择。** MDN 的指引非常直接：如果你的代码需要在移动设备上
  运行，那么你必须使用持久通知，因为 `Notification()` 构造函数在**大多数**移动浏览器上会抛出
  `TypeError`。「大多数」这个词是准确的——browser-compat-data 记录 Chrome for Android 总是抛错，
  而 Firefox for Android 镜像桌面版 Firefox，在那里构造函数是受支持的。按持久通知这条路径来写，
  正是让代码跨越这道分裂的办法。

```js
// 持久通知：能跨越上述移动端分裂的那条路径。
// `ready` 正是「把执行推迟到 worker 活跃为止」的工具——在你已确知本页会注册 worker 时
// 用它没问题。但它不适合用在*检测*路径：那里它永不 reject 且会无限期等待，
// 详见下文「如何在运行时检测」。
async function notify(title, body) {
  const registration = await navigator.serviceWorker.ready;
  await registration.showNotification(title, { body, data: { url: '/inbox' } });
}
```

在 service worker 而非页面中处理激活：

```js
// service-worker.js
self.addEventListener('notificationclick', (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow(event.notification.data.url));
});
```

`ServiceWorkerRegistration.getNotifications()` 会按创建顺序返回当前源通过当前 service worker
注册创建的通知列表——用于合并或清理你已经显示过的内容很方便。

## 权限模型

显示通知需要用户授予当前源显示系统通知的权限。`Notification.permission` 是以下三个字符串之一：

- `granted` —— 用户已明确授予当前源显示系统通知的权限。
- `denied` —— 用户已明确拒绝。
- `default` —— 用户的决定未知；此时应用会按权限被拒绝来行事。请把它理解为「目前还不能显示任何
  东西」，而不要当成「用户从未见过提示」的保证。

- 用 `Notification.permission` 读取当前状态。
- 用 `Notification.requestPermission()` 请求，它会解析为最终状态。
- **从用户手势中调用它。** MDN 指出该方法只应在处理用户手势（例如处理一次鼠标点击）时调用。
- **选择会持续。** 一旦做出选择，该设置通常会在当前会话内持续——因此在拒绝之后，你在该会话
  内通常无法再弹出请求。

```js
btn.addEventListener('click', async () => {
  // 先做守卫：在 iOS 标签页里该接口是 undefined，读取它会抛错。
  if (!('Notification' in window)) return;
  const permission = await Notification.requestPermission();
  if (permission === 'granted') {
    await notify('已为你开启', '有新动态时我们会通知你。');
  }
});
```

## 通知选项

`options` 常包含 `body`、`icon`、`badge`、`tag`（用于合并或替换通知）以及 `data`（供点击处理器
使用的负载）。另外两个也值得了解：

- **`actions`** —— 操作按钮。`Notification.maxActions` 是一个静态 getter，其步骤是返回所支持的
  最大操作数量，所以请读取它，不要凭假设写死数量。切记构造函数会以 `TypeError` 拒绝非空的
  `actions` 列表。
- **`navigate`** —— 一个 URL，在通知被接受时会被打开。当设置了 `navigate` URL 时，非持久通知
  **不会**触发 `click` 事件；用户代理会改为导航到该 URL。

## 浏览器与生态支持

MDN 将 Notifications API 的可用性标注为**有限**——它不是 Baseline，因为它在部分最广泛使用的
浏览器中并不工作。来自 MDN browser-compat-data 的逐浏览器情况，其不均衡之处比版本号本身更值得
关注：

| 浏览器 | 起始支持 `Notification` | browser-compat-data 记录的注意事项 |
|---|---|---|
| Chrome（桌面） | 20 | 自 Chrome 49 起，通知在隐身模式下不工作 |
| Chrome（Android） | 42 | 部分支持：通知只能从 service worker 发送，且构造函数*总是*抛出 `TypeError` |
| Edge | 14 | —— |
| Firefox | 22 | `actions` 自 Firefox 152 起才支持 |
| Safari（macOS） | 7 | 不支持 `actions` |
| Safari（iOS/iPadOS） | 16.4 | 部分支持：除非页面是已添加到主屏、且其 manifest 的 `display` 为非默认值的 web 应用，否则 `Notification` 接口是 **undefined**；通知只能从 service worker 发送 |
| Samsung Internet | 4.0 | 部分支持：仅通过 service worker 可用 |
| Android WebView / iOS WebView | 不支持 | —— |

iOS/iPadOS 那一行最让人意外，而它与 WebKit 自己的公告是吻合的：驱动通知的 Web Push 在
iOS 与 iPadOS 16.4 中是**为已添加到主屏的 web 应用**引入的，而非为在 Safari 标签页中打开的
站点。该平台的规则见 [iOS 与 Safari Web Push](/zh/reference/notifications/ios-safari-push/)。

`actions` 与 `navigate` 的支持落后于基础接口：`actions` 在 Chrome 53、Edge 18、Opera 39 与
Firefox 152 落地，Safari 不支持；而 `navigate` 在 Safari 18.4 中受支持，Chrome 不支持。

## 如何在运行时检测

由于该接口可能整个是 **undefined**（iOS 标签页），也由于即便接口存在构造函数仍可能抛错
（Android 上的 Chrome），请探测持久通知那条路径，并在其缺失时显式分支——不要假设可以退回到
`new Notification()`。

有一个细节决定了回退分支究竟会不会被执行：`'serviceWorker' in navigator` 只证明该 *API* 存在，
并不证明当前页面已有可用的 worker。`ServiceWorkerContainer.ready` 的用途是把代码执行推迟到
service worker 处于活跃状态为止——MDN 给出的契约是：它返回的 Promise **永远不会 reject**，并会
*无限期*等待，直到与当前页面关联的注册拥有活跃 worker。因此在探测路径里 await 它没有有界结果：
当没有东西可供激活时，它既不兑现也不 reject，页面内回退永远不会执行。

`getRegistration()` 是那个会 settle 的替代方案——MDN 描述它 resolve 为一个
`ServiceWorkerRegistration` 或 `undefined`——但仅有注册还不够。
`ServiceWorkerRegistration.active` 返回状态为 activating 或 activated 的那个 worker，MDN 说明
没有时它为 `null`；一个注册可能只持有 `installing` 或 `waiting` worker，而按 Service Worker
规范，安装失败的 installing worker 会变为 `redundant`，此时活跃 worker 永远不会出现。所以请
直接检查活跃 worker，没有就返回 `null`，而不要把等待交给 `ready`：

```js
async function getNotifier() {
  if (!('Notification' in window) || !('serviceWorker' in navigator)) {
    // 没有可用的 Notifications API —— 例如并非主屏 web 应用的 iOS 标签页。
    return null;
  }
  // getRegistration() 无论如何都会 settle；而 `ready` 永不 reject 且会无限期等待。
  const registration = await navigator.serviceWorker.getRegistration();
  // 注册可能只持有 installing / waiting worker，而该安装还可能变成 redundant，
  // 所以这里要求活跃 worker，而不是在此等待激活。
  if (!registration || !registration.active) {
    return null;
  }
  if (!('showNotification' in registration)) {
    return null;
  }
  return registration;
}

async function alertUser(title, body) {
  const notifier = await getNotifier();
  if (!notifier) {
    // 回退：把消息留在页面内，而不是直接丢掉。
    showInAppBanner(title, body);
    return;
  }
  if (Notification.permission !== 'granted') {
    showInAppBanner(title, body);
    return;
  }
  await notifier.showNotification(title, { body });
}
```

## 实践清单

- [ ] 使用前同时特性检测 `Notification` **与** `navigator.serviceWorker`，并为两者都不存在的分支保留页面内回退。
- [ ] 在检测路径中用 `navigator.serviceWorker.getRegistration()` 取得注册并要求 `registration.active`；不要 await `navigator.serviceWorker.ready`——它永不 reject，且会为一个可能永远产生不出活跃 worker 的注册（只有 `installing`/`waiting`，或安装失败）无限期等待。若确实需要等待激活，请监听 worker 的 `statechange` 区分 `activated` 与 `redundant`，并加上你自己的超时，超时即走回退。
- [ ] 各处都优先使用 `registration.showNotification()`——它是移动端可移植的路径，是用户代理应当在通知中心里呈现的那一类，也是唯一接受 `actions` 的路径。
- [ ] 绝不要向 `new Notification()` 传入 `actions`：非空列表会抛出 `TypeError`。
- [ ] 读取 `Notification.maxActions`，而不要写死能显示多少个按钮。
- [ ] 从用户手势、在上下文中、在用户选择加入提醒之后请求权限。
- [ ] 在拒绝之后隐藏该功能，而非在该会话内再次弹出请求——该设置通常会在会话内持续。
- [ ] 在 service worker 的 `notificationclick` 事件中处理点击；聚焦或打开正确的页面。
- [ ] 在 iOS 与 iPadOS 上，把整个功能限制在 manifest 设置了非默认 `display` 值的主屏 web 应用之内。
- [ ] 不要把通知当作 WebView 功能来交付：Android 与 iOS WebView 都不支持该接口。
- [ ] 当通知必须在应用关闭时送达时，与 Web Push 搭配。

## 下一步

- [Web Push](/zh/reference/notifications/web-push/) —— 投递那一半：VAPID 密钥、订阅与服务端
  发送路径。
- [iOS 与 Safari Web Push](/zh/reference/notifications/ios-safari-push/) —— 主屏要求，以及
  上表 iOS 那一行背后的 WebKit 规则。
- [通知权限](/zh/reference/notifications/permissions/) —— 如何询问，以及被拒绝意味着什么。
- [通知操作与徽标](/zh/reference/notifications/notification-actions-badge/) —— 实践中的操作
  按钮与应用图标徽标。