从 0 到 1 接入浏览器推送:vibe 项目的 Web Push 代码实战
Web Push 看似半小时能跑通,上线后全是坑:iOS 收不到、VAPID 报 401、订阅表越养越肥。这篇代码续集带你走完完整链路——VAPID 密钥生成与保管、权限申请的最佳时机、Service Worker 推送接收与点击处理、服务端 web-push 实战、iOS 16.4 适配、410 过期订阅清理与排错清单,所有代码均可直接落地。

你的 vibe 项目上线了,用户注册完就消失了——不是产品不行,是用户根本想不起来回来。邮件会进垃圾箱、短信一条几分钱还扰民,而浏览器推送(Web Push)是独立开发者手里最便宜的"召回武器":零短信成本、用户关了页面也能收到、点一下直达页面。
但 Web Push 的坑恰恰在于"看起来简单":前端调两个 API、服务端发一个 POST,似乎半小时就能跑通。真正上线后你会遇到:iOS 用户说收不到、VAPID 配错报 401、订阅越积越多变成僵尸数据、Chrome 莫名其妙弹默认通知。这篇指南是站内《vibe 项目通知系统实战指南》(策略视角:渠道选型、频率控制与退订设计)的代码续集——那篇告诉你"要不要做、怎么做决策",这篇只讲"代码怎么写",从 VAPID 密钥生成到生产排错,一条链路全部落地。
一、先看清链路:Web Push 是四方协作,不是前后端直连
很多人第一次接 Web Push 会下意识地想:"服务端直接调浏览器 API 发通知"。错。Web Push 的标准链路里有四个角色,缺一不可:
- 前端页面 JS:负责申请权限、注册 Service Worker、生成订阅并上报给服务端。它的工作在用户"订阅"那一刻就结束了。
- 推送服务(Push Service):浏览器厂商运营的公网服务——Chrome 走 FCM,Firefox 走 Autopush,Safari 走 APNs。你的服务端把消息 POST 到订阅里的 endpoint,地址就指向这些服务。这是你唯一够不着、也控制不了的一环,出问题只能看状态码猜。
- 应用服务端:你的地盘。用 VAPID 私钥签名、把加密载荷 POST 到 endpoint,并维护订阅库(endpoint + p256dh + auth 三件套)。
- Service Worker:跑在浏览器里的后台脚本。即使用户关了你的页面,只要浏览器还在,推送服务就能唤醒它,由它调用
showNotification把通知展示出来。
理解这张图能帮你避开最大的认知坑:推送的可靠性天花板不在你的代码,而在推送服务和用户设备。你的代码能保证的是——订阅链路正确、签名正确、过期订阅及时清理。做不到 100% 到达率,但能做到"凡是失败的,我都知道为什么"。
二、VAPID 密钥:生成一次,保管好十年
VAPID(RFC 8292)是 Web Push 的身份机制:你的服务端每次推送,都要在请求头里带一个用私钥签名的 JWT,推送服务验签通过才转发。没有 VAPID,大部分推送服务会直接拒收。一句话理解:VAPID 公钥是前端订阅时的"身份证号",私钥是服务端推送时的"印章"。
生成
用 web-push 这个 npm 包自带的 CLI,一行搞定(EC P-256 密钥对,base64url 编码):
npm install -g web-push
web-push generate-vapid-keys
# 输出示例(每次运行都不同,请自己生成,不要抄):
# Public Key:
# BKd3f9xQ2vH7...(约 87 个字符)
# Private Key:
# qW8zLp2...(约 43 个字符)
保管:三条铁律
- 私钥只进服务端环境变量,永远不进前端代码、不进 Git。公钥可以公开(前端订阅时需要),私钥泄露 = 任何人都能冒充你的站点发推送。发现泄露立刻轮换。
- subject 用
mailto:ops@你的域名。这是 RFC 8292 要求的"应用服务器标识",必须是合法的mailto:或https:URL。写错、写成裸邮箱,某些推送服务会直接 401。 - 一对密钥用到底,别没事轮换。订阅是和订阅时的 applicationServerKey(VAPID 公钥)绑定的,换了密钥,存量订阅的推送签名就对不上了——等于逼所有用户重新订阅。非换不可时(比如私钥泄露),正确姿势是:前端每次打开页面时比对本地订阅用的公钥指纹,发现变化就自动
unsubscribe()再重新subscribe();服务端在过渡期保留旧私钥继续为旧订阅签名。
环境变量示例(Node 服务端):
VAPID_PUBLIC_KEY=BKd3f9xQ2vH7...
VAPID_PRIVATE_KEY=qW8zLp2...
VAPID_SUBJECT=mailto:ops@example.com
三、订阅流程:把 requestPermission 放在对的时机
订阅是整条链路里唯一和用户直接打交道的环节,也是授权率差异最大的环节。先看完整时序:
权限三态,denied 是不可逆的
Notification.permission 只有三种值:default(还没问)、granted(允许)、denied(拒绝)。关键事实:一旦用户点了拒绝,你的代码再也弹不出授权框了——requestPermission() 会直接返回 denied,用户必须手动去浏览器设置里改回来。这意味着:每一次弹窗都是一次性机会,浪费了就没了。
所以最佳时机铁律:永远不要在页面加载时直接弹窗。正确做法是两步走——先在页面里放一个你自己的"开启通知"开关/按钮(解释清楚"开启后能收到什么",比如"任务完成时提醒你"),等用户主动点击这个按钮(用户手势),再调用 requestPermission()。实测中,这种"先给理由再要权限"的授权率通常是"一进页面就弹"的数倍。频率控制和退订策略属于策略层设计,见通知系统指南的第 6 节,这里不展开。
完整订阅代码(前端)
// 1. VAPID 公钥是 base64url 编码,必须先转成 Uint8Array(MDN 标准写法)
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
async function enablePush() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
alert('当前浏览器不支持 Web Push');
return;
}
// 2. 先注册 Service Worker(sw.js 必须和页面同源)
const registration = await navigator.serviceWorker.register('/sw.js');
// 3. 在用户手势之后申请权限
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
alert('通知权限被拒绝,请去浏览器设置中手动开启');
return;
}
// 4. 向推送服务订阅。Chrome 里 userVisibleOnly 只允许传 true
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array('你的VAPID公钥'),
});
// 5. 把订阅上报给服务端入库(endpoint + keys.p256dh + keys.auth)
await fetch('/api/push/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription.toJSON()),
});
alert('订阅成功!');
}
// 绑定到你自己的按钮上,而不是页面加载
document.getElementById('btn-enable-push').addEventListener('click', enablePush);
服务端收到上报后,入库三个字段即可:endpoint(推送地址,定长字符串,可做唯一键去重)、keys.p256dh、keys.auth(加密用)。建议再存 userId 和 createdAt,方便按用户推送和日后清理。同一 endpoint 重复上报要幂等(按 endpoint 做 upsert),否则用户每次点按钮都会产生一条僵尸订阅。
四、Service Worker:推送接收与点击处理
sw.js 是整条链路里最容易写错 50% 的文件——因为它跑在独立的 Worker 线程里,没有 DOM、没有 window,调试只能靠 DevTools 的 Application 面板。它的职责只有两个:收到推送时展示通知、用户点击通知时正确跳转。
// sw.js —— 放在站点根目录,保证 scope 覆盖全站
self.addEventListener('push', (event) => {
// 载荷是服务端加密下发的,解密由浏览器自动完成
const data = event.data ? event.data.json() : {};
const title = data.title || '新消息';
// event.waitUntil:告诉浏览器"别杀我,等通知展示完"
event.waitUntil(
self.registration.showNotification(title, {
body: data.body || '',
icon: '/icon-192.png', // 大图标,建议 192x192
badge: '/badge-72.png', // Android 状态栏小图标,单色 72x72 效果最好
tag: data.tag || 'default', // 相同 tag 的通知会互相覆盖,避免通知堆积
renotify: false, // tag 覆盖时是否重新提醒,默认 false
data: { url: data.url || '/' }, // 点击跳转目标,存在 notification.data 里
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close(); // 点击后先关掉通知
const url = (event.notification.data && event.notification.data.url) || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => {
// 如果目标页面已经开着,直接聚焦,不再开新标签页
for (const client of list) {
const path = new URL(client.url).pathname;
if (path === new URL(url, self.location.origin).pathname && 'focus' in client) {
return client.focus();
}
}
return clients.openWindow(url);
})
);
});
三个 90% 的人会踩的坑
- Chrome 铁律:
push事件里必须调用showNotification。如果你收到推送却什么都不展示(想做"静默推送",见第六节),Chrome 会替你弹一条系统默认通知(类似"该网站已在后台更新"),体验极差。这是浏览器强制"用户可见"的要求,不是 bug。 event.waitUntil不是装饰品。Service Worker 是"用完即杀"的,事件回调是异步的;不包waitUntil,浏览器可能在showNotification完成前就终止 Worker,通知直接丢失。- 通知点击必须处理,否则点通知没反应。很多教程只写
push不写notificationclick,结果用户点了通知毫无反馈——这是召回链路的最后一公里断裂。
五、服务端推送:web-push 库实战(推荐)vs 原生实现(对照)
服务端要做的事:用 VAPID 私钥给每次推送签名(JWT,ES256 算法)、用订阅里的 p256dh/auth 把载荷加密(RFC 8291 的 aes128gcm)、POST 到 endpoint。先给结论,再给对照:
| 方案 | 代码量 | 维护成本 | 适合谁 |
|---|---|---|---|
web-push 库(推荐) | 约 20 行 | 跟随 npm 更新,协议细节不用管 | 99% 的 vibe 项目,直接用 |
| 原生实现(手写签名+加密) | 500+ 行 | ECDH 密钥协商、HKDF、AES-GCM 全得自己对,错一个字节就 400 | 想理解协议的学习者;生产环境别用 |
原生实现要手搓的东西包括:用私钥签 ES256 的 VAPID JWT(含 aud=推送服务源、exp 过期时间)、X25519 ECDH 协商出共享密钥、HKDF 派生内容加密密钥、AES-128-GCM 加密载荷并按 RFC 8291 组装 aes128gcm 头。任何一步的字节序错了,推送服务只会回你一个干巴巴的 400,不告诉你哪错了。独立开发者的时间应该花在产品上,不是花在和加密字节较劲上。
web-push 实战代码(Node.js)
const webpush = require('web-push');
// 初始化一次:subject 必须是 mailto: 或 https: URL
webpush.setVapidDetails(
process.env.VAPID_SUBJECT, // mailto:ops@example.com
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
// 给单个订阅推送
async function pushTo(subscription, payload) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload), {
TTL: 3600, // 离线保留 1 小时;TTL: 0 表示"现在送不到就丢弃"
});
} catch (err) {
// 410 / 404:订阅已过期(用户清数据/浏览器回收),删库,见第八节
if (err.statusCode === 410 || err.statusCode === 404) {
await db.pushSubscriptions.delete({ where: { endpoint: subscription.endpoint } });
return 'expired';
}
// 401 / 403:VAPID 配错了,先别删订阅,先修配置
// 413:payload 超过 4096 字节,精简后再发
throw err;
}
}
// 批量推送:allSettled 保证一条失败不影响其他,并发太高会被推送服务限流
async function pushToMany(subscriptions, payload) {
const results = await Promise.allSettled(
subscriptions.map((s) => pushTo(s, payload))
);
const failed = results.filter((r) => r.status === 'rejected');
console.log(`推送 ${results.length} 条,失败 ${failed.length} 条`);
}
几点生产经验:payload 上限 4096 字节(加密前),超了直接 413,所以正文别塞大对象;TTL 按消息时效设——验证码类用 TTL: 0(过时不候),普通通知 3600~86400;批量推送别无脑 Promise.all 全并发,几千条订阅建议分批(每批 50~100)加间隔,避免被推送服务限流。
六、"静默推送"的真相:Chrome 不允许,死心但有替代
很多人的需求是:"我只想悄悄更新个角标/同步个数据,不想打扰用户。" 残酷的真相是:Web Push 在 Chrome 里没有真正的静默推送。第四节提过,push 事件里不调 showNotification,Chrome 会替你弹默认通知——这是写死在浏览器里的"用户可见"强制要求,绕不过。
诚实的替代方案有三条,按推荐度排序:
- 数据消息模式(最推荐):推送载荷里只带
{ id, type }这种最小标识,不带正文;Service Worker 收到后用fetch去服务端拉最新详情再展示。这样既避开了 4KB 上限,又保证用户看到的永远是最新内容(推送在离线队列里躺了半小时,正文可能已经过时了)。 - 接受一条"轻通知":用
tag让同类通知互相覆盖(比如"3 条新评论"只保留一条),配合renotify: false不重复打扰。用户感知弱,但链路简单可靠。 - 后台同步(仅 Chrome/Edge,PWA 限定):
Periodic Background Sync可以让已安装的 PWA 定时唤醒拉数据,实现真静默更新。但它要求用户安装了你的 PWA、浏览器说了算的最小间隔(通常数小时起),且 Safari/Firefox 不支持——只能做渐进增强,不能当主力。
// 数据消息模式:SW 收到 id 后拉取详情再展示
self.addEventListener('push', (event) => {
const { id, type } = event.data.json();
event.waitUntil(
fetch(`/api/notifications/${id}`)
.then((r) => r.json())
.then((detail) => self.registration.showNotification(detail.title, {
body: detail.body,
tag: type, // 同类合并
data: { url: detail.url },
}))
);
});
七、iOS 16.4+:能用了,但有条件
2023 年之前,"iOS 不支持 Web Push"是铁律;iOS 16.4 之后松动了,但条件苛刻,必须逐条满足,缺一不可:
- 系统版本 iOS / iPadOS 16.4 及以上;
- 你的网站必须是一个合格的 PWA:有 manifest、
display为standalone、有 Service Worker; - 用户必须把你的网站添加到主屏幕,从主屏幕图标打开——在 Safari 里直接打开网页、点订阅是收不到推送的。
macOS 端宽松得多:macOS Ventura + Safari 16 起,Safari 里直接订阅就能收。所以同一套代码,桌面 Mac 能用、iPhone 上必须走"添加到主屏幕"流程——这个差异必须在产品层面向用户讲清楚,否则你的客服(就是你自己)会被"我开了通知为什么收不到"淹没。
检测 + 降级代码
function pushCapability() {
const isStandalone =
window.matchMedia('(display-mode: standalone)').matches ||
window.navigator.standalone === true; // iOS 老式检测
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
if (isIOS && !isStandalone) {
// iPhone 上用 Safari 直接打开:引导用户"分享 → 添加到主屏幕"
return { ok: false, reason: 'ios-not-installed' };
}
if (!('PushManager' in window)) return { ok: false, reason: 'unsupported' };
return { ok: true };
}
// 调用处:按 reason 展示不同的引导文案,而不是一个干巴巴的"不支持"
const cap = pushCapability();
if (!cap.ok && cap.reason === 'ios-not-installed') {
showGuide('iPhone 用户请先"添加到主屏幕",再开启推送');
}
降级策略一句话:iOS 上收不到推送的用户,用应用内消息中心 + 邮件兜底("渠道选型决策表"见通知系统指南第 2 节)。Web Push 是召回渠道之一,不是全部——别把鸡蛋放在一个篮子里。
八、订阅过期与 410 Gone 清理:不删僵尸订阅,库会越养越肥
订阅不是永久有效的。用户清浏览器数据、卸载 PWA、浏览器回收长期不用的订阅,都会让 endpoint 失效。失效的订阅有个标准信号:推送返回 410 Gone(或 404)。RFC 8030 规定,收到 410 就意味着"这个订阅永远不会再有效了",正确动作只有一个:删。
// 推送 worker 里的清理逻辑(接第五节的 pushTo)
async function pushTo(subscription, payload) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload), { TTL: 3600 });
await db.pushSubscriptions.update({
where: { endpoint: subscription.endpoint },
data: { lastSuccessAt: new Date(), failCount: 0 },
});
} catch (err) {
if (err.statusCode === 410 || err.statusCode === 404) {
// 永久失效:删库,不重试
await db.pushSubscriptions.delete({ where: { endpoint: subscription.endpoint } });
return 'expired';
}
// 其他错误(网络抖动/限流):记 failCount,连续失败 N 次再人工看
await db.pushSubscriptions.update({
where: { endpoint: subscription.endpoint },
data: { failCount: { increment: 1 } },
});
throw err;
}
}
清理策略建议"懒清理为主、巡检为辅":每次推送时遇到 410/404 就地删除(零额外成本);再加一个每月跑一次的巡检任务,删掉 failCount 连续超标或 lastSuccessAt 超过 90 天的订阅。一个 10 万订阅的库如果不清理,一年后可能一半是僵尸——每次全量推送都在给空气烧钱(烧的是推送服务的限流配额和你的服务器时间)。
还有一种"软过期":用户没删订阅,但连续几十次推送都 fulfilled 却从不点击。这不是技术问题,是内容问题——说明你的通知对他没价值,该做的是降频或引导退订,而不是继续轰炸。判断"推什么、推多频"的策略,同样见通知系统指南。
九、本地联调与排错清单
先说环境:Service Worker 要求安全上下文(Secure Context),localhost 算安全上下文,所以本机 http://localhost:3000 联调完全没问题。但如果你要在手机上联调(比如测 iOS),局域网 IP 的 http://192.168.x.x 是不行的——用 mkcert 签个本地证书配 HTTPS,或者用 Cloudflare Tunnel 这类工具把本地服务暴露成公网 HTTPS 域名。
最高效的定位工具是 Chrome DevTools:Application → Service Workers → Push,可以直接往你的 SW 塞一条模拟推送,绕过"服务端→推送服务"整段链路。如果模拟推送能弹出、真实推送不行,问题一定在服务端签名或订阅数据;如果模拟推送都不弹,问题在 SW 代码或权限——二分法一步到位。
排错时按上图从上往下查,90% 的问题落在这张对照表里:
| 症状 | 最可能的原因 | 修法 |
|---|---|---|
| 点了订阅按钮没反应 / 授权框不弹 | requestPermission 不在用户手势里调用,或之前已拒绝(denied) | 绑定到按钮点击事件;denied 只能引导用户去浏览器设置手动开 |
register('/sw.js') 报错 | 非 HTTPS(且非 localhost)、sw.js 跨域、scope 路径不对 | 本机用 localhost;sw.js 放站点根目录同源 |
subscribe() 报 InvalidAccessError | VAPID 公钥没转 Uint8Array,或传成了私钥 | 用 urlBase64ToUint8Array 转公钥 |
| 服务端返回 401 / 403 | VAPID 私钥错、subject 格式错(非 mailto:/https:) | 核对环境变量;重新 generate-vapid-keys |
| 服务端返回 410 / 404 | 订阅过期 | 删库(见第八节),不是 bug 是正常现象 |
| 服务端返回 413 | payload 超 4096 字节 | 改用数据消息模式(第六节) |
| 推送成功但用户没收到,也没报错 | SW 的 push 监听没注册/没包 waitUntil;或系统勿扰模式 | DevTools 模拟推送二分定位;检查系统通知设置 |
| iPhone 收不到,Android 正常 | 没添加到主屏幕,或 iOS 版本低于 16.4 | 引导"添加到主屏幕";低版本走邮件兜底 |
十、上线前检查清单(复制即用)
- ☐ VAPID 私钥只在服务端环境变量,前端代码/Git 历史里搜不到它
- ☐
subject是合法的mailto:地址 - ☐
requestPermission只在用户点击"开启通知"按钮后调用,页面加载不弹窗 - ☐
denied状态有引导文案(告诉用户去浏览器设置开),而不是反复弹窗 - ☐
sw.js同源、scope 覆盖全站,push和notificationclick两个监听都写了 - ☐
push回调包了event.waitUntil,且一定调了showNotification - ☐ 通知带
tag防堆积,点击能正确聚焦/打开目标页 - ☐ 订阅上报按
endpoint做 upsert,不产生重复 - ☐ 推送 catch 里处理了 410/404 删订阅、401/403 告警、413 精简
- ☐ iOS 用户有"添加到主屏幕"引导 + 邮件兜底
- ☐ 批量推送用
allSettled+ 分批,不会一条失败拖垮全部
结语:Web Push 是"订阅资产",不是"发送功能"
回头看这条链路,最贵的部分不是代码——20 行服务端代码一下午就写完了。最贵的是订阅资产:每一个 granted 背后都是一个真实用户的一次信任(他本可以点拒绝),每一条僵尸订阅都是你某次没处理 410 留下的技术债。把 VAPID 保管好、把授权时机放对、把过期订阅清干净,这三件事做到了,Web Push 就是你 vibe 项目里 ROI 最高的召回渠道。
最后再划一次边界:本文只解决"Web Push 这一条链路的代码落地"。至于"哪些消息值得推、一天推几次、退订页怎么做"——那是策略问题,请回看《vibe 项目通知系统实战指南》。代码让你能发出通知,策略决定用户愿不愿意继续收。
评论 (0)
相关文章

导入是脏数据入口,导出是数据主权出口。本指南讲透导入三段式(暂存、校验预览、确认导入)、五类校验引擎、幂等 upsert、流式导出、异步队列加签名链接,以及十条反模式清单,附可直接复制的 TypeScript 代码。

备份回答的是数据丢没丢,灾难恢复回答的是丢了之后多久能回来、回来多少。这篇实战指南覆盖 RPO/RTO 定级、五种故障场景矩阵、Postgres/Supabase/Neon/SQLite 四套可直接抄的 PITR 方案、每周自动恢复演练脚本、一页纸 runbook 模板与降级开关。

夏令时切换日、跨时区显示错乱、重复规则写崩——预约功能看起来简单,坑却最深。这篇指南讲透 UTC 存储铁律、IANA 时区库、RRULE、冲突检测事务锁,以及 12 个必测用例,帮你交付经得起夏令时考验的预约系统。