Appearance
推送通知 - 完整Web Push Notification指南
1. 推送通知基础
1.1 什么是Web推送通知
Web推送通知允许网站向用户发送及时的消息,即使用户没有打开网站也能收到。
typescript
const pushNotificationConcepts = {
components: {
pushService: '推送服务(FCM/APNS)',
serviceWorker: 'Service Worker处理通知',
notificationAPI: 'Notification API显示通知',
pushAPI: 'Push API接收推送'
},
workflow: [
'1. 用户授权通知权限',
'2. 获取推送订阅',
'3. 将订阅发送到服务器',
'4. 服务器通过推送服务发送消息',
'5. Service Worker接收推送',
'6. 显示通知给用户'
],
requirements: [
'HTTPS环境',
'Service Worker',
'用户授权',
'推送服务支持'
]
};1.2 浏览器支持
typescript
const browserSupport = {
chrome: '支持 (FCM)',
firefox: '支持 (Mozilla推送服务)',
safari: '支持 (macOS Big Sur+)',
edge: '支持 (FCM)',
ios: '支持 (iOS 16.4+)',
check: () => {
return {
pushSupported: 'PushManager' in window,
notificationSupported: 'Notification' in window,
serviceWorkerSupported: 'serviceWorker' in navigator
};
}
};2. 请求通知权限
2.1 基础权限请求
javascript
// 检查权限状态
function checkNotificationPermission() {
if (!('Notification' in window)) {
console.log('浏览器不支持通知');
return 'unsupported';
}
return Notification.permission;
// 'default' - 未请求
// 'granted' - 已授权
// 'denied' - 已拒绝
}
// 请求权限
async function requestNotificationPermission() {
if (!('Notification' in window)) {
return 'unsupported';
}
const permission = await Notification.requestPermission();
if (permission === 'granted') {
console.log('通知权限已授权');
} else if (permission === 'denied') {
console.log('通知权限被拒绝');
}
return permission;
}
// 使用
const permission = await requestNotificationPermission();2.2 React权限组件
tsx
// NotificationPermission.tsx
import { useState, useEffect } from 'react';
export function NotificationPermission() {
const [permission, setPermission] = useState<NotificationPermission>('default');
const [supported, setSupported] = useState(true);
useEffect(() => {
if (!('Notification' in window)) {
setSupported(false);
return;
}
setPermission(Notification.permission);
}, []);
const requestPermission = async () => {
const result = await Notification.requestPermission();
setPermission(result);
if (result === 'granted') {
// 订阅推送
await subscribeToPush();
}
};
if (!supported) {
return <div>您的浏览器不支持通知</div>;
}
if (permission === 'granted') {
return <div>通知已开启 ✓</div>;
}
if (permission === 'denied') {
return <div>通知已被阻止,请在浏览器设置中允许</div>;
}
return (
<button onClick={requestPermission}>
开启通知
</button>
);
}2.3 最佳请求时机
tsx
// 在合适时机请求权限
export function useNotificationPrompt() {
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
// 用户完成关键操作后请求
const hasCompletedOnboarding = localStorage.getItem('onboarded');
const notificationPermission = Notification.permission;
if (hasCompletedOnboarding && notificationPermission === 'default') {
// 延迟显示,避免干扰
setTimeout(() => {
setShowPrompt(true);
}, 3000);
}
}, []);
return { showPrompt, setShowPrompt };
}
// NotificationPrompt.tsx
export function NotificationPrompt() {
const { showPrompt, setShowPrompt } = useNotificationPrompt();
if (!showPrompt) return null;
return (
<div className="notification-prompt">
<h3>开启通知</h3>
<p>接收重要更新和提醒</p>
<button onClick={async () => {
await Notification.requestPermission();
setShowPrompt(false);
}}>
允许
</button>
<button onClick={() => setShowPrompt(false)}>
暂不
</button>
</div>
);
}3. 推送订阅
3.1 生成VAPID密钥
bash
# 安装web-push
npm install web-push
# 生成VAPID密钥对
npx web-push generate-vapid-keys
# 输出:
# Public Key: BEl62iUYgUivxIkv69yViEuiBIa...
# Private Key: vcGCRLda5yLa5FGoLPP98QAxN...3.2 订阅推送
javascript
// 订阅推送
async function subscribeToPush() {
if (!('serviceWorker' in navigator)) {
return;
}
const registration = await navigator.serviceWorker.ready;
// 检查是否已订阅
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// 创建新订阅
const vapidPublicKey = 'YOUR_PUBLIC_VAPID_KEY';
const convertedKey = urlBase64ToUint8Array(vapidPublicKey);
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedKey
});
}
// 发送订阅到服务器
await saveSubscription(subscription);
return subscription;
}
// Base64转换
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// 保存订阅
async function saveSubscription(subscription) {
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription)
});
}3.3 React订阅Hook
tsx
// usePushSubscription.ts
export function usePushSubscription(vapidPublicKey: string) {
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
const [loading, setLoading] = useState(false);
const subscribe = async () => {
setLoading(true);
try {
const registration = await navigator.serviceWorker.ready;
const convertedKey = urlBase64ToUint8Array(vapidPublicKey);
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedKey
});
// 保存到服务器
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub)
});
setSubscription(sub);
} catch (error) {
console.error('订阅失败:', error);
} finally {
setLoading(false);
}
};
const unsubscribe = async () => {
if (!subscription) return;
await subscription.unsubscribe();
// 从服务器删除
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint })
});
setSubscription(null);
};
useEffect(() => {
navigator.serviceWorker.ready.then(registration => {
registration.pushManager.getSubscription().then(sub => {
setSubscription(sub);
});
});
}, []);
return { subscription, subscribe, unsubscribe, loading };
}4. 发送推送消息
4.1 服务端发送(Node.js)
javascript
// server.js
const webpush = require('web-push');
// 配置VAPID
webpush.setVapidDetails(
'mailto:your-email@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
// 存储订阅
const subscriptions = new Map();
app.post('/api/push/subscribe', (req, res) => {
const subscription = req.body;
subscriptions.set(subscription.endpoint, subscription);
res.status(201).json({ success: true });
});
// 发送推送
app.post('/api/push/send', async (req, res) => {
const { title, body, icon, url } = req.body;
const payload = JSON.stringify({
title,
body,
icon: icon || '/icons/icon-192x192.png',
badge: '/icons/badge-72x72.png',
data: { url: url || '/' }
});
const promises = Array.from(subscriptions.values()).map(subscription => {
return webpush.sendNotification(subscription, payload)
.catch(error => {
if (error.statusCode === 410) {
// 订阅已过期,删除
subscriptions.delete(subscription.endpoint);
}
});
});
await Promise.all(promises);
res.json({ success: true });
});
// 发送给特定用户
async function sendPushToUser(userId, payload) {
const subscription = await getUserSubscription(userId);
if (subscription) {
await webpush.sendNotification(
subscription,
JSON.stringify(payload)
);
}
}4.2 发送选项配置
javascript
// 高级发送选项
const options = {
TTL: 3600, // 消息存活时间(秒)
urgency: 'high', // 'very-low' | 'low' | 'normal' | 'high'
topic: 'news', // 替换相同主题的旧消息
headers: {
'Content-Encoding': 'aes128gcm'
}
};
await webpush.sendNotification(subscription, payload, options);
// 带操作的通知
const richPayload = {
title: '新消息',
body: '您有一条新消息',
icon: '/icons/message.png',
badge: '/icons/badge.png',
tag: 'message-1',
requireInteraction: true,
actions: [
{
action: 'reply',
title: '回复',
icon: '/icons/reply.png'
},
{
action: 'close',
title: '关闭',
icon: '/icons/close.png'
}
],
data: {
messageId: '123',
url: '/messages/123'
}
};5. Service Worker处理推送
5.1 接收推送事件
javascript
// sw.js
self.addEventListener('push', (event) => {
if (!event.data) {
return;
}
const data = event.data.json();
const options = {
body: data.body,
icon: data.icon || '/icons/icon-192x192.png',
badge: data.badge || '/icons/badge-72x72.png',
vibrate: [200, 100, 200],
tag: data.tag,
requireInteraction: data.requireInteraction || false,
actions: data.actions || [],
data: data.data || {}
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// 通知点击
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then(windowClients => {
// 查找已打开的窗口
for (const client of windowClients) {
if (client.url === urlToOpen && 'focus' in client) {
return client.focus();
}
}
// 打开新窗口
if (clients.openWindow) {
return clients.openWindow(urlToOpen);
}
})
);
});
// 处理操作按钮
self.addEventListener('notificationclick', (event) => {
const action = event.action;
if (action === 'reply') {
event.waitUntil(
clients.openWindow('/reply?messageId=' + event.notification.data.messageId)
);
} else if (action === 'close') {
event.notification.close();
} else {
// 默认操作
event.waitUntil(
clients.openWindow(event.notification.data.url)
);
}
});
// 通知关闭
self.addEventListener('notificationclose', (event) => {
console.log('通知已关闭:', event.notification.tag);
// 发送分析事件
fetch('/api/analytics/notification-close', {
method: 'POST',
body: JSON.stringify({
tag: event.notification.tag,
timestamp: Date.now()
})
});
});5.2 后台同步
javascript
// 请求后台同步
async function requestBackgroundSync(tag) {
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(tag);
}
// Service Worker处理同步
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-messages') {
event.waitUntil(syncMessages());
}
});
async function syncMessages() {
const messages = await getUnsentMessages();
for (const message of messages) {
try {
await fetch('/api/messages', {
method: 'POST',
body: JSON.stringify(message)
});
await markMessageAsSent(message.id);
} catch (error) {
console.error('同步失败:', error);
}
}
}6. 通知UI定制
6.1 通知样式
javascript
// 基础通知
const basicNotification = {
title: '通知标题',
body: '通知内容',
icon: '/icons/icon.png',
badge: '/icons/badge.png',
image: '/images/notification-image.jpg',
vibrate: [200, 100, 200],
timestamp: Date.now(),
requireInteraction: false,
silent: false,
tag: 'unique-tag',
renotify: false
};
// 带操作按钮
const actionNotification = {
...basicNotification,
actions: [
{
action: 'accept',
title: '接受',
icon: '/icons/accept.png'
},
{
action: 'reject',
title: '拒绝',
icon: '/icons/reject.png'
}
]
};
// 进度通知
const progressNotification = {
...basicNotification,
body: '正在下载... 45%',
tag: 'download-progress'
};
// 更新进度
async function updateProgress(percentage) {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification('下载中', {
body: `进度: ${percentage}%`,
tag: 'download-progress',
renotify: true
});
}6.2 通知分组
javascript
// 使用tag分组
async function showGroupedNotification(messages) {
const registration = await navigator.serviceWorker.ready;
if (messages.length === 1) {
await registration.showNotification(messages[0].title, {
body: messages[0].body,
tag: 'messages'
});
} else {
await registration.showNotification('新消息', {
body: `您有${messages.length}条新消息`,
tag: 'messages',
data: { messages }
});
}
}
// Service Worker展开分组
self.addEventListener('notificationclick', async (event) => {
if (event.notification.data?.messages) {
event.notification.close();
const messages = event.notification.data.messages;
// 显示每条消息
for (const msg of messages) {
await self.registration.showNotification(msg.title, {
body: msg.body,
data: msg
});
}
}
});7. 通知管理
7.1 获取现有通知
javascript
// 获取所有通知
async function getNotifications() {
const registration = await navigator.serviceWorker.ready;
const notifications = await registration.getNotifications();
console.log('当前通知:', notifications);
return notifications;
}
// 获取特定tag的通知
async function getNotificationsByTag(tag) {
const registration = await navigator.serviceWorker.ready;
const notifications = await registration.getNotifications({ tag });
return notifications;
}
// 关闭所有通知
async function closeAllNotifications() {
const notifications = await getNotifications();
notifications.forEach(notification => notification.close());
}7.2 通知去重
javascript
// Service Worker去重
self.addEventListener('push', async (event) => {
const data = event.data.json();
// 检查是否已存在相同通知
const existingNotifications = await self.registration.getNotifications({
tag: data.tag
});
if (existingNotifications.length > 0) {
// 更新现有通知
existingNotifications[0].close();
}
event.waitUntil(
self.registration.showNotification(data.title, {
...data,
renotify: true
})
);
});8. 通知分析
8.1 跟踪通知事件
javascript
// 跟踪通知展示
self.addEventListener('push', (event) => {
const data = event.data.json();
// 记录展示
fetch('/api/analytics/notification-shown', {
method: 'POST',
body: JSON.stringify({
notificationId: data.id,
timestamp: Date.now()
})
});
event.waitUntil(
self.registration.showNotification(data.title, data)
);
});
// 跟踪点击
self.addEventListener('notificationclick', (event) => {
fetch('/api/analytics/notification-clicked', {
method: 'POST',
body: JSON.stringify({
notificationId: event.notification.data?.id,
action: event.action,
timestamp: Date.now()
})
});
});
// 跟踪关闭
self.addEventListener('notificationclose', (event) => {
fetch('/api/analytics/notification-closed', {
method: 'POST',
body: JSON.stringify({
notificationId: event.notification.data?.id,
timestamp: Date.now()
})
});
});8.2 通知效果分析
typescript
// 分析通知效果
interface NotificationMetrics {
sent: number;
delivered: number;
shown: number;
clicked: number;
closed: number;
ctr: number; // Click-through rate
}
function calculateNotificationMetrics(data: NotificationEvent[]): NotificationMetrics {
const sent = data.filter(e => e.type === 'sent').length;
const delivered = data.filter(e => e.type === 'delivered').length;
const shown = data.filter(e => e.type === 'shown').length;
const clicked = data.filter(e => e.type === 'clicked').length;
const closed = data.filter(e => e.type === 'closed').length;
const ctr = shown > 0 ? (clicked / shown) * 100 : 0;
return { sent, delivered, shown, clicked, closed, ctr };
}9. 最佳实践
typescript
const pushNotificationBestPractices = {
permission: [
'在合适时机请求权限',
'解释为什么需要通知',
'提供关闭选项',
'尊重用户选择',
'不要反复请求'
],
content: [
'标题简洁明了',
'内容有价值',
'及时发送',
'避免垃圾通知',
'个性化内容'
],
design: [
'提供清晰图标',
'使用操作按钮',
'合理分组',
'支持深色模式',
'测试不同设备'
],
technical: [
'处理订阅过期',
'实现重试机制',
'优化payload大小',
'使用tag去重',
'跟踪通知效果'
],
compliance: [
'遵守平台规则',
'提供退订选项',
'保护用户隐私',
'透明的数据使用',
'符合GDPR等法规'
]
};10. 总结
Web推送通知的核心要点:
- 权限管理: 合适时机请求,尊重用户选择
- 订阅流程: VAPID密钥,订阅保存
- 发送推送: 服务端配置,payload设计
- SW处理: 显示通知,处理交互
- UI定制: 图标,操作按钮,分组
- 通知管理: 去重,更新,关闭
- 效果分析: 跟踪展示,点击,转化
正确实施推送通知可以有效提升用户参与度。