Web Push 的实现横跨 VAPID 密钥、Service Worker、加密订阅对象、推送服务中转四个层次,任何一层理解不到位都会踩坑。这篇文章完整记录在 Next.js 站点上为 iOS Web Clip 接入推送通知的全过程,以及调试中遇到的每一个真实问题。
上一篇《打磨 iOS Web Clip 体验(一):原生质感的下拉刷新》解决了 standalone 模式下刷新的问题。这篇继续在同一个场景里往前走:让网站在用户不主动打开的情况下,也能把新内容送到锁屏。
在动手之前,先把整条链路搞清楚。Web Push(Chen 注:Web Push 是 W3C 标准协议(Push API + Notifications API),允许服务器在用户没打开网页的情况下向其设备发送消息。与原生 App 推送不同,它不需要上架应用商店,也不依赖厂商 SDK——浏览器本身充当消息通道。) 涉及四个角色:
endpoint、p256dh、auth 的对象。endpoint 是投递地址(推送服务分配),p256dh 和 auth 是端对端加密的密钥材料——消息在你的服务器上加密,只有目标设备的浏览器能解密。showNotification()。四个层次,任何一个理解不到位都会踩坑。后面每个坑都会对应到具体的层次。
VAPID 密钥对只生成一次,之后固定使用:
npx web-push generate-vapid-keys输出两个 base64url 编码的密钥:
Public Key: BLVRrLpE4qLTv8DMBtMjcGM...
Private Key: ApAtxUplrP4P_4w6qx6-MXD...公钥:浏览器订阅时需要它来向推送服务注册,服务器发推送时也要带上它做身份标识。它是公开信息,直接内联在客户端代码里没有问题。
私钥:只存在服务器端环境变量里,永远不能出现在客户端 bundle 中。
一个容易犯的错误:在 Next.js 里,NEXT_PUBLIC_ 前缀的变量会被打进客户端 bundle——公钥用 NEXT_PUBLIC_ 没问题,但私钥加了 NEXT_PUBLIC_ 就直接泄露了。
另一个实际踩到的坑:NEXT_PUBLIC_ 变量在构建时被内联,如果 dev server 是在写入 .env.local 之前启动的,这次构建就读不到这个变量,结果运行时 publicKey 是 undefined,订阅静默失败。最终的做法是把公钥直接硬编码进客户端组件——它本来就是公开的。
Service Worker 是一个独立于页面生命周期的 Worker 脚本,注册后持续在后台运行,即使页面没打开也能接收推送事件。
self.addEventListener("push", (event) => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
payload = { title: "新消息", body: event.data.text() };
}
const { title,
几个关键点:
event.waitUntil() 是必须的。Service Worker 的生命周期很短,事件处理完就可能被终止。waitUntil 接收一个 Promise,告诉运行时「在这个 Promise resolve 之前不要结束我」,确保 showNotification 有机会执行完。
notificationclick 里用 clients.matchAll 检查是否已有对应页面打开——有就 focus,没有就 openWindow。iOS 上这个逻辑很重要:如果不检查直接 openWindow,每次点通知都会开一个新标签。
badge 是 Android 上状态栏里的小图标,iOS 目前忽略这个字段,但加上也没有副作用。
放在 public/sw.js 是刻意的。Next.js 的 App Router 会对 src/ 下的文件做代码分割和 hash 处理,而 Service Worker 必须有固定的 URL(用于注册和作用域判定),所以只能放在 public/ 下静态伺服。
并非所有场景都支持 Web Push。在初始化时先做检测:
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
setState("unsupported");
return;
}PushManagerPushManager——Web Push 在 iOS 上只对添加到主屏幕的 Web App 开放"unsupported" 时直接 return null,不渲染任何 UIserviceWorker.ready 的坑初始化时需要读取当前订阅状态:
const timeout = setTimeout(() => setState("unsubscribed"), 2000);
navigator.serviceWorker.ready
.then((reg) => reg.pushManager.getSubscription())
.then((sub) => setState(sub ? "subscribed" : "unsubscribed"))
.catch(() => setState("unsubscribed"))
navigator.serviceWorker.ready 返回一个永不 reject 的 Promise,在 Service Worker 激活后 resolve。问题在于:iOS PWA 首次加载时,如果 sw.js 还没注册过,ready 会一直 pending,而按钮的初始状态是 "loading" 且 disabled。结果就是用户看到铃铛图标,点了没有任何反应。
解法是加 2 秒超时兜底:如果 ready 超时没有响应,就把状态设为 "unsubscribed",让按钮变为可交互。
这是 iOS 最严格的一条限制。Notification.requestPermission() 必须在用户手势(tap)的同步调用栈里发起,每增加一个 await 都会把调用推离手势上下文,iOS 可能认为这不是用户主动触发的,直接静默拒绝。
订阅流程的顺序因此很关键:
async function subscribe() {
setState("loading");
try {
// 权限请求必须第一个 await,紧跟手势
const permission = await Notification.requestPermission();
if (permission !== "granted") { setState("unsubscribed"); return; }
const reg = await navigator.serviceWorker.register("/sw.js");
await navigator.serviceWorker.ready;
注意 userVisibleOnly: true 是强制要求——iOS 不允许静默推送(收到推送但不显示通知),这个字段必须为 true,否则订阅会被拒绝。
pushManager.subscribe 的 applicationServerKey 要求 Uint8Array,而 VAPID 公钥是 base64url 编码的字符串,需要手动转换:
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g
base64url 用 - 和 _ 替换了标准 base64 的 + 和 /,同时去掉了 = 填充。atob 只接受标准 base64,所以需要先还原。
订阅对象包含 endpoint(推送地址)和加密密钥,存在 Upstash Redis 里:
const SUBSCRIPTIONS_KEY = "push:subscriptions";
export async function saveSubscription(sub: PushSubscription) {
await redis.hset(SUBSCRIPTIONS_KEY, {
[sub.endpoint]: JSON.stringify(sub),
});
}用 Hash 而不是 List,原因是 endpoint 本身就是唯一标识——同一个用户在同一设备上重复订阅,endpoint 相同,hset 会覆盖而不是追加重复记录。如果用 List,就需要额外的去重逻辑。
Upstash 的 Redis SDK 有一个行为要注意:hgetall 返回值时,如果存储的是 JSON 字符串,SDK 会自动尝试把它解析为对象。结果就是你存进去的是 JSON.stringify(sub),取出来的已经是一个对象,不是字符串。
如果代码里无脑 JSON.parse(v),就会执行 JSON.parse("[object Object]"),抛出 SyntaxError。正确的处理:
export async function getAllSubscriptions(): Promise<PushSubscription[]> {
const map = await redis.hgetall<Record<string, PushSubscription | string>>(SUBSCRIPTIONS_KEY);
if (!map) return [];
return Object.values(map).map((v) =>
typeof
先判断类型,如果已经是对象就直接用,如果还是字符串就解析。这个坑在本地开发时不会出现(本地用真实 Redis 不做自动反序列化),部署到 Vercel 后才暴露。
export async function sendPushToAll(payload: PushPayload) {
initVapid();
const subs = await getAllSubscriptions();
const expired: string[] = [];
await Promise.allSettled(
subs.map(async (sub) => {
try {
await
Promise.allSettled 而不是 Promise.all:向多个订阅发推送时,单个失败不应该中断其余的发送。
410 / 404 处理是关键。推送服务返回 410 Gone 表示该订阅已经失效(用户卸载了 App,或者在系统设置里取消了通知权限),404 同理。这两种情况下继续向这个 endpoint 发推送是无意义的,应该立即从存储里清除,避免订阅表越积越脏。
实现完成后,有几个 iOS 特有的限制(Chen 注:以下限制均为 iOS 系统层面的强制约束,无法通过代码绕过。在设计推送策略前需要对此有清醒预期。)需要明确知道:
没有声音、没有震动:iOS 上 Web Push 是静默送达的,通知只会出现在通知中心和锁屏,不会触发铃声和震动。这个限制无法绕过。
只对主屏幕 Web App 开放:在普通 Safari 标签页里,PushManager 根本不存在,连弹权限弹窗的机会都没有。
EU 地区例外:iOS 17.4 起,欧盟地区的 Web App 被强制在 Safari 标签页内打开(Apple 应对 DMA 法规),这导致 PushManager 不可用,推送功能在欧盟失效。
userVisibleOnly 强制为 true:不支持后台静默数据推送,每次推送必须展示一条通知给用户看到。
这些限制使得 iOS Web Push 更适合低频的内容更新通知(新文章、重要公告),而不适合即时消息类场景。
触发推送的管理端 API 通过 Authorization: Bearer <secret> 鉴权。本站域名 whchen.dev 会 308 重定向到 www.whchen.dev。
curl 默认跟随重定向,但跨域重定向时会自动丢弃 Authorization header(RFC 规范行为(Chen 注:RFC 9110(HTTP Semantics)第 15.4 节规定:客户端跟随重定向时,如果目标地址与原始地址的 host 不同,必须移除包含认证信息的 header(如 Authorization、Cookie),防止凭证被无意间发送到非预期的第三方服务器。),防止凭证泄露到意外的目标)。结果就是请求到了但返回 401:
# 错误:会丢失 Authorization
curl -L -X POST https://whchen.dev/api/admin/push ...
# 正确:直接请求 www 子域
curl -X POST https://www.whchen.dev/api/admin/push \
-H 'Authorization: Bearer <secret>' \
-H 'Content-Type: application/json' \
-d '{"title":"...", "body":"..."}'这类问题在 Postman 和浏览器 fetch 里不会出现(fetch 的 redirect 默认是 follow,但同样会丢 Authorization),实际部署时最好统一用最终域名。
最终落地的实现横跨五个文件:
| 文件 | 职责 |
|---|---|
public/sw.js | Service Worker,接收 push 事件,展示通知,处理通知点击 |
src/lib/push.ts | 服务端工具库:VAPID 初始化、订阅存取、推送发送、历史记录 |
src/app/api/push/subscribe/route.ts | 客户端订阅注册(POST)/ 取消(DELETE) |
src/app/api/admin/push/route.ts | 管理员触发推送(POST)/ 查询推送历史(GET) |
src/components/ui/PushSubscribeButton.tsx | 铃铛按钮组件 + 订阅状态管理 hook |
数据流:
这篇文章和上一篇《打磨 iOS Web Clip 体验(一):原生质感的下拉刷新》共同构成了本站 iOS Web Clip 体验建设的两个方向:一个解决「站内交互的连续性」,另一个解决「站外触达的可能性」。两者都依赖 iOS standalone 模式的特有能力,也都绕不开它的限制。