#React#TypeScript

Web Push 通知:从订阅到推送

想给 Web 用户发推送通知,不用 app 也能做到——靠 Web Push API + Service Worker + Push 服务。本文讲清 Web Push 的完整链路(订阅→服务端推送→SW 展示)、VAPID 密钥、Notification 权限与体验,以及 iOS Safari 16.4+ 也支持了。

本文要点

  • 主题聚焦:React、TypeScript
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年4月18日·更新于 2026年7月23日

做过用户留存的同学大概都被同一件事折磨过:怎么在用户离开网站之后还能触达他。邮件太慢、短信太贵、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 不需要。

它的底层依赖三样东西:

  1. Push 服务(Push Service):每个浏览器厂商跑一个中转服务(Chrome 用 FCM、Firefox 用 Mozilla 自己的、Safari 用 APNs)。你的服务端把消息发给它,它负责把消息投递到对应设备。
  2. Service Worker:一个常驻(按需唤醒)在浏览器后台的 JS 脚本,用来在消息到达时被唤醒、展示通知。
  3. Notification API:真正把通知画到系统通知中心的能力。

合起来,Web 给了你一条和原生 App 几乎等价的通知通道,但用户全程不用装任何东西——授权、订阅、接收,全在浏览器里完成。对于工具型站点、内容社区、后台管理系统,这是留存利器。

二、完整链路:七步看清推送是怎么跑通的

很多人能写出一个 showNotification,但讲不清整条链路。我把它拆成七步,顺着数据流走一遍:

  1. 用户授权:Notification.requestPermission() 弹窗,用户点"允许"。
  2. 注册 Service Worker:页面里 navigator.serviceWorker.register('sw.js'),拿到 registration。
  3. 订阅 Push:用 registration 调 pushManager.subscribe(),拿到一个 PushSubscription(里面有个 endpoint URL + 一对密钥)。
  4. 上传订阅:把这个 subscription 发给你自己的后端,存进数据库(每个用户一条)。
  5. 服务端推送:后端用 VAPID 私钥签名 + 用 subscription 的公钥加密 payload,POST 到那个 endpoint URL。
  6. Push 服务中转:浏览器厂商的 Push 服务收到请求,验证签名、解密校验,把消息下发给对应设备的浏览器。
  7. 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 库一行就出来:

js
// 只需运行一次,把结果存到环境变量里,长期使用
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。

ts
// 客户端代码 —— 注意:'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 长这样,把它整体存到后端:
ts
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 库把这些全包了:

ts
// 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 事件。展示通知和点击处理都写在这里:

js
// 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()。那是把留存机会一次性梭哈。正确的姿势:

  1. 先教育,再请求。用自己的 UI(一个卡片、一个按钮)先讲清楚"我们会在什么场景下给你推什么通知",让用户主动点"开启通知"按钮。
  2. 在用户点了按钮之后,才调 requestPermission()。浏览器要求权限请求由用户手势触发,这正好对上了。
  3. 被拒绝就别追问。检测到 denied,给一次"去设置开启"的引导链接,然后闭嘴。
  4. 内容要有意义。推"我们更新了隐私政策"这种废话,用户第一次就会关掉通知。把推送当成稀缺资源,只在"对你真正有价值"的时刻发。

有条件再加静默时段:用户配置"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

相关推荐