消息推送技术实战从WebSocket到Service Worker的完整实现方案

❤永景 前端 阅读 1,444
赞 15 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

消息推送这玩意儿,看起来就一个 Notification.requestPermission() + new Notification(),但真上生产环境跑三个月,你就会发现:权限没拿到、弹窗被拦截、点击没响应、重复推送、iOS 完全不工作……我去年在做一个后台运维告警面板时,被它按在地上摩擦了整整两周。

消息推送技术实战从WebSocket到Service Worker的完整实现方案

最后落地的方案,不是最炫的,也不是最标准的,但够稳、够快、能灰度、能降级。核心就三点:权限预判 + 推送兜底 + 点击归因闭环。下面直接贴我当前项目里正在跑的代码(已脱敏):

// 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 当广告进程杀掉,我们还没完全解决 —— 但至少不影响主流程。

以上是我个人对这个消息推送的完整讲解,有更优的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论