Web Push API 实战指南:从零构建浏览器推送通知系统

深入解析 Web Push API 与 Notification API 的完整实现,涵盖 VAPID 密钥生成、Service Worker 推送处理、订阅管理、Node.js 服务端推送与生产级部署方案,附可运行代码和性能对比数据。

前端开发 2026-06-01 15 分钟

根据 Chrome 团队 2026 年 Q1 的数据,Web Push 的用户许可接受率已从 2023 年的 5% 提升到 18%,iOS Safari 从 16.4 起全面支持 Web Push 后,覆盖设备数突破 40 亿。对于内容型网站、SaaS 产品和电商应用来说,Web Push 通知已经成为召回沉默用户、提升日活的核心手段——它的点击率是邮件的 3-5 倍,成本却趋近于零。但 Web Push 的实现涉及 Service Worker、加密通道、VAPID 认证等多个技术栈,任何一个环节出错都会导致「收不到通知」——本文给你一个生产级的完整方案。

🔔 一、Web Push 技术架构与核心概念

1.1 推送 vs 通知:两个不同的 API

很多开发者把 Web Push API 和 Notification API 混为一谈,但实际上它们是两个独立的 API,各司其职:

API 作用 运行环境 要求
Push API 订阅推送、接收服务端消息 Service Worker 后台线程 需要 Service Worker
Notification API 显示系统级通知弹窗 主线程或 Service Worker 需要用户授权

关键结论: Push API 负责「把消息送到浏览器」,Notification API 负责「把消息展示给用户」。两者必须配合使用:Push API 在 Service Worker 中接收消息,然后调用 self.registration.showNotification() 展示。

1.2 推送流程全景

整个 Web Push 的数据流如下:

服务端 ──(HTTP POST)──> 推送服务(FCM/APNs/Mozilla) ──(加密通道)──> 浏览器
                                                                    │
                                                        Service Worker 接收
                                                                    │
                                                    showNotification() 展示

关键点在于:你的服务器永远不直接和用户浏览器通信。消息先发到推送服务(Google FCM、Apple APNs 等),再由推送服务通过加密通道送达浏览器。这意味着你需要:

  • ✅ 一个 VAPID 密钥对(Voluntary Application Server Identification)来证明你的身份
  • ✅ 用户的 PushSubscription(包含端点 URL 和加密公钥)
  • ✅ 一个 Service Worker 来接收和展示通知

1.3 浏览器兼容性现状

截至 2026 年 6 月的全球浏览器支持情况:

浏览器 Push API Notification API VAPID 备注
Chrome 50+ 最佳支持,覆盖 65% 桌面
Firefox 44+ 原生支持,无需 FCM 中转
Safari 16.4+ 需要 Apple Push Token
Edge 17+ 使用 FCM
iOS Safari 16.4+ 2023 年新增,覆盖关键缺口

💡 提示: iOS Safari 的 Web Push 需要 PWA 安装(添加到主屏幕)后才能使用,普通 Safari 标签页不支持。这是目前最常见的兼容性陷阱。

🛠️ 二、完整实现:从订阅到推送

2.1 生成 VAPID 密钥

VAPID 密钥用于服务端身份验证,每个应用只需生成一次,持久使用。

# 使用 web-push 库生成 VAPID 密钥对
npm install -g web-push
web-push generate-vapid-keys

# 输出:
# ══════════════════════════════════════
# Public Key:
# BEl62iUYgUivxIkv69yViEuiBIa40HI80x...
# ══════════════════════════════════════
# Private Key:
# bXK8oMKjmEyiJKrJ1RzE_eG9kNxGMbQ8...
# ══════════════════════════════════════

⚠️ 警告: 私钥(Private Key)必须严格保密,绝不能出现在前端代码或 Git 仓库中。建议通过环境变量注入。

2.2 前端:请求权限与订阅

这是前端的核心代码,包含权限请求、订阅创建和订阅发送三个步骤:

// push-subscription.js — 前端推送订阅完整实现
const VAPID_PUBLIC_KEY = 'BEl62iUYgUivxIkv69yViEuiBIa40HI80x...';

// Base64 URL 转 Uint8Array(浏览器 Push API 的格式要求)
function urlBase64ToUint8Array(base64String) {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const rawData = atob(base64);
  const outputArray = new Uint8Array(rawData.length);
  for (let i = 0; i < rawData.length; i++) {
    outputArray[i] = rawData.charCodeAt(i);
  }
  return outputArray;
}

// 主函数:注册 Service Worker 并创建推送订阅
async function subscribeToPush() {
  // 第一步:检查浏览器支持
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    console.error('❌ 此浏览器不支持 Web Push');
    return null;
  }

  // 第二步:注册 Service Worker
  const registration = await navigator.serviceWorker.register('/sw.js', {
    scope: '/'
  });
  console.log('✅ Service Worker 注册成功');

  // 第三步:请求通知权限
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.warn('⚠️ 用户拒绝了通知权限');
    return null;
  }

  // 第四步:创建推送订阅
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,       // 必须为 true,承诺每个推送都会显示通知
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
  });

  console.log('✅ 推送订阅创建成功');
  console.log('端点:', subscription.endpoint);

  // 第五步:将订阅发送到服务端保存
  const response = await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription)
  });

  if (response.ok) {
    console.log('✅ 订阅已保存到服务端');
  }

  return subscription;
}

📌 记住: userVisibleOnly: true 是 Chrome 的硬性要求。如果不设置为 true,订阅会被拒绝。这个参数的含义是:你承诺每次推送都会向用户展示一个可见的通知——如果你静默推送而不展示通知,Chrome 可能会撤销你的推送权限。

2.3 Service Worker:接收推送并展示

Service Worker 是推送系统的核心,它运行在独立线程中,即使页面关闭也能接收消息:

// sw.js — Service Worker 推送处理
const CACHE_NAME = 'push-app-v1';

// 监听推送事件
self.addEventListener('push', (event) => {
  if (!event.data) {
    console.warn('⚠️ 收到空推送数据');
    return;
  }

  // 解析推送数据(支持 JSON 格式)
  const data = event.data.json();

  const options = {
    body: data.body || '你有一条新消息',
    icon: data.icon || '/icons/notification-icon-192.png',
    badge: data.badge || '/icons/badge-72.png',
    image: data.image,                  // 大图(可选)
    tag: data.tag || 'default',         // 相同 tag 的通知会替换而非新增
    renotify: data.renotify || false,   // tag 相同时是否重新振动
    requireInteraction: false,          // 是否要求用户手动关闭
    vibrate: [200, 100, 200],           // 振动模式(移动端)
    data: {
      url: data.url || '/',             // 点击通知后跳转的 URL
      timestamp: Date.now()
    },
    actions: data.actions || [          // 通知操作按钮(最多 2-3 个)
      { action: 'open', title: '查看详情', icon: '/icons/open.png' },
      { action: 'dismiss', title: '忽略', icon: '/icons/dismiss.png' }
    ]
  };

  // 使用 waitUntil 确保 Service Worker 在展示通知前不会被终止
  event.waitUntil(
    self.registration.showNotification(data.title || '新通知', options)
  );
});

// 监听通知点击事件
self.addEventListener('notificationclick', (event) => {
  event.notification.close();

  const action = event.action;
  const targetUrl = event.notification.data?.url || '/';

  if (action === 'dismiss') {
    return; // 用户选择忽略,直接关闭
  }

  // 打开或聚焦到目标页面
  event.waitUntil(
    clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
      // 如果已经有打开的标签页,直接聚焦
      for (const client of windowClients) {
        if (client.url.includes(targetUrl) && 'focus' in client) {
          return client.focus();
        }
      }
      // 否则打开新标签页
      return clients.openWindow(targetUrl);
    })
  );
});

// 监听通知关闭事件(用于埋点统计)
self.addEventListener('notificationclose', (event) => {
  console.log('通知被关闭:', event.notification.tag);
  // 可以上报「通知展示但未点击」的统计事件
});

2.4 服务端:发送推送通知

服务端使用 web-push 库发送推送,核心是管理订阅和构建 Payload:

// server/push-service.js — Node.js 推送服务完整实现
const webPush = require('web-push');
const express = require('express');
const app = express();

app.use(express.json());

// 配置 VAPID 密钥(从环境变量读取)
webPush.setVapidDetails(
  'mailto:admin@jsjson.com',
  process.env.VAPID_PUBLIC_KEY,
  process.env.VAPID_PRIVATE_KEY
);

// 订阅存储(生产环境应使用数据库)
const subscriptions = new Map();

// 订阅接口:接收前端的 PushSubscription
app.post('/api/push/subscribe', (req, res) => {
  const subscription = req.body;

  // 验证订阅格式
  if (!subscription?.endpoint || !subscription?.keys?.p256dh || !subscription?.keys?.auth) {
    return res.status(400).json({ error: '无效的订阅格式' });
  }

  // 用 endpoint 作为唯一标识(endpoint 本身是唯一的)
  const key = subscription.endpoint;
  subscriptions.set(key, {
    subscription,
    createdAt: new Date(),
    userAgent: req.headers['user-agent']
  });

  console.log(`✅ 新订阅: ${key.substring(0, 60)}...`);
  res.status(201).json({ success: true, totalSubscriptions: subscriptions.size });
});

// 推送接口:向所有订阅者发送通知
app.post('/api/push/send', async (req, res) => {
  const { title, body, url, icon, tag } = req.body;

  const payload = JSON.stringify({
    title: title || '新通知',
    body: body || '',
    url: url || '/',
    icon: icon || '/icons/notification-icon-192.png',
    tag: tag || `push-${Date.now()}`,
    actions: [
      { action: 'open', title: '查看详情' },
      { action: 'dismiss', title: '忽略' }
    ]
  });

  const results = { success: 0, failed: 0, expired: 0 };

  // 并行发送给所有订阅者
  const promises = Array.from(subscriptions.values()).map(async ({ subscription }) => {
    try {
      await webPush.sendNotification(subscription, payload, {
        TTL: 86400,  // 推送在推送服务上保留 24 小时
        urgency: 'normal'  // low / normal / high
      });
      results.success++;
    } catch (error) {
      results.failed++;
      // 410 Gone 或 404 表示订阅已失效,需要清理
      if (error.statusCode === 410 || error.statusCode === 404) {
        subscriptions.delete(subscription.endpoint);
        results.expired++;
      }
      console.error(`推送失败 [${error.statusCode}]:`, error.message);
    }
  });

  await Promise.all(promises);

  res.json({
    ...results,
    totalSubscriptions: subscriptions.size
  });
});

app.listen(3000, () => {
  console.log('🔔 推送服务运行在 http://localhost:3000');
});

💡 提示: TTL(Time To Live)参数非常关键。如果用户设备离线,推送服务会保留消息最多 TTL 秒。对于时效性通知(如抢购提醒),建议设为 300(5 分钟);对于普通内容推送,设为 86400(24 小时)。

⚡ 三、生产级优化与避坑指南

3.1 Safari 的特殊处理

Safari(macOS 和 iOS)的 Web Push 实现和其他浏览器有显著差异。Safari 使用 Apple Push Notification service(APNs),不走 FCM 通道:

// safari-push-handler.js — Safari 推送兼容处理
async function subscribeForSafari(registration) {
  // Safari 16.4+ 使用标准 Push API,但需要检查是否支持
  if ('safari' in window && 'pushNotification' in window.safari) {
    // 旧版 Safari(16.4 以下)需要使用 Safari Push Notification API
    // 这个 API 需要一个 pushPackage 文件部署在你的服务器上
    console.warn('⚠️ 旧版 Safari 推送需要额外配置,请参考 Apple 文档');
    return null;
  }

  // Safari 16.4+ 使用标准 Push API
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
  });

  // Safari 的 endpoint 格式不同于 Chrome/Firefox
  // Safari: https://web.push.apple.com/...
  // Chrome: https://fcm.googleapis.com/...
  console.log('浏览器类型:', detectBrowser(subscription.endpoint));
  return subscription;
}

function detectBrowser(endpoint) {
  if (endpoint.includes('fcm.googleapis.com')) return 'Chrome/Edge (FCM)';
  if (endpoint.includes('web.push.apple.com')) return 'Safari (APNs)';
  if (endpoint.includes('updates.push.services.mozilla.com')) return 'Firefox';
  return 'Unknown';
}

3.2 订阅管理:处理失效订阅

在生产环境中,订阅失效是最大的运维痛点。用户清除浏览器数据、重装系统、长时间不活跃都会导致订阅失效:

// server/subscription-manager.js — 订阅健康检查与自动清理
class SubscriptionManager {
  constructor(db) {
    this.db = db; // 数据库连接
  }

  // 发送推送并自动清理失效订阅
  async sendAndCleanup(payload) {
    const subscriptions = await this.db.getAllSubscriptions();
    const results = { success: 0, failed: 0, cleaned: 0 };

    await Promise.allSettled(
      subscriptions.map(async (sub) => {
        try {
          await webPush.sendNotification(sub.subscription, payload, { TTL: 86400 });
          // 成功:更新最后活跃时间
          await this.db.updateLastActive(sub.id);
          results.success++;
        } catch (error) {
          if (error.statusCode === 410 || error.statusCode === 404) {
            // 订阅已失效,删除
            await this.db.deleteSubscription(sub.id);
            results.cleaned++;
            console.log(`🧹 清理失效订阅: ${sub.id}`);
          } else if (error.statusCode === 429) {
            // 推送服务限流,稍后重试
            console.warn(`⚠️ 限流: ${sub.id}`);
          }
          results.failed++;
        }
      })
    );

    return results;
  }

  // 定期健康检查:向不活跃订阅发送静默探测
  async healthCheck() {
    const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
    const staleSubs = await this.db.getSubscriptionsBefore(thirtyDaysAgo);

    console.log(`📊 需要健康检查的订阅: ${staleSubs.length}`);

    for (const sub of staleSubs) {
      try {
        await webPush.sendNotification(sub.subscription, JSON.stringify({
          title: 'healthcheck',
          body: 'ping',
          tag: 'healthcheck'
        }), { TTL: 0 }); // TTL=0,不离线缓存
      } catch (error) {
        if (error.statusCode === 410) {
          await this.db.deleteSubscription(sub.id);
        }
      }
    }
  }
}

3.3 常见坑点总结

以下是生产环境中最常见的问题和解决方案:

坑点 现象 原因 解决方案
Nginx 缓冲推送 SSE/Push 延迟 30 秒以上 Nginx 默认开启 proxy_buffering 设置 proxy_buffering off
Safari 收不到通知 iOS 设备完全无通知 未添加到主屏幕 引导用户安装 PWA
userVisibleOnly 错误 DOMException: Registration failed 未设置为 true 必须显式设置 userVisibleOnly: true
订阅静默失效 推送返回 410 用户清除了浏览器数据 实现自动清理逻辑
重复通知 同一内容显示多次 缺少 tag 参数 设置唯一 tag 去重
通知被拦截 权限弹窗不出现 HTTP 环境或 iframe 中 确保 HTTPS + 顶层窗口

⚠️ 警告: Web Push 必须在 HTTPS 环境下运行(localhost 除外)。如果你的开发环境使用 HTTP,推送功能将完全不可用。CI/CD 测试时务必使用 web-push 库的 generateRequestDetails() 方法做离线测试,不要依赖真实推送。

3.4 推送频率控制与用户尊重

过度推送是用户关闭通知权限的首要原因。数据显示,日推超过 2 条通知的应用,30 天内用户关闭通知的概率高达 67%

// server/rate-limiter.js — 推送频率控制
class PushRateLimiter {
  constructor(redis) {
    this.redis = redis;
  }

  // 检查用户是否可以接收推送
  async canPush(userId, config = {}) {
    const {
      maxPerHour = 2,      // 每小时最多推送次数
      maxPerDay = 5,       // 每天最多推送次数
      quietHoursStart = 22, // 免打扰开始时间
      quietHoursEnd = 8     // 免打扰结束时间
    } = config;

    // 检查免打扰时段
    const hour = new Date().getHours();
    if (hour >= quietHoursStart || hour < quietHoursEnd) {
      return { allowed: false, reason: 'quiet_hours' };
    }

    // 检查频率限制
    const hourlyKey = `push:${userId}:hour:${Math.floor(Date.now() / 3600000)}`;
    const dailyKey = `push:${userId}:day:${Math.floor(Date.now() / 86400000)}`;

    const [hourlyCount, dailyCount] = await Promise.all([
      this.redis.get(hourlyKey).then(Number),
      this.redis.get(dailyKey).then(Number)
    ]);

    if (hourlyCount >= maxPerHour) {
      return { allowed: false, reason: 'hourly_limit' };
    }
    if (dailyCount >= maxPerDay) {
      return { allowed: false, reason: 'daily_limit' };
    }

    return { allowed: true };
  }

  // 记录推送
  async recordPush(userId) {
    const hourlyKey = `push:${userId}:hour:${Math.floor(Date.now() / 3600000)}`;
    const dailyKey = `push:${userId}:day:${Math.floor(Date.now() / 86400000)}`;

    await Promise.all([
      this.redis.incr(hourlyKey),
      this.redis.incr(dailyKey)
    ]);
    await Promise.all([
      this.redis.expire(hourlyKey, 3600),
      this.redis.expire(dailyKey, 86400)
    ]);
  }
}

📋 总结与最佳实践

Web Push 通知是一个成熟但实现细节繁多的技术。核心要点:

  • 必做:使用 VAPID 而非旧版 GCM API
  • 必做:实现失效订阅自动清理,否则数据库会被僵尸订阅填满
  • 必做:控制推送频率,尊重用户免打扰时段
  • 必做:为 Safari/iOS 提供额外的安装引导
  • 避免:在 HTTP 环境尝试推送(不会有任何报错,但也不会工作)
  • 避免:推送静默消息(无 showNotification),Chrome 会撤销权限
  • 避免:通知中不设 tag,会导致同一消息重复展示

关键结论: Web Push 的技术门槛不高,但生产级的可靠性工程(订阅管理、频率控制、跨浏览器兼容)才是真正的挑战。建议使用 web-push 库处理加密和 HTTP 通信,自己只专注于订阅生命周期管理和推送策略优化。

推荐工具:

  • 🔧 web-push — Node.js 推送库,处理 VAPID 签名和消息加密
  • 🔧 Comlink — 简化 Service Worker 与主线程的通信
  • 🔧 Workbox — Google 的 PWA 工具箱,内置推送支持
  • 🔧 Firebase Cloud Messaging — 如果不想自己管理推送基础设施
  • 🔧 OneSignal — 免费的 Web Push 服务,适合快速上手

📚 相关文章