如何在 Service Worker 中实现推送通知功能?
核心回答
Service Worker 推送通知的实现依赖三个 API 协作:Notification API(请求权限+显示通知)、Push API(订阅推送服务)、Service Worker(后台监听 push 事件)。完整流程:请求通知权限 → 订阅推送服务(生成 endpoint)→ 将订阅发送给服务器 → 服务器调用推送服务 → Service Worker 的 push 事件触发 → 调用 showNotification 显示通知。
关键前提:页面必须在 HTTPS 环境下运行,且 Service Worker 已注册并激活。
推送通知工作原理
shell┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ 应用服务器 │────▶│ 浏览器推送服务 │────▶│ 用户浏览器 │ │ (你的后端) │ │ (FCM/Mozilla等) │ │ (Service Worker) │ └──────────────┘ └──────────────────┘ └──────────────────┘ │ │ │ 订阅时:浏览器向推送服务注册 │ │ 返回 subscription(endpoint+keys) │ │ │ └──── 服务器用 subscription.endpoint 发消息 ────────┘
应用服务器并不直接与浏览器通信,而是通过浏览器厂商提供的推送服务(Chrome 用 FCM,Firefox 用 Mozilla Push Service)中转。订阅时浏览器会生成一个唯一 endpoint URL,服务器向这个 URL 发送加密消息即可。
实现步骤
1. 请求通知权限
javascript// main.js async function requestNotificationPermission() { // 三种状态:default / granted / denied if (Notification.permission === 'granted') { return true; } if (Notification.permission === 'denied') { console.log('用户已拒绝通知权限,无法再次请求'); return false; } const permission = await Notification.requestPermission(); return permission === 'granted'; }
注意:denied 状态下再次调用 requestPermission() 不会弹出授权弹窗,只能引导用户去浏览器设置中手动开启。
2. 订阅推送服务
javascript// main.js async function subscribeUserToPush() { const registration = await navigator.serviceWorker.ready; // 先检查是否已有订阅 let subscription = await registration.pushManager.getSubscription(); if (!subscription) { // 获取 VAPID 公钥(服务器生成,用于标识你的应用) const vapidPublicKey = await fetch('/api/vapid-public-key').then(r => r.text()); const convertedKey = urlBase64ToUint8Array(vapidPublicKey); subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, // 必须为 true:推送必须显示通知 applicationServerKey: convertedKey // VAPID 公钥 }); } // 将订阅信息发给服务器保存 await fetch('/api/save-subscription', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription) }); return subscription; } function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); return Uint8Array.from([...rawData].map(c => c.charCodeAt(0))); }
userVisibleOnly: true 是硬性要求——浏览器不允许静默推送,每条推送都必须展示通知。VAPID(Voluntary Application Server Identification)让推送服务知道是哪个应用在发送消息,无需每条消息单独验证。
3. Service Worker 处理推送事件
javascript// sw.js self.addEventListener('push', event => { let data = {}; if (event.data) { data = event.data.json(); } const options = { body: data.body || '您有一条新消息', icon: '/icons/icon-192x192.png', badge: '/icons/badge-72x72.png', tag: data.tag || 'default', // 相同 tag 的通知会替换 requireInteraction: false, // true 则通知不会自动消失 actions: [ { action: 'open', title: '查看' }, { action: 'dismiss', title: '忽略' } ], data: { url: data.url || '/', timestamp: Date.now() } }; event.waitUntil( self.registration.showNotification(data.title || '新通知', options) ); });
event.waitUntil() 确保 Service Worker 在通知显示完成前不会被终止。tag 属性用于通知分组——相同 tag 的新通知会替换旧的,避免通知栏堆叠。
4. 处理通知点击
javascript// sw.js self.addEventListener('notificationclick', event => { event.notification.close(); if (event.action === 'dismiss') return; const urlToOpen = event.notification.data?.url || '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }) .then(clientList => { // 优先聚焦已有窗口 for (const client of clientList) { if (client.url.includes(new URL(urlToOpen).pathname) && 'focus' in client) { return client.focus(); } } // 没有则打开新窗口 return clients.openWindow(urlToOpen); }) ); });
点击通知后应优先聚焦已有标签页而非重复打开,这是常见的用户体验考量点。
服务器端发送推送
javascript// server.js const webpush = require('web-push'); // 生成 VAPID 密钥:npx web-push generate-vapid-keys const vapidKeys = { publicKey: 'YOUR_PUBLIC_KEY', privateKey: 'YOUR_PRIVATE_KEY' }; webpush.setVapidDetails( 'mailto:your-email@example.com', vapidKeys.publicKey, vapidKeys.privateKey ); async function sendPush(subscription, payload) { try { await webpush.sendNotification(subscription, JSON.stringify(payload)); } catch (err) { if (err.statusCode === 410) { // 订阅已过期,从数据库删除 await removeSubscription(subscription.endpoint); } throw err; } }
410 状态码表示订阅已失效(用户取消授权或卸载浏览器),必须清理,否则后续推送全部失败。
常见面试追问
Q: 推送消息有大小限制吗? 推送消息载荷上限约 4KB(不同推送服务略有差异)。大数据应先推送通知,用户点击后从服务器拉取完整内容。
Q: 用户关闭浏览器后还能收到推送吗? 可以。推送服务是浏览器厂商运行的云端服务,消息先到达推送服务,等浏览器上线后投递。但浏览器完全退出(非后台驻留)时,桌面端可能收不到,移动端依赖系统级推送通道。
Q: VAPID 和旧版 GCM 密钥有什么区别? VAPID 是标准化的应用身份验证方案,不依赖特定厂商(如 Google),Firefox 和 Chrome 都支持。GCM 密钥是 Chrome 早期的私有方案,已废弃。
Q: 如何处理推送失败? 订阅可能随时失效(用户撤销权限、浏览器更新、订阅过期),服务端必须捕获 410/404 响应并清理无效订阅,否则会持续推送失败拖慢系统。
Q: periodicSync 和 push 的区别?
periodicsync 是浏览器定时触发的后台同步,用于定期拉取数据(如每日新闻),目前仅 Chrome 支持且有严格的频率限制。push 是服务器主动推送,实时性更强,兼容性更好。
浏览器兼容性
| 功能 | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Push API | 支持 | 支持 | 16.4+ | 支持 |
| Notification | 支持 | 支持 | 支持 | 支持 |
| actions | 支持 | 支持 | 不支持 | 支持 |
| badge | 支持 | 不支持 | 不支持 | 支持 |
| periodicSync | 支持 | 不支持 | 不支持 | 不支持 |
Safari 对 Push API 的支持从 16.4 开始,但 actions 和 badge 等增强特性暂不支持。生产环境中建议做特性检测后再使用对应能力。