
大多数开发者对Push通知的理解,停留在"调用API发送消息"的表面。但我们很少深入思考:为什么用户关闭浏览器后还能收到通知?这背后的通信机制到底是什么?为什么你的推送打开率这么低?
这篇文章,我们会从源码级别剖析Service Worker、Push API和Notification API的协作原理,以及大厂(字节、阿里云)推荐的实现思路。
一个让人困惑的现象
你是否遇到过这样的情况:
-
调用了
Notification.requestPermission(),用户也点了允许,但推送消息要么没收到,要么收到了没人点 -
看似完整的推送流程,在真实生产环境中就是"失效"
-
同样的代码,字节跳动员工的App推送打开率是你的3倍
这不是巧合。问题的根源在于:大多数开发者对Push通知的实现停留在"Copy-Paste代码"阶段,从未理解它在浏览器层面的真实工作机制。
Push通知的真实工作流:看不见的三层架构
让我先画出完整的消息流转过程:
go
┌─────────────────────────────────────────────────────────────────┐
│ 用户点击"允许通知"的那一刻,发生了什么? │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [浏览器进程] [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注册 --- 隐形的守护进程
想象Service Worker就像你手机后台运行的应用。即使你关闭浏览器,它仍然可以收信息。
go
// ✅ 标准的注册方式(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(),很可能会报错。
正确做法是这样的:
go
// ✅ 确保Service Worker已准备就绪
navigator.serviceWorker.ready.then(registration => {
console.log('SW已准备好,可以安全地进行下一步');
// 在这里进行push订阅操作
});
关键词:**ready不是register**。很多人混淆这两个概念,导致竞态条件问题。
为什么Service Worker这么重要?
让我们看看浏览器对SW的内存管理机制:
go
浏览器生命周期管理:
[Page Active] [Page Closed] [Browser Closed]
│ │ │
├─ 页面SW活跃 ├─ SW仍在运行 ├─ SW持久化
│ (优先级最高) │ (中等优先级) │ 保存状态
│ │ │
└─ 可监听所有事件 └─ 只监听push/ └─ 系统通知
notification事件 触发SW
这就是为什么只有Service Worker能接收来自推送服务的消息 。普通的JavaScript(即使在<script>标签里)是收不到的------因为页面关闭后,普通脚本随之销毁,但SW会被浏览器保活。
第二层:权限请求和Push订阅 --- 建立信任链
现在我们进入真正的Push通知核心------这一步很多人做错了。
go
// ❌ 新手常见的错误做法
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个严重问题。让我们一个一个修正:
问题1:权限状态检查
浏览器的权限模型很复杂,你需要理解这些状态:
go
// ✅ 正确的权限检查流程
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状态? -
反复请求权限对转化率的影响?
这些数据直接影响产品决策。
问题2:Service Worker真正就绪的时机
关键代码:
go
// ✅ 正确的时序控制
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)) // 转为字节数组
);
}
问题3:VAPID密钥的安全性
这是一个很多教程都做错的地方。让我们对比一下:
go
// ❌ 危险做法:硬编码在前端
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公钥本身并不是"秘密"(名字里就有"公"),但如果硬编码在前端,意味着:
-
如果你需要轮换密钥,必须重新部署前端代码
-
源码泄露会暴露你使用的推送服务商和密钥ID
-
无法针对不同应用/环境使用不同的密钥
第三层:Service Worker中的Push事件处理 --- 真正的魔法发生地
现在我们进入sw.js(Service Worker脚本)。这才是Push通知的核心。
go
// ✅ 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()的重要性:
go
没有waitUntil()时的时序:
┌──────────────────────────────────────┐
│ SW收到push事件 │
├──────────────────────────────────────┤
│ │ 开始显示通知 │
│ │ (异步操作) │
│ │ │
│ └─ SW可能被浏览器直接杀死! ❌ │
│ (浏览器认为SW已完成工作) │
│ 结果:通知显示失败 │
└──────────────────────────────────────┘
使用waitUntil()时的时序:
┌──────────────────────────────────────┐
│ SW收到push事件 │
├──────────────────────────────────────┤
│ │ 开始显示通知 │
│ │ (异步操作,被waitUntil包裹) │
│ │ │
│ │ SW继续运行,直到Promise resolve │
│ │ 通知成功显示 ✅ │
│ │ │
│ └─ 然后SW才能被杀死 │
└──────────────────────────────────────┘
第四层:服务器端的推送实现 --- 把消息真正送出去
前端做得再完美,如果服务器无法正确发送,一切都是白搭。这是大多数开发者的薄弱环节。
go
// ✅ 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);
});
实战案例:某大厂的推送效果很好,为什么?
我看过某个产品的推送实现,他们做对了什么:
1. 细致的subscription管理
go
// ✅ 不仅存储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的健康度
-
自动清理僵尸subscription
2. 推送前的双重验证
go
// ✅ 发送前检查subscription是否仍然有效
asyncfunction isSubscriptionStillValid(subscription) {
try {
// 尝试发送一个哑元通知(仅用于验证)
await webPush.sendNotification(
subscription,
JSON.stringify({ test: true })
);
returntrue;
} catch (error) {
// 如果报410,说明已失效
return error.statusCode !== 410;
}
}
3. 智能的推送时机
go
// ✅ 根据用户时区和活跃时间段发送
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中使用)goconst subscribeToPush = useCallback(async () => { // 订阅逻辑... }, []); // 空依赖数组表示只初始化一次 -
批量推送时使用
Promise.allSettled()而不是Promise.all()
-
防止单个失败导致整个批次中断
-
允许部分成功
-
实现subscription缓存
-
-
不要每次都重新创建subscription
-
检查
pushManager.getSubscription()的返回值
安全方面
- VAPID私钥必须只在服务器端存储
-
环境变量中,不要提交到git
-
定期轮换
-
-
验证推送请求的来源
go// 在API端点中检查授权 app.post('/api/push/send', authenticateUser, (req, res) => { // 只有认证用户才能发送 }); -
限制推送频率
go// 防止滥用 if (tooManyPushesInShortTime(userId)) { return res.status(429).json({ error: 'Too many requests' }); }
诊断工具:如何排查推送问题?
如果推送不工作,按这个顺序诊断:
go
// 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 vs App Push vs In-App Notifications
为什么有时候该选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效果更好。
总结:你应该记住什么?
- Push通知的三个关键角色:
-
前端(注册SW,请求权限,建立subscription)
-
Service Worker(监听push和notification事件)
-
服务器(存储subscription,发送消息)
-
最常见的失败原因:
-
-
Subscription没有保存到服务器(导致服务器无法发送)
-
没有正确处理异步时序(竞态条件)
-
VAPID配置错误
-
-
生产环境必须做好:
-
-
Subscription生命周期管理(及时删除过期的)
-
错误处理和重试机制
-
推送时机优化(不是越快越好,而是越合适越好)
-
-
性能优化的核心:
-
-
不要频繁请求权限
-
批量推送使用
allSettled -
缓存subscription对象
延伸思考
-
如果你要在电商产品中做Push,应该在什么时机请求权限?→ 答案:购物车页面或结算页面,此时用户有需求
-
为什么有些推送你永远收不到?→ 因为你的subscription可能已失效,服务器没有及时清理
-
Web Push在国内推广缓慢的根本原因是什么?→ 不是技术问题,而是用户习惯问题(国内用户习惯了App Push)
推荐资源
-
Push API - MDN Web Docs
-
Service Worker规范
-
web-push npm库
-
VAPID密钥生成工具(谷歌官方)
后记
Web Push通知看似简单的"发送一条消息",实际上涉及浏览器、Service Worker、推送服务、加密、权限管理等多个复杂层面。
理解这些细节的开发者,能写出健壮、高效的推送系统。 而那些"Copy-Paste教程代码"的开发者,到了真实场景中往往一筹莫展。
如果你正在开发一个需要推送功能的产品,希望这篇文章能帮你避免大多数人踩过的坑。
喜欢这篇深度技术分析吗?
👉 关注**《前端达人》**,获取更多React、前端架构、系统设计等高质量原创内容
👉 点赞 + 分享给更多需要的人,让更多开发者理解Push通知的本质
👉 在评论区分享:*你在实现Push通知时遇到过哪个坑?*我们一起讨论解决方案!
-