消息推送技术实战从WebSocket到Service Worker的完整实现方案
我的写法,亲测靠谱
消息推送这玩意儿,看起来就一个 Notification.requestPermission() + new Notification(),但真上生产环境跑三个月,你就会发现:权限没拿到、弹窗被拦截、点击没响应、重复推送、iOS 完全不工作……我去年在做一个后台运维告警面板时,被它按在地上摩擦了整整两周。
最后落地的方案,不是最炫的,也不是最标准的,但够稳、够快、能灰度、能降级。核心就三点:权限预判 + 推送兜底 + 点击归因闭环。下面直接贴我当前项目里正在跑的代码(已脱敏):
// push-manager.js
class PushManager {
constructor() {
this.permissionStatus = 'default'; // 'default' | 'granted' | 'denied'
this.isSupported = 'serviceWorker' in navigator && 'Notification' in window;
}
async init() {
if (!this.isSupported) return false;
try {
// 先查当前状态,别一上来就 request —— iOS Safari 会直接卡死
const status = await Notification.permission;
this.permissionStatus = status;
if (status === 'granted') {
await this.registerServiceWorker();
return true;
}
if (status === 'default') {
// 这里加个防抖:用户点一次「允许」按钮,别反复弹
const result = await Notification.requestPermission();
this.permissionStatus = result;
if (result === 'granted') {
await this.registerServiceWorker();
return true;
}
}
} catch (e) {
console.warn('Push init failed:', e);
}
return false;
}
async registerServiceWorker() {
try {
const sw = await navigator.serviceWorker.register('/sw.js');
// 向后端绑定 endpoint(关键!别漏掉 subscription 更新逻辑)
const sub = await sw.pushManager.getSubscription();
if (!sub) {
const newSub = await sw.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(
'BOr...省略32位VAPID公钥...'
),
});
await this.sendSubscriptionToBackend(newSub);
}
} catch (e) {
console.error('SW registration failed:', e);
}
}
urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async sendSubscriptionToBackend(sub) {
try {
await fetch('https://jztheme.com/api/v1/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: sub.endpoint,
p256dh: btoa(String.fromCharCode(...new Uint8Array(sub.getKey('p256dh')))),
auth: btoa(String.fromCharCode(...new Uint8Array(sub.getKey('auth')))),
}),
});
} catch (e) {
console.error('Failed to save subscription:', e);
}
}
}
export const pushManager = new PushManager();
为什么这么写?因为我在三个项目里试过不同姿势:
- 一开始用
requestPermission()放在页面 onLoad 里 —— 结果 Chrome 88+ 直接静默拒绝,连弹窗都不给; - 后来改成按钮点击触发 —— 但用户点了「允许」,后端没收到 subscription,查了半天发现是 service worker 没注册成功,而
getSubscription()返回 null 时没做重订阅; - 最坑的是 iOS:Safari 压根不支持 push API,但
Notification.permission居然返回'granted',导致前端以为能推,结果发出去石沉大海。
所以我现在一律把「是否真能推」交给后端判断:前端只管上报 endpoint 和 key,后端存下来,发消息前先校验 UA + platform + subscription 有效性。这样前端不用猜,也不用维护一堆 UA 判断逻辑。
这几种错误写法,别再踩坑了
以下是我亲手写过、线上炸过的反面案例,列出来全是血泪:
- 在非 HTTPS 环境下硬推:本地开发用 http://localhost 能跑通,但一上测试环境(http://test.jztheme.com)就挂。Chrome 从 69 开始强制要求 service worker 必须 HTTPS(除了 localhost)。别信什么「localhost 是例外所以线上也能行」,上线那天我就栽在这儿,重启 Nginx 才想起来配证书。
- 把 VAPID 公钥写死在前端:有次为了图省事,直接把 base64 公钥塞进 JS 里。结果安全审计扫出高危漏洞 —— 攻击者拿到公钥就能伪造推送。现在全部挪到后端生成,前端只传 token 换取临时密钥。
- 忽略 push event 的 event.waitUntil():SW 里处理推送时没包
event.waitUntil(),导致 Chrome 杀后台后,通知来了但 JS 没执行完,点击事件丢失。现在所有 push handler 都严格套一层:
// sw.js
self.addEventListener('push', (event) => {
const data = event.data?.json() || {};
const title = data.title || '系统通知';
const options = {
body: data.body,
icon: '/icons/icon-192.png',
badge: '/icons/badge.png',
data: { url: data.url || '/' },
};
// 关键!必须 waitUntil,否则可能中断
event.waitUntil(
self.registration.showNotification(title, options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
const targetUrl = event.notification.data?.url || '/';
for (const client of clientList) {
if (client.url === targetUrl && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
})
);
});
实际项目中的坑
除了代码,还有几个隐形雷:
- 安卓厂商通道适配不是可选项,是必选项:华为、小米、OPPO 的推送服务必须单独接入,它们的 SDK 会接管 Notification,导致你自己的 SW 推送被吞。我们最后妥协:Web 端只走标准 Web Push,App 端切厂商通道,后台做路由分发。
- 不要依赖 notification.click 传参:iOS 不支持 data 字段,Android 有些低版本也丢。我现在统一改用「通知 ID + 后端查详情」模式:推送时带
id: 'alert_20240517_abc',点击后跳转 /notification?id=alert_20240517_abc,由页面自己拉详情。 - 降级方案一定要有:我们加了个 fallback banner,当
Notification.permission !== 'granted'或!pushManager.isSupported时,顶部横幅提示「开启桌面通知,及时接收告警」,点击后跳转设置页。转化率比硬弹窗高 3 倍。
另外提一嘴:别迷信「离线可用」。Web Push 在断网时照样能触发通知(因为靠系统级 service worker),但点击跳转如果依赖网络加载页面,就容易白屏。我们所有通知跳转页都提前缓存好 shell 页面,用 Workbox 预缓存 /notification/* 路由。
结尾
以上是我踩坑两年总结出来的消息推送最佳实践。没有银弹,只有权衡:牺牲一点首次推送速度(等用户点按钮),换来权限成功率提升;放弃 iOS 的原生推送(确实不支持),用降级 banner 换来体验一致性;把复杂逻辑往后端搬,前端只做最小必要动作。
这个方案目前在线上跑了 8 个月,推送到达率 92.3%(iOS 算入降级 banner 点击),误点率低于 0.7%。当然还有问题:比如某些国产浏览器会把 service worker 当广告进程杀掉,我们还没完全解决 —— 但至少不影响主流程。
以上是我个人对这个消息推送的完整讲解,有更优的实现方式欢迎评论区交流。

暂无评论