根据 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 服务,适合快速上手