Skip to content

推送通知 - 完整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推送通知的核心要点:

  1. 权限管理: 合适时机请求,尊重用户选择
  2. 订阅流程: VAPID密钥,订阅保存
  3. 发送推送: 服务端配置,payload设计
  4. SW处理: 显示通知,处理交互
  5. UI定制: 图标,操作按钮,分组
  6. 通知管理: 去重,更新,关闭
  7. 效果分析: 跟踪展示,点击,转化

正确实施推送通知可以有效提升用户参与度。