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大挑戰
- 瀏覽器相容性:Safari推送API與Chrome差異大
- 訂閱管理:使用者換裝置/重灌後訂閱失效
- 推送加密:Payload加密複雜,金鑰管理繁瑣
- 離線場景:使用者離線時推送訊息的處理
- 通知疲勞:推送頻率過高導致使用者關閉許可權
分步實操: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 |
進階最佳化
- 推送分組:相同tag通知自動替換,避免通知堆疊
- 靜默推送:
silent: true僅喚醒SW更新資料,不顯示通知 - 推送分析:追蹤推送送達率、開啟率、轉化率
- A/B測試:不同通知文案和時機的效果對比
- Web Push閘道:自建推送閘道統一管理多瀏覽器推送服務
對比分析
| 維度 | Web Push | SSE | WebSocket | 輪詢 |
|---|---|---|---|---|
| 離線推送 | ✅ | ❌ | ❌ | ❌ |
| 頁面關閉推送 | ✅ | ❌ | ❌ | ❌ |
| 即時性 | 秒級 | 毫秒級 | 毫秒級 | 依賴間隔 |
| 伺服器資源 | 低 | 中 | 高 | 高 |
| 瀏覽器支援 | 主流 | 主流 | 主流 | 全部 |
| 雙向通訊 | ❌ | ❌ | ✅ | ✅ |
總結:Service Worker推送通知讓Web應用具備了與原生APP同等的即時通知能力。VAPID認證+Push API+Background Sync三位一體,實現了從訂閱管理到離線訊息佇列的完整推送鏈路。2026年Safari的Push API支援讓Web推送終於實現跨瀏覽器覆蓋,推送頻率控制和使用者偏好管理是生產環境的關鍵。
線上工具推薦
- JSON格式化:/zh-TW/json/format
- Hash計算:/zh-TW/encode/hash
- cURL轉程式碼:/zh-TW/dev/curl-to-code
本站提供瀏覽器本地工具,免註冊即可試用 →
#Service Worker推送#Web Push#离线通知#PWA推送#2026#前端工程