Notification 与 Push
约 768 字大约 3 分钟
PWANotificationPush
2026-04-08
Notification API
用于向用户展示系统级通知,即使标签页被最小化或关闭(配合 SW)也能触达。
请求权限
if ('Notification' in window) {
const permission = await Notification.requestPermission();
// 'granted' | 'denied' | 'default'
console.log('通知权限:', permission);
}⚠️ Chrome 从 2020 年起要求
requestPermission必须由用户手势触发,不能页面加载时自动弹出,否则会被静默拒绝。
在主线程直接弹通知
if (Notification.permission === 'granted') {
const n = new Notification('新消息', {
body: '来自 ZhenYu 的一条测试通知',
icon: '/icons/icon-192.png',
tag: 'msg-1' // 相同 tag 会合并/替换
});
n.onclick = () => window.focus();
}在 Service Worker 中弹通知(推荐)
只有 SW 发出的通知支持 actions、badge、以及在页面关闭时弹出。
// SW 内
self.registration.showNotification('标题', {
body: '正文内容',
icon: '/icons/icon-192.png',
badge: '/icons/badge.png', // 状态栏小图标(Android)
image: '/banner.jpg', // 大图
tag: 'chat-42',
renotify: true, // 相同 tag 也再次震动/响铃
requireInteraction: true, // 必须用户手动关闭
silent: false,
vibrate: [200, 100, 200],
data: { url: '/chat/42' },
actions: [
{ action: 'reply', title: '回复', icon: '/icons/reply.png' },
{ action: 'close', title: '关闭' }
]
});处理通知点击
// SW 内
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'reply') {
// 处理 action
return;
}
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window' }).then((list) => {
// 已有窗口则聚焦
for (const c of list) {
if (c.url.includes(url) && 'focus' in c) return c.focus();
}
// 否则新开
return clients.openWindow(url);
})
);
});
self.addEventListener('notificationclose', (event) => {
// 统计用户关闭行为
});Push API(服务端推送)
Notification 只能本地弹,Push API 才能让服务器主动推送消息到已关闭的页面。Push 与 Notification 是两套独立 API,但通常配合使用。
工作流程
浏览器 推送服务(FCM/Mozilla) 应用服务器
│ │ │
│── subscribe() ────────────▶│ │
│◀── PushSubscription ───────│ │
│ │ │
│── 上报 subscription ───────────────────────────────────▶│
│ │ │
│ │◀── 发送 push payload ───────│
│◀── push 事件 ──────────────│ │
│ │ │
│ SW showNotification() │ │1. 订阅推送
// 1. 生成 VAPID 公私钥对(服务端执行一次)
// npx web-push generate-vapid-keys
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true, // 每次 push 必须展示通知,否则会被静默丢弃
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
// 2. 上报到服务器保存
await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub)
});PushSubscription 结构:
{
"endpoint": "https://fcm.googleapis.com/fcm/send/xxx",
"keys": {
"p256dh": "BNc...",
"auth": "abc..."
}
}2. 服务器发送 push
使用 web-push 库(Node.js):
import webpush from 'web-push';
webpush.setVapidDetails('mailto:admin@example.com', PUBLIC_KEY, PRIVATE_KEY);
await webpush.sendNotification(subscription, JSON.stringify({
title: '新消息',
body: '你有一条来自 ZhenYu 的消息',
url: '/chat/42'
}));3. SW 接收并展示
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {};
event.waitUntil(
self.registration.showNotification(data.title ?? '提示', {
body: data.body,
icon: '/icons/icon-192.png',
data: { url: data.url }
})
);
});订阅更新/失效
推送服务可能主动让 subscription 失效,需要监听 pushsubscriptionchange:
self.addEventListener('pushsubscriptionchange', async (event) => {
const newSub = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: VAPID_PUBLIC_KEY
});
await fetch('/api/resubscribe', {
method: 'POST',
body: JSON.stringify({ old: event.oldSubscription, new: newSub })
});
});权限最佳实践
- 不要一进站就请求权限,应在用户理解价值后(例如点击 "订阅更新" 按钮)再请求
- 提供撤销入口,在设置页展示当前状态
- 尊重 denied 状态,不要反复弹窗骚扰
- Safari 需要 HTTPS + 已 "添加到主屏幕" 才支持 Web Push(iOS 16.4+)
VAPID key 转换工具函数
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}