# iOS 与 Safari 的 Web Push（16.4+）

> Web Push 在 iOS、iPadOS 与 Safari 上的运作方式：主屏幕前提、由用户交互触发的权限请求、VAPID 订阅，以及必须绕开的 WebKit 规则。

import CompatTable from '@components/CompatTable.astro';

**一句话：** Web Push 是 **Push API**、**Notifications API** 与 **Service Worker** 三项 W3C 标准
协同工作的能力，让站点在未被打开时也能通知用户；WebKit 在 **iOS 与 iPadOS 16.4** 上为
已添加到主屏幕的 Web 应用加入了该能力——权限请求必须由用户的直接交互触发，且订阅必须
承诺展示可见通知。自 **iOS 与 iPadOS 18.4** 起还有第二种形态——**Declarative Web Push**
（声明式 Web Push），它在*无需*已安装 Service Worker 的情况下即可订阅并展示通知。

## 16.4 带来了什么

- **基于标准的推送。** WebKit 实现了与 macOS 上相同的 W3C 标准，因此只要应用是按标准编写的
  ——使用特性检测而非浏览器检测——就能自动在 iPhone 与 iPad 上生效。
- **iOS/iPadOS 需要主屏幕 Web 应用。** *已添加到主屏幕* 的 Web 应用可以请求接收推送通知的
  权限；这正是 WebKit 为其加入推送的场景。
- **通知与其他应用的通知一致。** 它们出现在锁定屏幕、通知中心以及配对的 Apple Watch 上，
  用户可在「通知」设置中按 Web 应用逐个管理。
- **Focus 与 Badging 同期到来。** 主屏幕 Web 应用的通知与「专注模式」集成；16.4 同时加入了
  [Badging API](/zh/reference/installation/badging/)，Web 应用可以设置图标角标数字。
- **由 APNs 负责投递。** iOS 与 iPadOS 上的 Web Push 使用与原生推送相同的 Apple 推送通知服务。
  你**无需**加入 Apple Developer Program；但如果你管控服务器的推送端点，请放行
  `*.push.apple.com` 的 URL。

## 支持情况

在 iPhone 与 iPad 上，iOS/iPadOS 16.4 是下限：该能力随这一版本到来，面向已添加到主屏幕的
Web 应用。桌面端的时间线更早，且没有安装这一步——WebKit 的 iOS/iPadOS 16.4 文章把 macOS 上的版本记为
**macOS Ventura 上的 Safari 16.1**（更早的公告写的是 Safari 16）；其实现依赖系统守护进程
（`webpushd`），因此即便 Safari 未在运行，推送也能送达。

<CompatTable feature="web-push" />

### 「16.4、仅限主屏幕」在代码里长什么样

MDN 的 browser-compat-data 把 `Notification` 接口的 iOS/iPadOS 条目记为 **16.4、部分支持**，
它附上的两条注记正是这道安装门槛的具体形态：

- **除非页面是已保存到主屏幕的 Web 应用，否则该接口是 undefined**，而且该应用的清单必须有一个
  **非默认的 `display` 值**。所以在 iOS 上这不是一个你能从中恢复的「权限被拒绝」状态——在普通
  Safari 标签页里这个符号根本不存在，触碰构造函数会抛出 `ReferenceError`。
- 在 iOS 与 iPadOS 上，**通知只能从 Service Worker 发送**：请使用
  `ServiceWorkerRegistration.showNotification()`，而不是 `new Notification()`。

这两条注记解释了为什么一套在桌面版 Safari 上可用的推送集成，在 iPhone 上会显得完全不存在：
清单的 `display` 值承担着关键作用，而页面作用域的 `new Notification()` 在该平台上根本没有
受支持的路径。

## iOS 上的流程（原始 Web Push）

以下是 WebKit 在 16.4 中推出的、基于 Service Worker 的路径。18.4 新增的无需 Service Worker
的替代方案见下方「Declarative Web Push（18.4+）」一节。

1. **先安装。** 用户从分享菜单把 Web 应用添加到主屏幕。16.4 起第三方浏览器也可以提供
   「添加到主屏幕」，且只要清单把 `display` 设为 `standalone` 或 `fullscreen`，无论由哪个
   浏览器添加，打开时都以 Web 应用形式运行。
2. **由直接的用户交互发起请求。** 在已安装的 Web 应用内调用
   `Notification.requestPermission()`，且必须响应真实交互——WebKit 将其描述为点按 Web 应用
   提供的「订阅」按钮。随后 iOS 或 iPadOS 会弹出系统提示。
3. **使用 VAPID 订阅。** 调用 `pushManager.subscribe()`，传入 `userVisibleOnly: true` 与你的
   应用服务器密钥，并把返回的 `PushSubscription`（`endpoint` 以及 `p256dh`、`auth` 密钥）
   持久化到服务器端。
4. **发送并展示。** 服务器向 `endpoint` 投递加密消息；`push` 事件会启动你的 Service Worker，
   在其中调用 `showNotification()`。

第一个可能失败的环节是权限请求，而在 iOS 上它失败得很*硬*：正如上表所记录的，普通标签页里
`Notification` 接口是 undefined，因此 `Notification.requestPermission()` 会在任何
`subscribe()` 调用之前抛出 `ReferenceError`——只包住 `subscribe()` 的 `try` 根本看不到它。
请先检测该接口，并把权限请求一并纳入这条受控路径：

```js
// 在已安装的 Web 应用内，从用户交互处理函数中调用：
async function enablePush(swReg, applicationServerKey) {
  // 在 iOS/iPadOS 上，主屏幕 Web 应用之外该接口并不存在，普通标签页里读取它会抛出
  // ReferenceError。请把这种情况导向安装引导。
  if (!('Notification' in window)) {
    showAddToHomeScreenGuidance();
    return null;
  }
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') return null;
  try {
    return await swReg.pushManager.subscribe({
      userVisibleOnly: true, // 必填：每条推送都必须展示通知
      applicationServerKey,  // VAPID 密钥——客户端代码中没有 APNs 密钥
    });
  } catch (error) {
    // NotAllowedError 可能表示推送权限被拒、window 作用域不是 HTTPS，或用户代理要求
    // userVisibleOnly 为 true 而实际传入了 false。它不能证明应用尚未安装；上面的
    // Notification 守卫才用于检测 iOS 上的这道门槛。
    reportSubscriptionFailure(error);
    return null;
  }
}
```

## Declarative Web Push（18.4+）

上文描述的是**原始 Web Push**：以 JavaScript 为先的设计，由 Service Worker 注册创建订阅，并在
`PushEvent` 处理函数中调用 `showNotification()`。WebKit 在 **iOS 与 iPadOS 18.4** 上为已添加到
主屏幕的 Web 应用推出了 **Declarative Web Push**，并自 **Safari 18.5**（随 macOS 15.5 发布）起
在 **macOS** 上支持：它让你**无需已安装的 Service Worker** 就能请求推送订阅并展示用户可见通知。

写代码时有两点差异要注意：

- **不同的 `PushManager`。** 原始 Web Push 唯一可用的 `PushManager` 是
  `ServiceWorkerRegistration.pushManager`。Declarative Web Push *额外*暴露了
  `window.pushManager`，以便在没有 Service Worker 的情况下管理订阅。如果你同时在域名根作用域
  注册了 Service Worker，它与 `window` 对象共享同一个推送订阅——而注销该注册并不会影响这个订阅。
- **声明式的消息格式。** 要让通知以声明式方式被处理，推送消息必须符合声明式标准 JSON 格式
  （一个 `"web_push": 8030` 字段加上一个 `notification` 对象），这样浏览器无需任何 JavaScript
  就掌握了展示通知所需的全部信息。Service Worker 的 JavaScript 可以选择性地修改收到的通知内容。

```js
// Declarative Web Push：无需注册 Service Worker。
const subscription = await window.pushManager.subscribe({
  userVisibleOnly: true,
  applicationServerKey: arrayForPublicKey,
});
```

## 特性检测与回退

不要嗅探 Safari，而应做特性检测——WebKit 自己的建议就是：使用特性检测的应用会在推送发布后
自动在 iPhone 与 iPad 上获得该能力。优先使用声明式的、挂在 window 上的 manager，没有它时再
回退到原始 Web Push 所需的、挂在 Service Worker 注册上的那个：

要探测的是可用的 *manager*，而不是 `PushManager` 接口：这个全局接口存在，本身并不意味着当前
上下文就能订阅。同时要注意它做**不到**什么——下面的任何客户端探测都无法确定是否为主屏幕
Web 应用，因此在 iOS 上，一个普通的 Safari 标签页可能通过这些检查，却在之后才失败。真正拦住
这种标签页的，是上文 `enablePush()` 里的 `Notification` 守卫；`subscribe()` 被 reject 本身
**不是**可靠的安装信号，因为同一条拒绝通道也承载着密钥与订阅错误（见上文按错误名的区分）。

```js
async function getPushManager() {
  // Declarative Web Push（18.4+）：无需已安装的 Service Worker 即可订阅。
  if ('pushManager' in window) return window.pushManager;

  if (!('serviceWorker' in navigator)) {
    // 完全没有可用来订阅的东西。回退到应用内消息，并展示安装引导，
    // 而不是直接失败。
    showAddToHomeScreenGuidance();
    return null;
  }
  // 原始 Web Push：订阅挂在 Service Worker 注册上。
  // 直接查询注册——`navigator.serviceWorker.ready` 永不 reject，且会为活跃 worker
  // 无限期等待，那样会卡在这里，根本走不到下面的安装引导。这里要求 `active`：
  // 注册没有活跃 worker 时，subscribe() 会以 "InvalidStateError" 拒绝。
  const swReg = await navigator.serviceWorker.getRegistration();
  if (!swReg || !swReg.active || !('pushManager' in swReg)) {
    showAddToHomeScreenGuidance();
    return null;
  }
  return swReg.pushManager;
}
```

即便 Service Worker API 存在，`navigator.serviceWorker.ready` 在这里也是错误的探测手段。MDN 把
它记录为一个**永远不会 reject**、并会*无限期*等待页面注册拥有活跃 worker 的 Promise。对于一个
暴露了 Service Worker API、却没有注册（或注册只持有 `installing` / `waiting` worker——按
Service Worker 规范，安装失败时它会变成 `redundant`）且没有 `window.pushManager` 的上下文——
普通的 iOS Safari 标签页正是其中之一——await 它会让该函数永远挂住，上文承诺的安装引导也永远
不会出现。`getRegistration()` 无论如何都会 settle（MDN：它 resolve 为一个
`ServiceWorkerRegistration` 或 `undefined`），而 `ServiceWorkerRegistration.active` 在没有
worker 进入 activating / activated 之前为 `null`——所以读 `active` 能给你一个有界答案，而
`ready` 给你的只是等待。如果你确实需要等待激活，请监听 worker 的 `statechange` 区分
`activated` 与 `redundant`，并加上你自己的超时，而不要去 await `ready`。

## 需要绕开的约束

- **提示需要明确的用户手势。** 请求推送订阅必须由用户手势触发，因此在页面加载时弹出的提示
  无处着落。请在用户做了推送能帮上忙的操作之后、在上下文中请求。
- **不允许静默推送。** 两种形态都强制 `userVisibleOnly: true`。在**原始 Web Push** 下你必须用
  JavaScript 兑现这一承诺：只要 `PushEvent` 处理函数因任何原因没有展示用户可见通知，WebKit 就会
  **吊销**该订阅——而 Service Worker 脚本的 bug、网络状况或设备本地状况都可能让
  `showNotification()` 无法及时被调用。在 **Declarative Web Push** 下则没有这项惩罚：Service
  Worker 未能展示通知时，声明式推送消息本身会作为回退被使用。无论哪种形态，推送都不是静默后台
  运行的许可证。
- **endpoint 是一个能力 URL。** MDN 的警告是：只要知道该 endpoint，就足以向你的应用发送消息，
  因此它需要被保密——否则其他应用可能得以向你的应用推送消息。
- **为投递限制留余量。** 唤醒 Service Worker 会消耗电量，各浏览器做法不同：Firefox 对不产生
  通知的推送消息设有配额，Chrome 则没有限制，目前没有标准机制。
- **在真机上测试。** 安装门槛、权限提示与投递都是系统级行为——务必在运行 16.4 及以上版本的
  真实 iPhone 或 iPad 上验证。

## 决策框架

| 决策问题 | 推荐做法 | 理由 |
|---|---|---|
| 能否向浏览器标签页中的 iOS 用户推送？ | 不能——先要有主屏幕 Web 应用。 | 16.4 是为已添加到主屏幕的 Web 应用加入 Web Push 的。 |
| 用什么密钥订阅？ | 标准 VAPID `applicationServerKey`。 | WebKit 实现同一套 W3C Push API；客户端无需 APNs 密钥。 |
| 需要 Apple Developer Program 会员资格吗？ | 不需要。 | WebKit 明确说明发送 Web Push 无需加入该计划。 |
| 何时请求权限？ | 在已安装的 Web 应用内，由直接的用户交互触发。 | 请求推送订阅需要明确的用户手势。 |
| macOS Safari 不安装行吗？ | 自 macOS Ventura 上的 Safari 16.1 起即支持。 | 主屏幕这一步是 iOS/iPadOS 的路径。 |
| 可以不展示通知就推送吗？ | 不可以。 | `userVisibleOnly: true` 为强制项；原始 Web Push 下未展示通知的处理函数会被吊销订阅。 |
| 必须注册 Service Worker 吗？ | 18.4+ 的 Declarative Web Push 不需要。 | 它无需已安装的 Service Worker 即可订阅并展示通知；原始 Web Push 只有 `ServiceWorkerRegistration.pushManager`。 |

## 实践清单

- [ ] 在 iOS/iPadOS 上，把推送提示置于主屏幕 Web 应用之后；否则展示安装引导。
- [ ] 仅从直接的用户交互、在上下文中请求权限。
- [ ] 使用 `userVisibleOnly: true` 与 VAPID `applicationServerKey` 订阅。
- [ ] 将完整的 `PushSubscription`（endpoint + `p256dh` + `auth`）持久化到服务器端，并把 endpoint 当作机密对待。
- [ ] 走原始 Web Push 路径时，始终在 `push` 处理函数中调用 `showNotification()`——漏掉一次通知可能让你丢掉订阅。
- [ ] 想在 18.4+ 上走声明式路径，请用 `window.pushManager` 订阅，并以声明式 JSON 格式发送消息，这样就不需要 Service Worker。
- [ ] 如果你限制了出站推送端点，请放行 `*.push.apple.com`。
- [ ] 使用特性检测，绝不要用浏览器检测——并且要探测你实际会用到的那个 manager（声明式路径用 `window.pushManager`，原始路径用注册对象上的 `pushManager`），而不要把裸的 `PushManager` 接口当作当前上下文可用的证据。
- [ ] 用 `navigator.serviceWorker.getRegistration()` 取得注册并要求 `registration.active`，而不要直接 await `navigator.serviceWorker.ready`：`ready` 永不 reject，且会为活跃 worker 无限期等待，探测会挂住而展示不出安装引导。
- [ ] 在调用 `Notification.requestPermission()` **之前**先检测 `'Notification' in window`——普通 iOS 标签页里该接口是 undefined，读取它抛出的 `ReferenceError` 是包在 `subscribe()` 外的 `try` 永远捕获不到的。
- [ ] 不要把 `subscribe()` 的拒绝当作安装信号。`NotAllowedError` 可能表示推送权限被拒、window 作用域不是 HTTPS，或用户代理要求 `userVisibleOnly: true` 而实际传入了 `false`。在 iOS 上，应根据符合条件的主屏幕 Web 应用之外不存在 `Notification` 接口这一点，进入安装引导分支。
- [ ] 给清单一个非默认的 `display` 值——在 iOS 上没有它，`Notification` 接口会一直是 undefined。
- [ ] 从 Service Worker 展示通知（`registration.showNotification()`）；`new Notification()` 在 iOS 与 iPadOS 上没有受支持的路径。
- [ ] 在真实的 iOS 16.4+ 设备上验证整个流程。

## 相关参考

- [Web Push](/zh/reference/notifications/web-push/) —— 本页所依赖的跨浏览器协议、VAPID 密钥与
  服务端发送路径。
- [iOS 添加到主屏幕](/zh/reference/installation/ios-add-to-home-screen/) —— 在 iPhone 与 iPad 上
  解锁推送的安装步骤。
- [通知权限](/zh/reference/notifications/permissions/) —— 如何请求权限，以及被拒绝意味着什么。
- [Notifications API](/zh/reference/notifications/notifications-api/) —— 显示那一半，以及
  iOS 的 Service Worker 要求所由来的「持久 / 非持久」之分。