Web Push 怎样实现?pushManager.subscribe、VAPID 和 Service Worker 的 push 事件怎样配合?
页面在用户点击后调用 pushManager.subscribe,传入 VAPID 公钥,拿到含 endpoint 和加密密钥的订阅对象交给服务器;服务器用 VAPID 私钥签名后向 endpoint 发消息;浏览器唤醒 Service Worker 触发 push 事件,在 event.waitUntil 里调用 showNotification 显示通知。iOS 16.4 起只对添加到主屏幕的网页应用支持。
简答
用户关掉了外卖网站的页面,订单状态变了,手机上还是弹出一条通知,这就是 Web Push。它分三方:浏览器、你的应用服务器,以及浏览器厂商提供的推送服务。页面在 Service Worker 注册好以后调用
registration.pushManager.subscribe(),拿到一个订阅对象,里面有推送服务的endpoint地址和加密用的密钥,把它发给自己的服务器保存。服务器要发消息时,用订阅里的密钥加密内容,再向
endpoint发请求。VAPID 是服务器向推送服务证明“我是谁”的办法:订阅时页面传入服务器的公钥applicationServerKey,之后服务器每次发消息都带一个用对应私钥签名的 JWT,推送服务只接受这台服务器发来的消息。消息到达后,浏览器会在需要时启动 Service Worker,触发
push事件。页面不用开着,Service Worker 在事件里读出数据,用event.waitUntil()包住registration.showNotification()把通知显示出来。要注意三点。
subscribe()要在用户点击之类的操作里调用,Chrome 和 Edge 还要求userVisibleOnly: true。endpoint本身就是发消息的凭证,要当作机密保存。iOS 和 iPadOS 从 16.4 开始支持,但只对添加到主屏幕的网页应用有效。
三方各做什么
先把参与者分清,后面的代码就好懂了。
浏览器负责向用户申请权限、生成订阅、收到消息后唤醒 Service Worker。推送服务由浏览器厂商运营,订阅里的 endpoint 就是它的一个地址,你的服务器只和它通信,不直接连用户的设备。应用服务器是你自己的后端,保存订阅,需要时发消息。
MDN 对 Push API 的描述是:应用要接收推送消息,必须有一个处于激活状态的 Service Worker;不管页面是否在前台,甚至没有打开,消息都能送达。所以 Web Push 建立在 Service Worker 之上,Service Worker 的注册和更新可以看《Service Worker 的安装、激活与缓存更新有哪些坑?》。
第一步:在页面里订阅
订阅要在用户点击按钮后发起。MDN 的说法是,这不只是好的做法,浏览器也会明确禁止不是由用户操作触发的通知,Firefox 从 72 版起已经这样做了。
subscribeButton.addEventListener('click', async () => {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY,
});
await fetch('/api/push/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
});
userVisibleOnly: true 表示这个订阅只用于“效果对用户可见”的消息,实际做法就是每条消息都显示通知。MDN 注明 Chrome 和 Edge 在没有设成 true 时会拒绝这个 Promise,所以实际上都要写。
applicationServerKey 是服务器的 VAPID 公钥,MDN 的说明是一个 ECDSA P-256 公钥,可以是 Base64 字符串或 ArrayBuffer。设置以后,服务器发来的所有消息都必须带上用对应私钥签名的 JWT。MDN 还特别提醒,这把公钥和加密消息内容用的 ECDH 密钥不是同一个。
为什么可以直接 JSON.stringify(subscription)?因为 PushSubscription 有 toJSON() 方法,JSON.stringify 会自动调用它,得到包含 endpoint、expirationTime 和 keys(其中有 p256dh 和 auth)的普通对象。如果自己用 getKey('p256dh') 去取,拿到的是 ArrayBuffer,直接序列化会变成 {},服务器就收不到密钥。
VAPID 公钥常见的格式问题
服务端工具生成的 VAPID 公钥通常是 base64url 字符串,也就是用 - 和 _ 代替 + 和 /,并去掉末尾的 =。很多示例会把它先转成 Uint8Array 再传给 applicationServerKey。下面这个转换函数可以单独运行,看看转换结果:
function toStandardBase64(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
return (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
}
function urlBase64ToUint8Array(base64String) {
const raw = atob(toStandardBase64(base64String));
return Uint8Array.from(raw, (char) => char.charCodeAt(0));
}
const bytes = urlBase64ToUint8Array('BAEC_-8');
console.log(toStandardBase64('BAEC_-8')); // BAEC/+8=
console.log(bytes.length); // 5
console.log(bytes.join(',')); // 4,1,2,255,239
'BAEC_-8' 只是演示用的短字符串。转换分两步:先补齐 = 让长度是 4 的倍数,再把 -、_ 换回标准 Base64 的 +、/,然后用 atob 解码成字节。真实的公钥要长得多,RFC 8292 规定它是 P-256 公钥的未压缩形式。
第二步:服务器用 VAPID 签名后发消息
RFC 8292 定义了 VAPID。服务器持有一对 ECDSA P-256 密钥,算法标识是 ES256。每次向 endpoint 发消息,服务器生成一个 JWT,其中 aud 必须是 endpoint 的源,exp 不能超过请求时间之后 24 小时,sub 建议写一个 mailto: 或 https: 的联系地址。JWT 放在请求头里:
Authorization: vapid t=<JWT>, k=<base64url 编码的公钥>
RFC 8292 说明了这样做的两个目的。一是服务器可以主动向推送服务表明身份。二是订阅一旦绑定了公钥,推送服务就必须拒绝没有有效 VAPID 签名的消息,别人拿到 endpoint 也不能冒充你发消息。
消息内容本身还要用订阅里的 p256dh 和 auth 加密。实际开发中,签名和加密一般交给服务端的 Web Push 库完成,前端要做的是把完整的订阅对象交给服务器。
第三步:Service Worker 处理 push 事件
消息到达后,浏览器会在需要时启动 Service Worker,并派发 push 事件。MDN 说明这个事件只在 Service Worker 里可用,event.data 是 PushMessageData,可以用 json() 读出服务器发的数据。
// sw.js
self.addEventListener('push', (event) => {
const data = event.data ? event.data.json() : {};
event.waitUntil(
self.registration.showNotification(data.title ?? '新消息', {
body: data.body,
data: { url: data.url },
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(clients.openWindow(event.notification.data.url));
});
为什么要用 event.waitUntil() 包住 showNotification()?因为 Service Worker 处理完事件就可能被浏览器停掉。MDN 对 waitUntil() 的解释是,它告诉浏览器还有工作在进行,在传入的 Promise 完成前不要终止 Service Worker。不包的话,通知可能还没显示出来,Service Worker 就被回收了。
notificationclick 处理用户点击通知,这里用 clients.openWindow() 打开消息对应的页面。MDN 说明 openWindow() 需要本源的某个窗口有临时用户激活,Firefox 只允许在通知点击事件里打开窗口,所以打开页面的逻辑放在 notificationclick 里。
订阅失效和安全
订阅不是永久有效的。PushSubscription.expirationTime 是订阅的过期时间,没有就为 null。推送服务让订阅失效或即将失效时,Service Worker 会收到 pushsubscriptionchange 事件,这时要重新订阅,并把新订阅发给服务器。用户想关闭通知时,调用 subscription.unsubscribe(),服务器也要删掉对应的记录。
MDN 在安全方面提醒了两点。第一,endpoint 是一个能力 URL,知道它就能向你的应用发消息,所以要保密。第二,实现订阅接口时要防范 CSRF,否则别人可以诱导用户提交伪造的订阅。
各平台支持到什么程度
MDN 把 Push API 标为 Baseline Widely available,从 2023 年 3 月起在主流浏览器都可用。
iOS 和 iPadOS 的情况要单独说。WebKit 官方博客介绍,iOS 和 iPadOS 16.4 加入了 Web Push,但只面向添加到主屏幕的网页应用;网页要提供 manifest,并把 display 设成 standalone 或 fullscreen;申请权限必须响应用户的直接操作;用的是 Push API、Notifications API 和 Service Worker 这套 W3C 标准,不需要加入 Apple 开发者计划。
所以在 iPhone 的 Safari 标签页里,subscribe() 是用不了的,要先引导用户添加到主屏幕。功能检测可以像《PWA 由哪些能力组成,是否所有浏览器体验都一致?》里那样,先判断 'serviceWorker' in navigator 和 'PushManager' in window,检测到了再显示订阅按钮。
常见追问
为什么不在页面加载时直接请求通知权限?
用户还不知道你要推什么就被弹窗打断,体验不好,浏览器也在限制这种行为。MDN 提到 Firefox 72 起不允许不是由用户操作触发的通知,iOS 也要求响应用户的直接操作。更好的做法是在用户关心的地方放一个按钮,比如“订单状态变化时通知我”,点击后再调用
subscribe()。
endpoint 泄露了会怎样?VAPID 能防住吗?
endpoint是推送服务给这个订阅的地址,知道它就能尝试发消息,所以 MDN 要求保密。订阅时如果传了applicationServerKey,RFC 8292 要求推送服务拒绝没有有效 VAPID 签名的消息,别人没有你的私钥就发不进来。所以 VAPID 私钥只能放在服务器上,不能出现在前端代码里。
push 事件里可以不显示通知、只在后台同步数据吗?
在 Chrome 和 Edge 里不行,它们要求订阅时设
userVisibleOnly: true,也就是承诺每条消息都会让用户看见。所以push事件里应该总是调用showNotification(),后台更新数据可以顺带做,但不能代替通知。