Web Push 通知:从订阅到推送
想给 Web 用户发推送通知,不用 app 也能做到——靠 Web Push API + Service Worker + Push 服务。本文讲清 Web Push 的完整链路(订阅→服务端推送→SW 展示)、VAPID 密钥、Notification 权限与体验,以及 iOS Safari 16.4+ 也支持了。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
做过用户留存的同学大概都被同一件事折磨过:怎么在用户离开网站之后还能触达他。邮件太慢、短信太贵、IM 消息要账号体系,而 App 推送——那得先让用户装一个 App。Web 长期没有像样的答案,直到 Web Push 成熟。
这两年最关键的变化是 iOS 16.4+ 终于支持了 Web Push。这意味着同一套 Push API + Service Worker 代码,桌面 Chrome/Edge/Firefox/Safari 能收,iPhone 用户也能收。以前 Safari 是 Web Push 的黑洞,今天它补上了最后一块。本文把这条链路从订阅到推送一次讲透,代码按实际能跑的标准写。
一、Web Push 的价值:像 App 一样,但不用装 App
Web Push 的本质能力就一句话:用户关掉你的网站、甚至关掉浏览器(桌面端),你依然能把一条消息推到他的屏幕上。这和页面上弹个 toast 是两回事——toast 需要页面开着,Web Push 不需要。
它的底层依赖三样东西:
- Push 服务(Push Service):每个浏览器厂商跑一个中转服务(Chrome 用 FCM、Firefox 用 Mozilla 自己的、Safari 用 APNs)。你的服务端把消息发给它,它负责把消息投递到对应设备。
- Service Worker:一个常驻(按需唤醒)在浏览器后台的 JS 脚本,用来在消息到达时被唤醒、展示通知。
- Notification API:真正把通知画到系统通知中心的能力。
合起来,Web 给了你一条和原生 App 几乎等价的通知通道,但用户全程不用装任何东西——授权、订阅、接收,全在浏览器里完成。对于工具型站点、内容社区、后台管理系统,这是留存利器。
二、完整链路:七步看清推送是怎么跑通的
很多人能写出一个 showNotification,但讲不清整条链路。我把它拆成七步,顺着数据流走一遍:
- 用户授权:
Notification.requestPermission()弹窗,用户点"允许"。 - 注册 Service Worker:页面里
navigator.serviceWorker.register('sw.js'),拿到 registration。 - 订阅 Push:用 registration 调
pushManager.subscribe(),拿到一个PushSubscription(里面有个 endpoint URL + 一对密钥)。 - 上传订阅:把这个 subscription 发给你自己的后端,存进数据库(每个用户一条)。
- 服务端推送:后端用 VAPID 私钥签名 + 用 subscription 的公钥加密 payload,POST 到那个 endpoint URL。
- Push 服务中转:浏览器厂商的 Push 服务收到请求,验证签名、解密校验,把消息下发给对应设备的浏览器。
- Service Worker 收到 push 事件被唤醒,调
showNotification把通知画出来,用户点击走notificationclick。
记死这条链路,后面每个概念都是在填某一步的坑:VAPID 是第 5 步的身份签名,subscription 是第 3、4 步的握手产物,Service Worker 是第 7 步的接收方。
三、VAPID:推送世界的"身份证"
VAPID(Voluntary Application Server Identification)是整条链路里最容易让人懵的概念,但它的目的极其朴素:让 Push 服务知道"这条推送是你这个应用发的",防止别人冒充你往你的用户身上塞消息。
它是一对公私钥(ECDSA P-256):
- 私钥:留在你的服务端,永远不外泄,用来给每次推送请求签名。
- 公钥:在浏览器订阅时(
applicationServerKey)交给浏览器,浏览器把它交给 Push 服务;Push 服务后续用这个公钥验证你发来的签名对不对。
生成它是一次性动作,用 web-push 库一行就出来:
// 只需运行一次,把结果存到环境变量里,长期使用
import webpush from 'web-push';
const vapidKeys = webpush.generateVAPIDKeys();
console.log(vapidKeys);
// { publicKey: 'BMxx...base64url', privateKey: 'DhM...' }
// 把这两个值分别放进 VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY
注意:公钥要给前端(订阅时用),私钥只在后端。两个都泄漏等于别人能替你发推送,泄漏了就重新生成、让用户重新订阅。
四、订阅:权限 + PushManager.subscribe()
订阅分两小步,顺序不能反:先有 Notification 权限,再调 subscribe。
// 客户端代码 —— 注意:'use client' 边界内、用户手势(按钮点击)触发
async function subscribeUser() {
// 1. 权限
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
// 用户拒绝或忽略 —— 这里别硬刚,提示一下就好
return null;
}
// 2. 注册 Service Worker
const reg = await navigator.serviceWorker.ready;
// 3. 订阅 Push —— applicationServerKey 就是 VAPID 公钥
const subscription = await reg.pushManager.subscribe({
userVisibleOnly: true, // 必须为 true:每条推送都必须展示通知
applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
});
// 4. 把 subscription 发给后端存起来
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
return subscription;
}
几个关键点必须说清:
userVisibleOnly: true是所有浏览器强制的。它的含义是"你每推一条,都必须给用户看见一条通知"——不允许"静默后台推送"。这是 Web Push 和原生推送最大的体验差异,也是隐私上的一道闸。想偷偷在后台同步数据?Web 不给你这个选项。applicationServerKey是 VAPID 公钥(base64url 字符串直接传即可)。- 订阅成功拿到的
subscription长这样,把它整体存到后端:
interface PushSubscription {
endpoint: string; // 形如 https://fcm.googleapis.com/fcm/send/xxxx
keys: {
p256dh: string; // 浏览器生成的 ECDH 公钥,用来加密 payload
auth: string; // 16 字节 auth secret,加密用
};
expirationTime: number | null;
}
endpoint 是这台设备、这个浏览器在你这个应用下的唯一投递地址。keys 是端到端加密 payload 要用的材料。两者都不可泄漏。
还有个常被忽略的坑:subscription 会过期失效。浏览器版本升级、用户清缓存、或 Push 服务轮换密钥时,旧的 subscription 会失效,继续推只会 404。处理方式是监听 Service Worker 里的 pushsubscriptionchange 事件,在它触发时重新订阅并把新 endpoint 上报后端,替换掉旧记录。可惜这个事件各浏览器支持不一(Chrome 长期不触发),稳妥的做法是每次用户访问站点时,比对本地缓存的 subscription 和后端存的,不一致就刷新一遍。
五、服务端推送:web-push 库一行搞定
后端发推送,核心就是"往那个 endpoint POST 一个加密过的请求"。但加密(RFC 8291)、签名(JWT + ES256)、处理重试——手写既蠢又危险。web-push 库把这些全包了:
// server.ts (Node.js)
import webpush from 'web-push';
// 配置 VAPID 身份 —— 进程启动时跑一次
webpush.setVapidDetails(
'mailto:[email protected]', // Push 服务真出问题时联系你
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!,
);
// 给某个用户推一条 —— subscription 是第 4 步存进库里的那个对象
async function pushNotification(
subscription: webpush.PushSubscription,
payload: { title: string; body: string; url?: string },
) {
try {
await webpush.sendNotification(
subscription,
JSON.stringify(payload), // payload 会被库自动端到端加密
{
TTL: 60 * 60 * 24, // 1 天内投不到就丢弃,别无限重试
},
);
} catch (err) {
// 410 Gone / 404:订阅已失效,从库里删掉,别再推
if (err.statusCode === 410 || err.statusCode === 404) {
await db.pushSubscription.delete({ where: { endpoint: subscription.endpoint } });
}
}
}
库帮你做的事:用 VAPID 私钥签 JWT 放进 Authorization 头、用 subscription 的 p256dh+auth 加密 payload、按 endpoint 的域名 POST 出去。你只管 sendNotification(subscription, 字符串)。
两个实战要点别忽略:
- 失效订阅必须清理。用户卸载浏览器、清数据、取消授权,endpoint 会变成 404/410。继续往死地址推是浪费配额,FCM 在大流量下会因为这个把你限流。捕获 statusCode 删库,是基本卫生。
- TTL 要设。默认 4 周,对绝大多数业务(到点提醒、状态变更)太长。一条"直播开始"的推送 24 小时后才送到,只会让用户觉得你 bug 了。
六、Service Worker:接收、展示、点击
推送到达时,浏览器会唤醒你注册的 Service Worker 触发 push 事件。展示通知和点击处理都写在这里:
// public/sw.js
self.addEventListener('push', (event) => {
let data;
try {
data = event.data.json(); // 对应服务端 JSON.stringify(payload)
} catch {
data = { title: '通知', body: event.data?.text() ?? '' };
}
event.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/icon-192.png',
badge: '/badge-72.png',
data: { url: data.url ?? '/' }, // notificationclick 里取
tag: data.tag, // 相同 tag 会顶掉上一条,适合"未读消息数"
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const targetUrl = event.notification.data?.url ?? '/';
event.waitUntil(
(async () => {
const allClients = await self.clients.matchAll({
type: 'window',
includeUncontrolled: true,
});
// 优先 focus 已打开的同源标签页,没有再开新的
for (const client of allClients) {
if (client.url.includes(targetUrl) && 'focus' in client) {
return client.focus();
}
}
return self.clients.openWindow(targetUrl);
})(),
);
});
注意几个细节:event.waitUntil 是必须的,Service Worker 跑完事件就会被挂起,不 waitUntil 通知可能来不及画出来;tag 用来去重叠通知(连续推三条未读,只想留一条就给同一个 tag);notificationclick 里别直接 openWindow 就完事,先看有没有已开标签页可以 focus,体验好得多。
七、体验:权限时机是成败关键,跟技术无关
Web Push 项目失败,九成不是技术挂了,是权限被用户拒绝。这里有一条铁律你必须记住:
用户一旦在浏览器里点了"拒绝",你就再也没法弹第二次。 浏览器会记住这个决定,后续 requestPermission() 直接返回 denied,连弹窗都不弹。要挽回,只能让用户去浏览器设置里手动改——99% 的用户不会回来。
所以千万别在页面一加载就 requestPermission()。那是把留存机会一次性梭哈。正确的姿势:
- 先教育,再请求。用自己的 UI(一个卡片、一个按钮)先讲清楚"我们会在什么场景下给你推什么通知",让用户主动点"开启通知"按钮。
- 在用户点了按钮之后,才调
requestPermission()。浏览器要求权限请求由用户手势触发,这正好对上了。 - 被拒绝就别追问。检测到
denied,给一次"去设置开启"的引导链接,然后闭嘴。 - 内容要有意义。推"我们更新了隐私政策"这种废话,用户第一次就会关掉通知。把推送当成稀缺资源,只在"对你真正有价值"的时刻发。
有条件再加静默时段:用户配置"22:00–8:00 不收推送",你后端按用户时区把 TTL 推后或直接跳过。这是把控制权还给用户,也是留存的长久之计。再进一步,给用户分类订阅的权力——"只收提及我的""只收系统安全通知",把噪声降到最低,授权转化的留存率才会高。一刀切的全量推送,是运营偷懒,不是产品。
八、iOS 的特殊限制
iOS 16.4+ 支持 Web Push,但有个硬性前提:用户必须先把你的站点"添加到主屏幕"作为 PWA 安装。在普通 Safari 标签页里,Service Worker 能跑,但 pushManager.subscribe 不工作——苹果把 Web Push 锁在了"已安装的 Web App"范围内。
这意味着:
- 你得先把站点做成合格的 PWA(有 manifest、有 Service Worker、有图标、可独立打开),iOS 用户才会有"添加到主屏幕"的入口。
- 桌面端"开着浏览器就能收"的体验,在 iOS 上做不到——必须先安装。
- iOS 上的通知走的是系统通知中心,尊重"专注模式""勿扰",体验和原生通知一致。
这条限制比桌面多,但对工具型、社区型产品(用户本就有理由"安装"你的站点)不是致命伤。把"添加到主屏幕 → 开启通知"做成一条引导流,iOS 上的转化依然可观。
小结
- Web Push 让你在用户离开站点后依然能触达他,桌面全浏览器支持,iOS 16.4+ 也补上了最后一块——但前提是 PWA 安装到主屏。
- 链路七步要记死:权限 → 注册 SW → 订阅拿 endpoint → 存后端 → VAPID 签名加密推送 → Push 服务中转 → SW 收到 push 展示。
- VAPID 是应用身份证,一对公私钥,公钥给浏览器、私钥留服务端,泄漏了就重新生成。
userVisibleOnly: true是硬约束,Web 不允许静默后台推送——每推必显示,这是隐私底线也是和原生推送的根本差异。- 权限请求的时机决定项目生死。绝不在页面加载就要权限,被拒后再无第二次机会。先教育、后请求,内容必须值得打扰。
一句话收尾:Web Push 的技术不难,难在克制。 写出 sendNotification 是半天的事,决定"这条消息到底该不该发"是产品修养——用户把通知权限交给你,是信任,别用废话把它磨光。
Related