
大多数开发者对Push通知的理解,停留在"调用API发送消息"的表面。但我们很少深入思考:为什么用户关闭浏览器后还能收到通知?这背后的通信机制到底是什么?为什么你的推送打开率这么低?
这篇文章,我们会从源码级别剖析Service Worker、Push API和Notification API的协作原理,以及大厂(字节、阿里云)推荐的实现思路。
你是否遇到过这样的情况:
Notification.requestPermission(),用户也点了允许,但推送消息要么没收到,要么收到了没人点这不是巧合。问题的根源在于:大多数开发者对Push通知的实现停留在"Copy-Paste代码"阶段,从未理解它在浏览器层面的真实工作机制。
让我先画出完整的消息流转过程:
┌─────────────────────────────────────────────────────────────────┐
│ 用户点击"允许通知"的那一刻,发生了什么? │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [浏览器进程] [Service Worker进程] │
│ │ │ │
│ 1. 加载SW.js 1. 独立的后台进程 │
│ │ 2. 持久化存活 │
│ 2. 建立Push订阅 3. 监听push/notification事件 │
│ │ │ │
│ └──→ 获取VAPID密钥 ────────────┘ │
│ │ │
│ ├─→ 生成subscription对象 │
│ │ { │
│ │ endpoint: "https://fcm.google.com/...", │
│ │ keys: { p256dh, auth } │
│ │ } │
│ │ │
│ └─→ 发送到服务器保存 │
│ (关键!没有这一步就无法推送) │
│ │
│ [推送服务器](第三方:Google FCM、Apple APNs等) │
│ │ │
│ └─→ 加密消息 ──→ 推送给用户设备 ──→ 触发SW push事件 │
│ │
└─────────────────────────────────────────────────────────────────┘
这个图揭示了一个90%的开发者忽视的问题:
Push订阅对象必须存储在服务器。 如果没有这一步,再完美的前端代码也无法工作。这正是为什么很多人"按教程做了但还是收不到推送"。
想象Service Worker就像你手机后台运行的应用。即使你关闭浏览器,它仍然可以收信息。
// ✅ 标准的注册方式(99%的教程都是这样写)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker作用域:', registration.scope);
// 重要:记住这个registration对象,后续的push订阅需要它
})
.catch(error => {
console.error('注册失败:', error);
});
}
但这里有个坑: 上面的代码只是注册,并不能保证SW已经完全启动。如果你立刻去调用pushManager.subscribe(),很可能会报错。
正确做法是这样的:
// ✅ 确保Service Worker已准备就绪
navigator.serviceWorker.ready.then(registration => {
console.log('SW已准备好,可以安全地进行下一步');
// 在这里进行push订阅操作
});
关键词:**ready不是register**。很多人混淆这两个概念,导致竞态条件问题。
让我们看看浏览器对SW的内存管理机制:
浏览器生命周期管理:
[Page Active] [Page Closed] [Browser Closed]
│ │ │
├─ 页面SW活跃 ├─ SW仍在运行 ├─ SW持久化
│ (优先级最高) │ (中等优先级) │ 保存状态
│ │ │
└─ 可监听所有事件 └─ 只监听push/ └─ 系统通知
notification事件 触发SW
这就是为什么只有Service Worker能接收来自推送服务的消息。普通的JavaScript(即使在<script>标签里)是收不到的——因为页面关闭后,普通脚本随之销毁,但SW会被浏览器保活。
现在我们进入真正的Push通知核心——这一步很多人做错了。
// ❌ 新手常见的错误做法
asyncfunction setupPushNotifications() {
// 问题1:没有检查权限状态就直接请求
const permission = await Notification.requestPermission();
if (permission === 'granted') {
// 问题2:没等Service Worker真正ready
const registration = await navigator.serviceWorker.register('/sw.js');
// 问题3:VAPID密钥硬编码在前端(严重的安全问题)
const vapidKey = 'BJ_public_key_hardcoded_here...';
// 开始订阅
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidKey)
});
}
}
上面的代码有3个严重问题。让我们一个一个修正:
浏览器的权限模型很复杂,你需要理解这些状态:
// ✅ 正确的权限检查流程
asyncfunction checkNotificationPermission() {
// 1. 首先检查浏览器是否支持
if (!('Notification'inwindow)) {
return { supported: false, reason: '浏览器不支持Notification API' };
}
// 2. 检查当前权限状态(不会触发权限弹窗)
const currentPermission = Notification.permission;
switch (currentPermission) {
case'granted':
return { granted: true, canSubscribe: true };
case'denied':
// 用户已经明确拒绝,再调用requestPermission()也无效
return { granted: false, reason: '用户已拒绝,需要手动在浏览器设置中修改' };
case'default':
// 用户还没有做过任何选择,现在可以请求
return { granted: false, canRequest: true };
}
}
// 只有在default状态下,才值得调用requestPermission()
asyncfunction ensureNotificationPermission() {
if (Notification.permission === 'granted') {
returntrue;
}
if (Notification.permission === 'denied') {
console.warn('用户已拒绝通知权限,无法恢复');
returnfalse;
}
// 只在'default'状态下请求
const permission = await Notification.requestPermission();
return permission === 'granted';
}
为什么要这么细致? 因为在生产环境中(比如字节跳动的某个中后台系统),你需要统计:
default状态?这些数据直接影响产品决策。
关键代码:
// ✅ 正确的时序控制
asyncfunction subscribeToPushNotifications() {
// 第一步:确保权限已授予
const hasPermission = await ensureNotificationPermission();
if (!hasPermission) return;
// 第二步:确保SW已完全加载和激活
const registration = await navigator.serviceWorker.ready;
// ⚠️ 注意:这里用的是.ready,不是.register()的返回值
// 第三步:获取VAPID公钥(从服务器)
const vapidPublicKey = await fetchVapidPublicKeyFromServer();
// 第四步:转换VAPID密钥格式
const convertedKey = urlBase64ToUint8Array(vapidPublicKey);
// 第五步:真正的订阅操作
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true, // 必须为true,表示通知必须对用户可见
applicationServerKey: convertedKey
});
console.log('订阅成功,subscription对象:', subscription);
// 第六步:发送subscription到服务器(最关键!)
await sendSubscriptionToServer(subscription);
} catch (error) {
console.error('订阅失败:', error);
handleSubscriptionError(error);
}
}
// ✅ VAPID密钥转换函数(必须理解这一步)
function urlBase64ToUint8Array(base64String) {
// 为什么需要这个转换?
// VAPID公钥从服务器以Base64编码字符串的形式传输
// 但Push API需要Uint8Array格式
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+') // URL安全Base64 → 标准Base64
.replace(/_/g, '/'); //
const rawData = window.atob(base64); // 解码为二进制字符串
returnUint8Array.from(
[...rawData].map(char => char.charCodeAt(0)) // 转为字节数组
);
}
这是一个很多教程都做错的地方。让我们对比一下:
// ❌ 危险做法:硬编码在前端
const VAPID_PUBLIC_KEY = 'BJ_sPzT..._你的整个公钥'; // 暴露在源码里!
// ✅ 正确做法:从服务器动态获取
asyncfunction fetchVapidPublicKeyFromServer() {
const response = await fetch('/api/push/vapid-public-key', {
headers: {
'Content-Type': 'application/json',
// 如果需要验证用户身份
'Authorization': `Bearer ${userToken}`
}
});
if (!response.ok) {
thrownewError('获取VAPID密钥失败');
}
const { publicKey } = await response.json();
return publicKey;
}
为什么? 虽然VAPID公钥本身并不是"秘密"(名字里就有"公"),但如果硬编码在前端,意味着:
现在我们进入sw.js(Service Worker脚本)。这才是Push通知的核心。
// ✅ sw.js: Service Worker脚本
// 关键事件1:监听push事件
self.addEventListener('push', event => {
console.log('收到push消息:', event);
// 从push事件中提取数据
const pushData = event.data ? event.data.json() : null;
if (!pushData) {
console.warn('Push消息为空,使用默认通知');
return;
}
const { title, body, icon, badge, url, actions, tag } = pushData;
const notificationOptions = {
body: body,
icon: icon || '/default-icon.png',
badge: badge || '/default-badge.png',
tag: tag || 'general-notification', // 相同tag的通知会替换
data: { url }, // 存储自定义数据,通知点击时可获取
actions: actions || [ // 可选的操作按钮
{ action: 'open', title: '打开' },
{ action: 'close', title: '关闭' }
],
// 新增的React 19友好选项
requireInteraction: false, // true表示必须用户手动关闭,不会自动消失
vibrate: [200, 100, 200], // 振动模式
timestamp: Date.now() // 时间戳,某些浏览器会显示
};
// ⚠️ 关键:event.waitUntil() 确保Service Worker不会在操作完成前被杀死
event.waitUntil(
self.registration.showNotification(title, notificationOptions)
.then(() => {
console.log('通知显示成功');
})
.catch(error => {
console.error('显示通知失败:', error);
})
);
});
// 关键事件2:监听notificationclick事件
self.addEventListener('notificationclick', event => {
console.log('用户点击了通知:', event.notification.tag);
// 首先关闭通知
event.notification.close();
// 获取通知中存储的URL
const targetUrl = event.notification.data?.url || '/';
// ⚠️ 关键:event.waitUntil() 确保操作完成
event.waitUntil(
// 1. 查找已经打开的该应用窗口
clients.matchAll({ type: 'window' })
.then(windowClients => {
// 2. 检查是否已有对应URL的窗口打开
for (const client of windowClients) {
if (client.url === targetUrl && 'focus'in client) {
return client.focus(); // 有的话就聚焦
}
}
// 3. 如果没有,打开新窗口
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
})
);
});
// 关键事件3:监听notificationclose事件(可选但有用)
self.addEventListener('notificationclose', event => {
console.log('用户关闭了通知:', event.notification.tag);
// 在这里可以追踪用户没有点击的通知
// 对于分析推送效果很有帮助
fetch('/api/analytics/notification-closed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notificationTag: event.notification.tag,
timestamp: newDate().toISOString()
})
});
});
理解event.waitUntil()的重要性:
没有waitUntil()时的时序:
┌──────────────────────────────────────┐
│ SW收到push事件 │
├──────────────────────────────────────┤
│ │ 开始显示通知 │
│ │ (异步操作) │
│ │ │
│ └─ SW可能被浏览器直接杀死! ❌ │
│ (浏览器认为SW已完成工作) │
│ 结果:通知显示失败 │
└──────────────────────────────────────┘
使用waitUntil()时的时序:
┌──────────────────────────────────────┐
│ SW收到push事件 │
├──────────────────────────────────────┤
│ │ 开始显示通知 │
│ │ (异步操作,被waitUntil包裹) │
│ │ │
│ │ SW继续运行,直到Promise resolve │
│ │ 通知成功显示 ✅ │
│ │ │
│ └─ 然后SW才能被杀死 │
└──────────────────────────────────────┘
前端做得再完美,如果服务器无法正确发送,一切都是白搭。这是大多数开发者的薄弱环节。
// ✅ Node.js服务器端实现(使用web-push库)
const webPush = require('web-push');
// 1️⃣ 首先,设置VAPID密钥对
const vapidKeys = {
publicKey: 'BJ_sPzTWl..._公钥', // 与前端使用的相同
privateKey: 'abc123..._私钥' // ⚠️ 极其重要,不要泄露!
};
webPush.setVapidDetails(
'mailto:your-email@example.com', // 可以是任何联系方式
vapidKeys.publicKey,
vapidKeys.privateKey
);
// 2️⃣ 从数据库获取用户的push subscription对象
asyncfunction pushNotificationToUser(userId, notification) {
try {
// 从DB查询这个用户的订阅信息
const subscription = await getSubscriptionFromDB(userId);
if (!subscription) {
console.log(`用户 ${userId} 没有订阅push通知`);
return { success: false, reason: '未订阅' };
}
// 3️⃣ 发送通知(最关键的一步)
const payload = JSON.stringify({
title: notification.title,
body: notification.body,
icon: notification.icon,
badge: notification.badge,
url: notification.url,
tag: notification.tag || `notification_${Date.now()}`
});
await webPush.sendNotification(subscription, payload);
console.log(`✅ 通知已发送给用户 ${userId}`);
return { success: true };
} catch (error) {
// 4️⃣ 错误处理(这很关键!)
handlePushError(userId, error);
}
}
// 4️⃣ 错误处理的详细逻辑
asyncfunction handlePushError(userId, error) {
console.error(`用户 ${userId} 推送失败:`, error.statusCode, error.message);
// 根据错误类型采取不同的措施
if (error.statusCode === 410) {
// 410 Gone - subscription已过期,需要删除
console.log('Subscription已过期,从DB删除');
await deleteSubscriptionFromDB(userId);
}
elseif (error.statusCode === 401) {
// 401 Unauthorized - VAPID密钥配置错误
console.error('❌ VAPID密钥配置有误!');
}
elseif (error.statusCode === 429) {
// 429 Too Many Requests - 被限流,需要退避
console.warn('推送服务被限流,稍后重试');
// 实现指数退避重试
}
else {
// 其他错误,需要根据业务逻辑处理
console.error('未知错误:', error);
}
}
// 5️⃣ 批量推送的优化方案(大厂常用)
asyncfunction batchPushNotifications(notificationData) {
const allSubscriptions = await getAllSubscriptionsFromDB();
// 使用Promise.allSettled而不是Promise.all
// 这样单个失败不会导致整个批次中断
const results = awaitPromise.allSettled(
allSubscriptions.map(subscription =>
webPush.sendNotification(subscription, JSON.stringify(notificationData))
)
);
// 统计结果
let successCount = 0;
let failureCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
} else {
failureCount++;
handlePushError(allSubscriptions[index].userId, result.reason);
}
});
console.log(`批量推送完成: 成功${successCount}, 失败${failureCount}`);
}
// 6️⃣ Express API端点示例
const express = require('express');
const app = express();
app.post('/api/push/send', async (req, res) => {
const { userId, title, body, url } = req.body;
// 验证权限(确保只有授权的用户/系统可以发送)
if (!isAuthorized(req)) {
return res.status(403).json({ error: 'Unauthorized' });
}
const result = await pushNotificationToUser(userId, {
title,
body,
url,
icon: '/default-icon.png'
});
res.json(result);
});
我看过某个产品的推送实现,他们做对了什么:
// ✅ 不仅存储subscription对象,还记录元数据
asyncfunction saveSubscriptionWithMetadata(userId, subscription) {
constdocument = {
userId,
subscription,
// 这些数据很重要!
subscribedAt: newDate(),
lastActivityAt: newDate(),
isActive: true,
userAgent: navigator.userAgent,
platform: detectPlatform(),
language: navigator.language
};
await db.collection('push_subscriptions').insertOne(document);
}
为什么?这样他们可以:
// ✅ 发送前检查subscription是否仍然有效
asyncfunction isSubscriptionStillValid(subscription) {
try {
// 尝试发送一个哑元通知(仅用于验证)
await webPush.sendNotification(
subscription,
JSON.stringify({ test: true })
);
returntrue;
} catch (error) {
// 如果报410,说明已失效
return error.statusCode !== 410;
}
}
// ✅ 根据用户时区和活跃时间段发送
async function smartPush(userId, notification) {
const userProfile = await getUserProfile(userId);
const optimalTime = calculateOptimalPushTime(userProfile);
// 不是立刻发送,而是在用户最可能看到的时间发送
schedulePush(userId, notification, optimalTime);
}
这就是为什么大厂的推送打开率高。不是因为推送技术更好,而是推送策略更聪明。
问题 | 症状 | 解决方案 |
|---|---|---|
Subscription未保存到服务器 | 推送后无反应 | 确保sendSubscriptionToServer()在前端调用了 |
VAPID密钥配置错误 | 返回401错误 | 检查服务器的webPush.setVapidDetails()调用 |
没有使用event.waitUntil() | 通知显示不稳定 | 在push和notificationclick事件中都要用 |
混淆.register()和.ready | 竞态条件,随机失败 | 务必使用navigator.serviceWorker.ready |
VAPID密钥硬编码在前端 | 源码泄露风险 | 从服务器动态获取 |
权限弹窗过早出现 | 用户直接拒绝 | 在适当的用户交互时机请求(如购物车页面) |
没有处理subscription过期 | 推送积累失败,浪费资源 | 监听410错误并删除旧subscription |
使用useCallback或useMemo缓存重型操作(如果你在React中使用)
const subscribeToPush = useCallback(async () => {
// 订阅逻辑...
}, []); // 空依赖数组表示只初始化一次
批量推送时使用Promise.allSettled()而不是Promise.all()
实现subscription缓存
pushManager.getSubscription()的返回值VAPID私钥必须只在服务器端存储
验证推送请求的来源
// 在API端点中检查授权
app.post('/api/push/send', authenticateUser, (req, res) => {
// 只有认证用户才能发送
});
限制推送频率
// 防止滥用
if (tooManyPushesInShortTime(userId)) {
return res.status(429).json({ error: 'Too many requests' });
}
如果推送不工作,按这个顺序诊断:
// 1. 检查浏览器支持
console.log('✓ Notification API:', 'Notification'inwindow);
console.log('✓ Service Worker:', 'serviceWorker'in navigator);
console.log('✓ Push Manager:', 'PushManager'inwindow);
// 2. 检查Service Worker状态
navigator.serviceWorker.ready.then(reg => {
console.log('✓ Service Worker已激活');
// 3. 检查是否已订阅
reg.pushManager.getSubscription().then(sub => {
if (sub) {
console.log('✓ 已订阅,subscription对象:', sub);
console.log(' Endpoint:', sub.endpoint);
} else {
console.warn('✗ 未订阅,需要调用subscribe()');
}
});
});
// 4. 检查权限状态
console.log('权限状态:', Notification.permission);
// 5. 在浏览器DevTools中模拟push事件
// Chrome DevTools → Application → Service Workers → 点击"Push"
为什么有时候该选Web Push,有时候不应该?
维度 | Web Push | Native App Push | In-App通知 |
|---|---|---|---|
工作范围 | 浏览器关闭仍可收到 | 任何时候(最可靠) | 仅App打开时 |
用户打开率 | 15-25% | 40-60% | 60-80%(但基础小) |
实现难度 | 中等(需要SW) | 简单(Native API) | 简单(Just JS) |
适合场景 | 重要消息、实时更新 | 高转化场景 | 增强用户体验 |
中文市场现状 | 逐渐普及(PC为主) | 主流 | 常见 |
我的建议: 如果目标用户主要在PC或电脑上,Web Push是关键。如果主要移动端,还是Native App Push效果更好。
allSettledWeb Push通知看似简单的"发送一条消息",实际上涉及浏览器、Service Worker、推送服务、加密、权限管理等多个复杂层面。
理解这些细节的开发者,能写出健壮、高效的推送系统。 而那些"Copy-Paste教程代码"的开发者,到了真实场景中往往一筹莫展。
如果你正在开发一个需要推送功能的产品,希望这篇文章能帮你避免大多数人踩过的坑。
喜欢这篇深度技术分析吗?
👉 关注《前端达人》,获取更多React、前端架构、系统设计等高质量原创内容
👉 点赞 + 分享给更多需要的人,让更多开发者理解Push通知的本质
👉 在评论区分享:你在实现Push通知时遇到过哪个坑?我们一起讨论解决方案!