面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
打磨 iOS Web Clip 体验(二):接入 Web Push 通知

打磨 iOS Web Clip 体验(二):接入 Web Push 通知

2026年6月27日2,5178分钟
✦AI 生成摘要

Web Push 的实现横跨 VAPID 密钥、Service Worker、加密订阅对象、推送服务中转四个层次,任何一层理解不到位都会踩坑。这篇文章完整记录在 Next.js 站点上为 iOS Web Clip 接入推送通知的全过程,以及调试中遇到的每一个真实问题。


目录
  • 1. Web Push 的完整链路
  • 2. VAPID 密钥
  • 3. Service Worker
  • 4. 客户端订阅
  • 4.1 检测支持
  • 4.2 serviceWorker.ready 的坑
  • 4.3 权限请求必须紧跟手势
  • 4.4 VAPID 公钥的格式转换
  • 5. 服务端:存储与发送
  • 5.1 存储订阅
  • 5.2 Upstash 的自动反序列化坑
  • 5.3 发送推送
  • 6. iOS 的限制清单
  • 7. 重定向吞掉 Authorization header
  • 8. 整体架构回顾
目录
  • 1. Web Push 的完整链路
  • 2. VAPID 密钥
  • 3. Service Worker
  • 4. 客户端订阅
  • 4.1 检测支持
  • 4.2 serviceWorker.ready 的坑
  • 4.3 权限请求必须紧跟手势
  • 4.4 VAPID 公钥的格式转换
  • 5. 服务端:存储与发送
  • 5.1 存储订阅
  • 5.2 Upstash 的自动反序列化坑
  • 5.3 发送推送
  • 6. iOS 的限制清单
  • 7. 重定向吞掉 Authorization header
  • 8. 整体架构回顾
目录
  1. 1. Web Push 的完整链路
  2. 2. VAPID 密钥
  3. 3. Service Worker
  4. 4. 客户端订阅
  5. 4.1 检测支持
  6. 4.2 serviceWorker.ready 的坑
  7. 4.3 权限请求必须紧跟手势
  8. 4.4 VAPID 公钥的格式转换
  9. 5. 服务端:存储与发送
  10. 5.1 存储订阅
  11. 5.2 Upstash 的自动反序列化坑
  12. 5.3 发送推送
  13. 6. iOS 的限制清单
  14. 7. 重定向吞掉 Authorization header
  15. 8. 整体架构回顾
PWA前端
相关文章
  • 01
    打磨 iOS Web Clip 体验(三):一次 Service Worker 卡死排查2026/06
  • 02
    打磨 iOS Web Clip 体验(一):原生质感的下拉刷新2026/05
  • 03
    Prefetch 的完整图景2026/07
← 上一篇从阅读到知识(三):孤岛与碰撞
下一篇 →打磨 iOS Web Clip 体验(三):一次 Service Worker 卡死排查

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

上一篇《打磨 iOS Web Clip 体验(一):原生质感的下拉刷新》解决了 standalone 模式下刷新的问题。这篇继续在同一个场景里往前走:让网站在用户不主动打开的情况下,也能把新内容送到锁屏。


1. Web Push 的完整链路

在动手之前,先把整条链路搞清楚。Web Push(Chen 注:Web Push 是 W3C 标准协议(Push API + Notifications API),允许服务器在用户没打开网页的情况下向其设备发送消息。与原生 App 推送不同,它不需要上架应用商店,也不依赖厂商 SDK——浏览器本身充当消息通道。) 涉及四个角色:

  • VAPID(Chen 注:Voluntary Application Server Identification。用非对称密钥对推送请求签名,让推送服务(APNs / FCM)能验证消息确实来自你的服务器,防止任意第三方冒充你向你的用户发推送。私钥只存服务端,公钥可以公开。):你的服务器用私钥对推送请求签名,推送服务用公钥验证,确认消息确实来自你而不是仿冒者。
  • 推送服务:Apple 和 Google 各自维护的推送基础设施。你的服务器不直接连接用户设备,而是把消息投递给推送服务,再由它负责送达。这意味着即使用户的 App 没打开,消息也能通过系统通道触达。
  • 订阅对象(PushSubscription):用户同意接收通知后,浏览器向推送服务注册,拿到一个包含 endpoint、p256dh、auth 的对象。endpoint 是投递地址(推送服务分配),p256dh 和 auth 是端对端加密的密钥材料——消息在你的服务器上加密,只有目标设备的浏览器能解密。
  • Service Worker:在后台常驻的脚本,负责接收 push 事件并调用 showNotification()。

四个层次,任何一个理解不到位都会踩坑。后面每个坑都会对应到具体的层次。


2. VAPID 密钥

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,订阅静默失败。最终的做法是把公钥直接硬编码进客户端组件——它本来就是公开的。


3. Service Worker

Service Worker 是一个独立于页面生命周期的 Worker 脚本,注册后持续在后台运行,即使页面没打开也能接收推送事件。

public/sw.js
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/ 下静态伺服。


4. 客户端订阅

4.1 检测支持

并非所有场景都支持 Web Push。在初始化时先做检测:

if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
  setState("unsupported");
  return;
}
  • 普通 Safari 标签页里,iOS 16.3 及以下没有 PushManager
  • iOS 16.4+ 的普通 Safari 也没有 PushManager——Web Push 在 iOS 上只对添加到主屏幕的 Web App 开放
  • "unsupported" 时直接 return null,不渲染任何 UI
Web Push 在 iOS 上的准入门槛高于其他平台,是 Apple 有意为之的设计。(Chen 注:Apple 认为推送通知是一种「特权」,只有用户主动将网站安装到主屏幕、表达出持续使用意愿之后,才有资格接收推送。这也是为什么铃铛图标只在 standalone 模式下出现。)

4.2 serviceWorker.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",让按钮变为可交互。

4.3 权限请求必须紧跟手势

这是 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,否则订阅会被拒绝。

4.4 VAPID 公钥的格式转换

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,所以需要先还原。


5. 服务端:存储与发送

5.1 存储订阅

订阅对象包含 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,就需要额外的去重逻辑。

5.2 Upstash 的自动反序列化坑

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 后才暴露。

5.3 发送推送

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 发推送是无意义的,应该立即从存储里清除,避免订阅表越积越脏。


6. iOS 的限制清单

实现完成后,有几个 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 更适合低频的内容更新通知(新文章、重要公告),而不适合即时消息类场景。


7. 重定向吞掉 Authorization header

触发推送的管理端 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),实际部署时最好统一用最终域名。


8. 整体架构回顾

最终落地的实现横跨五个文件:

文件职责
public/sw.jsService 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 模式的特有能力,也都绕不开它的限制。

body
,
url
,
icon
}
=
payload;
event.waitUntil(
self.registration.showNotification(title ?? "whchen.dev", {
body: body ?? "",
icon: icon ?? "/apple-touch-icon.png",
badge: "/favicon-32x32.png",
data: { url: url ?? "https://whchen.dev" },
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data?.url ?? "https://whchen.dev";
event.waitUntil(
clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((windowClients) => {
const existing = windowClients.find((c) => c.url === url && "focus" in c);
if (existing) return existing.focus();
return clients.openWindow(url);
})
);
});
.finally(() => clearTimeout(timeout));
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
const subJson = sub.toJSON() as { endpoint: string; keys: { p256dh: string; auth: string } };
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subJson),
});
setState("subscribed");
} catch {
setState("error");
}
}
,
"/"
);
const rawData = atob(base64);
return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
}
v
===
"string"
?
JSON
.
parse
(v)
as
PushSubscription
:
v
as
PushSubscription
);
}
webpush.
sendNotification
(
sub as Parameters<typeof webpush.sendNotification>[0],
JSON.stringify(payload)
);
} catch (err: unknown) {
const statusCode = (err as { statusCode?: number })?.statusCode;
if (statusCode === 410 || statusCode === 404) {
expired.push(sub.endpoint);
}
}
})
);
if (expired.length > 0) {
await Promise.all(expired.map(removeSubscription));
}
return { sent: subs.length, expired: expired.length };
}