Service Worker推送通知实战:5个模式构建Web Push与离线告警系统

前端工程

Service Worker推送:Web应用的实时通知能力

移动端APP有推送通知,Web应用只能靠轮询?Service Worker + Web Push API让Web应用也能接收服务器推送的实时通知,即使用户关闭了页面。2026年,Push API已全面支持Chrome/Firefox/Edge/Safari,Background Sync API实现离线消息队列,Web应用的推送能力已不输原生APP。

本文将从5种核心模式出发,带你完成订阅管理→推送发送→离线通知→后台同步→通知交互的全链路实战。


核心概念

概念 说明
Service Worker 浏览器后台运行的脚本,拦截网络请求
Push API 服务器向客户端推送消息的API
Notification API 浏览器原生通知展示API
VAPID 推送认证协议,自托管推送密钥
Subscription 客户端推送订阅对象,含endpoint和密钥
Background Sync 网络恢复后自动重试的API
Push Event Service Worker接收的推送事件
Notification Event 用户点击/关闭通知的事件

问题分析:Service Worker推送的5大挑战

  1. 浏览器兼容性:Safari推送API与Chrome差异大
  2. 订阅管理:用户换设备/重装后订阅失效
  3. 推送加密:Payload加密复杂,密钥管理繁琐
  4. 离线场景:用户离线时推送消息的处理
  5. 通知疲劳:推送频率过高导致用户关闭权限

分步实操:5种Service Worker推送模式

模式1:VAPID密钥与订阅管理

// 生成VAPID密钥
// npm install web-push
import webpush from 'web-push';
import { generateKeyPair } from 'crypto';

const vapidKeys = webpush.generateVAPIDKeys();
console.log('Public Key:', vapidKeys.publicKey);
console.log('Private Key:', vapidKeys.privateKey);

webpush.setVapidDetails(
  'mailto:admin@example.com',
  vapidKeys.publicKey,
  vapidKeys.privateKey
);
// 前端订阅管理
class PushSubscriptionManager {
  private registration: ServiceWorkerRegistration | null = null;

  async init() {
    if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
      throw new Error('Push API not supported');
    }
    this.registration = await navigator.serviceWorker.register('/sw.js');
    await navigator.serviceWorker.ready;
  }

  async subscribe(): Promise<PushSubscription | null> {
    const permission = await Notification.requestPermission();
    if (permission !== 'granted') return null;

    const subscription = await this.registration!.pushManager.subscribe({
      userVisibleOnly: true,
      applicationServerKey: this.urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
    });

    await this.sendSubscriptionToServer(subscription);
    return subscription;
  }

  async unsubscribe(): Promise<boolean> {
    const subscription = await this.registration!.pushManager.getSubscription();
    if (subscription) {
      await subscription.unsubscribe();
      await this.removeSubscriptionFromServer(subscription);
    }
    return true;
  }

  async getSubscription(): Promise<PushSubscription | null> {
    return this.registration!.pushManager.getSubscription();
  }

  private async sendSubscriptionToServer(subscription: PushSubscription) {
    await fetch('/api/push/subscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(subscription.toJSON()),
    });
  }

  private async removeSubscriptionFromServer(subscription: PushSubscription) {
    await fetch('/api/push/unsubscribe', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ endpoint: subscription.endpoint }),
    });
  }

  private urlBase64ToUint8Array(base64String: string): Uint8Array {
    const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
    const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
    const rawData = atob(base64);
    return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
  }
}

模式2:服务器端推送发送

// server/push-sender.ts
import webpush from 'web-push';

interface PushMessage {
  title: string;
  body: string;
  icon?: string;
  image?: string;
  badge?: string;
  tag?: string;
  data?: Record<string, unknown>;
  actions?: Array<{ action: string; title: string; icon?: string }>;
  requireInteraction?: boolean;
  silent?: boolean;
}

interface SubscriptionRecord {
  endpoint: string;
  keys: { p256dh: string; auth: string };
  userId: string;
}

class PushSender {
  async sendToOne(subscription: SubscriptionRecord, message: PushMessage) {
    const payload = JSON.stringify(message);
    try {
      await webpush.sendNotification(subscription, payload, {
        TTL: 86400,
        urgency: 'normal',
        topic: message.tag,
      });
    } catch (error: any) {
      if (error.statusCode === 410) {
        await this.removeInvalidSubscription(subscription.endpoint);
      }
      throw error;
    }
  }

  async sendToMany(subscriptions: SubscriptionRecord[], message: PushMessage) {
    const payload = JSON.stringify(message);
    const results = await Promise.allSettled(
      subscriptions.map((sub) =>
        webpush.sendNotification(sub, payload, {
          TTL: 86400,
          urgency: 'normal',
        })
      )
    );

    const failed = results
      .map((r, i) => ({ result: r, subscription: subscriptions[i] }))
      .filter(({ result }) => result.status === 'rejected');

    for (const { result, subscription } of failed) {
      const err = (result as PromiseRejectedResult).reason;
      if (err.statusCode === 410) {
        await this.removeInvalidSubscription(subscription.endpoint);
      }
    }

    return {
      sent: results.filter((r) => r.status === 'fulfilled').length,
      failed: failed.length,
    };
  }

  async sendWithSchedule(
    subscriptions: SubscriptionRecord[],
    message: PushMessage,
    scheduledAt: Date
  ) {
    const delay = scheduledAt.getTime() - Date.now();
    if (delay <= 0) {
      return this.sendToMany(subscriptions, message);
    }
    setTimeout(() => this.sendToMany(subscriptions, message), delay);
  }

  private async removeInvalidSubscription(endpoint: string) {
    await fetch(`/api/push/cleanup`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ endpoint }),
    });
  }
}

模式3:Service Worker推送事件处理

// public/sw.js
self.addEventListener('install', (event) => {
  self.skipWaiting();
});

self.addEventListener('activate', (event) => {
  event.waitUntil(self.clients.claim());
});

self.addEventListener('push', (event) => {
  if (!event.data) {
    event.waitUntil(
      self.registration.showNotification('新消息', {
        body: '您收到了一条新消息',
        icon: '/icons/notification-192.png',
        badge: '/icons/badge-72.png',
      })
    );
    return;
  }

  const data = event.data.json();

  const options: NotificationOptions = {
    body: data.body,
    icon: data.icon || '/icons/notification-192.png',
    badge: data.badge || '/icons/badge-72.png',
    image: data.image,
    tag: data.tag || `notification-${Date.now()}`,
    data: {
      url: data.url || '/',
      ...data.data,
    },
    actions: data.actions || [],
    requireInteraction: data.requireInteraction || false,
    silent: data.silent || false,
    vibrate: data.silent ? undefined : [200, 100, 200],
    timestamp: Date.now(),
  };

  event.waitUntil(
    self.registration.getNotifications().then((existing) => {
      if (data.tag) {
        existing
          .filter((n) => n.tag === data.tag)
          .forEach((n) => n.close());
      }
      return self.registration.showNotification(data.title, options);
    })
  );
});

self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  const urlToOpen = event.notification.data?.url || '/';

  if (event.action) {
    event.waitUntil(
      self.clients.openWindow(event.notification.data?.[event.action] || urlToOpen)
    );
    return;
  }

  event.waitUntil(
    self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
      const existingClient = clients.find((client) => client.url.includes(self.location.origin));
      if (existingClient) {
        return existingClient.focus();
      }
      return self.clients.openWindow(urlToOpen);
    })
  );
});

self.addEventListener('notificationclose', (event) => {
  const tag = event.notification.tag;
  event.waitUntil(
    fetch('/api/push/notification-closed', {
      method: 'POST',
      body: JSON.stringify({ tag, timestamp: Date.now() }),
      headers: { 'Content-Type': 'application/json' },
    })
  );
});

模式4:Background Sync离线消息队列

// 前端注册同步
class OfflineMessageQueue {
  private db: IDBDatabase | null = null;

  async init() {
    this.db = await this.openDB();
  }

  async enqueue(message: { type: string; payload: unknown }) {
    const tx = this.db!.transaction('outbox', 'readwrite');
    const store = tx.objectStore('outbox');
    await store.add({
      ...message,
      timestamp: Date.now(),
      synced: false,
    });

    const registration = await navigator.serviceWorker.ready;
    await registration.sync.register('sync-messages');
  }

  private openDB(): Promise<IDBDatabase> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open('push-offline-db', 1);
      request.onupgradeneeded = () => {
        const db = request.result;
        if (!db.objectStoreNames.contains('outbox')) {
          db.createObjectStore('outbox', { keyPath: 'id', autoIncrement: true });
        }
      };
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
  }
}
// sw.js - Background Sync处理
self.addEventListener('sync', (event) => {
  if (event.tag === 'sync-messages') {
    event.waitUntil(this.syncOutboxMessages());
  }
});

async function syncOutboxMessages() {
  const db = await openDB();
  const tx = db.transaction('outbox', 'readwrite');
  const store = tx.objectStore('outbox');
  const messages = await store.getAll();

  const unsynced = messages.filter((m) => !m.synced);

  for (const msg of unsynced) {
    try {
      await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(msg),
      });
      msg.synced = true;
      store.put(msg);
    } catch {
      break;
    }
  }

  if (unsynced.some((m) => !m.synced)) {
    throw new Error('Some messages failed to sync');
  }
}

模式5:推送频率控制与用户偏好

// 推送频率控制器
class PushFrequencyController {
  private limits: Map<string, { count: number; resetAt: number }> = new Map();

  async canSend(userId: string, category: string): Promise<boolean> {
    const key = `${userId}:${category}`;
    const limit = this.limits.get(key);

    if (!limit || Date.now() > limit.resetAt) {
      this.limits.set(key, { count: 1, resetAt: Date.now() + 3600000 });
      return true;
    }

    const maxPerHour = await this.getUserPreference(userId, category);
    if (limit.count >= maxPerHour) {
      return false;
    }

    limit.count++;
    return true;
  }

  private async getUserPreference(userId: string, category: string): Promise<number> {
    const pref = await fetch(`/api/push/preferences/${userId}`).then((r) => r.json());
    return pref[category]?.maxPerHour ?? 5;
  }
}

// 用户偏好管理API
interface PushPreferences {
  enabled: boolean;
  categories: {
    [key: string]: {
      enabled: boolean;
      maxPerHour: number;
      quietHoursStart?: string;
      quietHoursEnd?: string;
    };
  };
}

class PushPreferenceManager {
  async getPreferences(userId: string): Promise<PushPreferences> {
    return fetch(`/api/push/preferences/${userId}`).then((r) => r.json());
  }

  async updatePreferences(userId: string, prefs: Partial<PushPreferences>) {
    await fetch(`/api/push/preferences/${userId}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(prefs),
    });
  }

  isInQuietHours(prefs: PushPreferences, category: string): boolean {
    const cat = prefs.categories[category];
    if (!cat?.quietHoursStart || !cat?.quietHoursEnd) return false;
    const now = new Date();
    const hours = now.getHours();
    const start = parseInt(cat.quietHoursStart);
    const end = parseInt(cat.quietHoursEnd);
    if (start <= end) return hours >= start && hours < end;
    return hours >= start || hours < end;
  }
}

避坑指南

坑1:VAPID密钥格式错误

// ❌ 错误:直接使用Base64字符串
applicationServerKey: 'BPxxx...'

// ✅ 正确:转换为Uint8Array
applicationServerKey: urlBase64ToUint8Array('BPxxx...')

坑2:未处理订阅失效

// ❌ 错误:忽略410 Gone错误
await webpush.sendNotification(sub, payload);

// ✅ 正确:清理失效订阅
try {
  await webpush.sendNotification(sub, payload);
} catch (err) {
  if (err.statusCode === 410) {
    await db.removeSubscription(sub.endpoint);
  }
}

坑3:推送无Payload

// ❌ 错误:服务器不发送Payload,SW无法获取数据
// 只能显示通用通知

// ✅ 正确:发送加密Payload
const payload = JSON.stringify({ title: '新消息', body: '内容', url: '/inbox' });
await webpush.sendNotification(subscription, payload);

坑4:通知点击未聚焦窗口

// ❌ 错误:总是打开新窗口
self.clients.openWindow(url);

// ✅ 正确:优先聚焦已有窗口
const clients = await self.clients.matchAll({ type: 'window' });
const client = clients.find(c => c.url.includes(origin));
if (client) client.focus();
else self.clients.openWindow(url);

坑5:iOS Safari兼容性

// ❌ 错误:假设所有浏览器行为一致
// Safari不支持actions、image等通知选项

// ✅ 正确:检测能力后降级
const supportsActions = 'actions' in Notification.prototype;
const options: NotificationOptions = {
  body: data.body,
  ...(supportsActions ? { actions: data.actions } : {}),
};

报错排查

序号 报错信息 原因 解决方法
1 Registration failed - permission denied 用户拒绝通知权限 引导用户在设置中开启
2 Push subscription expired 订阅过期 重新订阅并更新服务器记录
3 410 Gone 推送服务已取消订阅 从数据库移除该订阅
4 413 Payload too large 推送数据超过4KB 减少Payload大小
5 VAPID authentication failed VAPID密钥不匹配 检查公私钥对是否对应
6 Service Worker not registered SW注册失败 检查SW文件路径和HTTPS
7 Push event not received SW未激活 确保SW已activate并claim clients
8 Notification not shown 通知被系统阻止 检查系统级通知权限
9 Background Sync not triggered 浏览器不支持 降级为定时重试
10 Safari push error Safari推送API差异 使用Safari专有推送API

进阶优化

  1. 推送分组:相同tag通知自动替换,避免通知堆叠
  2. 静默推送silent: true仅唤醒SW更新数据,不显示通知
  3. 推送分析:追踪推送送达率、打开率、转化率
  4. A/B测试:不同通知文案和时机的效果对比
  5. Web Push网关:自建推送网关统一管理多浏览器推送服务

对比分析

维度 Web Push SSE WebSocket 轮询
离线推送
页面关闭推送
实时性 秒级 毫秒级 毫秒级 依赖间隔
服务器资源
浏览器支持 主流 主流 主流 全部
双向通信

总结:Service Worker推送通知让Web应用具备了与原生APP同等的实时通知能力。VAPID认证+Push API+Background Sync三位一体,实现了从订阅管理到离线消息队列的完整推送链路。2026年Safari的Push API支持让Web推送终于实现跨浏览器覆盖,推送频率控制和用户偏好管理是生产环境的关键。


在线工具推荐

本站提供浏览器本地工具,免注册即可试用 →

#Service Worker推送#Web Push#离线通知#PWA推送#2026#前端工程