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-CN/json/format
- Hash计算:/zh-CN/encode/hash
- cURL转代码:/zh-CN/dev/curl-to-code
本站提供浏览器本地工具,免注册即可试用 →
#Service Worker推送#Web Push#离线通知#PWA推送#2026#前端工程