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#前端工程