跳转到内容

添加推送通知

发布于

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

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

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

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

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

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

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

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()),
});
}

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

Section titled “4. 保存订阅,并把它当作会过期的东西”

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

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()),
}),
),
);
});

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

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);
}),
);
});

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

← 返回指南总览。