#React#TypeScript

Web Push 与 Service Worker:浏览器原生推送

Web Push 让你的网站能像 App 一样推送通知——即使用户没开网页。本文讲清 Web Push 的三件套(VAPID 密钥 + Push API + Service Worker)、订阅/发送/接收流程、权限请求体验、以及"用户没开浏览器也能收到推送"的原理。

本文要点

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

Web Push 与 Service Worker:浏览器原生推送

先问一个被问过无数次的问题:"用户关掉了浏览器标签页,我的网站还能给他发通知吗?"

能。这不是 PWA 的玄学,也不是什么"后台轮询"的黑魔法,而是一套叫 Web Push 的 W3C 标准。它让你的网站拿到和原生 App 几乎一样的推送能力——系统级通知中心、锁屏弹窗、甚至用户彻底退出浏览器后,消息照样能在下次打开时送达。

代价是:这套链路比你想的复杂。它涉及三个独立规范、一套非对称密钥、一个跑在浏览器后台的 Service Worker,以及浏览器厂商各自维护的推送服务(FCM / APNs / Mozilla AutoPush)。任何一个环节理解错了,你就会陷入"本地能跑、上线不响"的经典困境。

这篇文章把这些一次讲透,并给出能直接抄走的前端订阅、服务端发送、Service Worker 接收三段代码。


一、Web Push 到底解决了什么

在 Web Push 之前,网页想做"通知"只有两条路,都不好用:

  1. 页面内的红点 / Toast——用户没打开页面就什么都看不到,本质上是"刷新驱动"的伪通知。
  2. WebSocket 长连接——仍然依赖页面打开,而且你要自己维护推送基础设施(连接管理、断线重连、多端同步)。

Web Push 的本质区别在于:它不依赖你的页面是否打开,也不依赖你的服务器维持和浏览器的长连接。一旦用户订阅,你的服务器只需要把一条消息发给浏览器的推送服务(FCM / APNs / Mozilla),剩下的投递完全是浏览器和操作系统的职责。浏览器收到后唤醒 Service Worker,由 SW 决定怎么 showNotification。

这带来三个产品级的改变:

  • 触达率质变:用户不必停留在你的站点。购物车放弃提醒、价格变动、消息回复,都可以像微信一样主动触达。
  • 离线也能收:消息先到浏览器推送服务,浏览器上线后再投递,你不用关心用户什么时候在线。
  • 电量 / 带宽友好:不需要心跳、不需要轮询,系统级的推送通道远比应用层长连接高效。

但别急着上线。它的工程复杂度也实打实——下面我们一点点拆。


二、三件套:VAPID + Push API + Service Worker

Web Push 是三个独立规范的合奏,任何一个缺席都响不起来。理解它们的分工,是后面所有代码的前提。

1. VAPID:应用身份密钥

VAPID(Voluntary Application Server Identification) 是一套基于非对称加密的身份协议。它解决一个关键问题:浏览器推送服务怎么知道,这个推送请求是你这个网站发的,而不是别人冒充的?

VAPID 给你一对密钥:

  • 私钥(private key):留在你的服务器,绝不外泄,用来给每个推送请求签名。
  • 公钥(public key):会暴露给前端(它本就是公开的),前端订阅时交给浏览器,浏览器转交给推送服务,用来验证后续请求的签名。

这一对密钥一旦更换,所有老订阅都会失效(因为它们绑定的是旧公钥)。所以请像管数据库密码一样管 VAPID 私钥:用环境变量存、进密钥管理服务、纳入备份,但绝不进 Git。

2. Push API:浏览器侧的订阅与接收

Push API 是浏览器暴露给前端的接口,核心就两件事:

  • PushManager.subscribe()——前端调用它创建一个订阅(PushSubscription),拿到一个独一无二的 endpoint URL 和加密密钥。
  • Service Worker 监听 push 事件——推送服务把消息送到浏览器后,浏览器会唤醒 SW 并触发这个事件。

PushSubscription 对象长这样,它是整个链路的"投递地址":

ts
{
  endpoint: "https://fcm.googleapis.com/fcm/send/eqXAp...",
  keys: {
    p256dh: "BLN...=",   // 客户端公钥,用于加密 payload
    auth: "xR..."          // 认证密钥
  }
}

注意 endpoint 是浏览器厂商推送服务的地址(Chrome / Edge 走 FCM,Safari 走 APNs,Firefox 走 Mozilla AutoPush)。你不能选择走哪个,浏览器决定。这也是为什么跨浏览器测试很重要。

3. Service Worker:后台处理器

Service Worker(简称 SW) 是浏览器在后台独立线程运行的脚本,它是收到推送后唯一能"做点事"的地方。

关键点:页面没打开时,SW 依然活着(或被推送服务唤醒)。这就是"用户没开标签页也能收推送"的物理基础。SW 收到 push 事件后,调用 self.registration.showNotification(...) 弹出系统通知——这一步是必须的,Web Push 规范要求每条推送都要有可见通知(所谓 userVisibleOnly: true),否则浏览器会视为违规静默推送。

三件套的协作可以用一句话概括:VAPID 证明你是谁,Push API 负责订阅和投递,Service Worker 负责接收和展示。


三、订阅流程:前端代码

订阅是一次精心编排的四步舞:请求权限 → 注册 SW → 调 subscribe → 把订阅对象发回服务端存库。

ts
// lib/web-push.ts —— 浏览器侧工具函数

/** VAPID 公钥(注意:公钥可以、也应该暴露给前端) */
const VAPID_PUBLIC_KEY = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!;

/** 把 base64url 字符串转成 subscribe 需要的 Uint8Array */
function urlBase64ToUint8Array(base64String: string): Uint8Array {
  const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
  const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
  const raw = atob(base64);
  const output = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; ++i) output[i] = raw.charCodeAt(i);
  return output;
}

/** 入口:请求权限 + 订阅 + 上报后端 */
export async function subscribeToPush(): Promise<PushSubscription | null> {
  // 1. 能力检测:iOS Safari、老浏览器直接拒绝
  if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
    console.warn('[push] 当前浏览器不支持 Web Push');
    return null;
  }

  // 2. 请求权限——注意:这一步应该由用户手势触发,不要页面一加载就调
  const permission = await Notification.requestPermission();
  if (permission !== 'granted') {
    console.info('[push] 用户未授权通知');
    return null;
  }

  // 3. 注册 Service Worker(用 ready 等待它激活)
  const reg = await navigator.serviceWorker.register('/sw.js');
  await navigator.serviceWorker.ready;

  // 4. 如果已经订阅过,直接复用,避免重复创建
  const existing = await reg.pushManager.getSubscription();
  const subscription =
    existing ??
    (await reg.pushManager.subscribe({
      userVisibleOnly: true, // 规范要求:必须显示通知
      applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
    }));

  // 5. 把订阅对象发回服务端持久化(每个用户/每台设备一条)
  await fetch('/api/push/subscribe', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(subscription),
  });

  return subscription;
}

几个容易被忽略的细节:

  • getSubscription() 必须先查。每次都新建会导致同一个用户在库里堆积几十条订阅,推送服务会替你投递几十次,APNs / FCM 还会因此给你打"垃圾推送源"的标签。
  • userVisibleOnly: true 不可省。这是 Chrome 强制的承诺——你保证每条推送都弹通知。Safari 在这点上更严格,违反直接拒绝订阅。
  • applicationServerKey 是 VAPID 公钥,但必须是 Uint8Array 形式,base64 字符串直接传会报错。

订阅对象上报后,服务端要把它和"当前登录用户"绑定存表。一张典型的 PushSubscription 表:userId / endpoint / p256dh / auth / createdAt,以 endpoint 为唯一键(同一设备重订阅时 upsert)。


四、发送流程:服务端代码

服务端做的事看起来简单——"给一个 endpoint 发个 POST"——但里面藏着加密和签名两道工序。手写不现实,用 web-push 这个库,它是事实标准。

ts
// app/api/push/send/route.ts —— Next.js Route Handler
import webPush from 'web-push';
import { NextRequest, NextResponse } from 'next/server';
import { getSubscriptionsByUser } from '@/lib/push-store';

// 一次性配置:告诉 web-push 你的 VAPID 身份
webPush.setVapidDetails(
  'mailto:[email protected]', // 联系邮箱,推送服务出问题时会用到
  process.env.VAPID_PUBLIC_KEY!,
  process.env.VAPID_PRIVATE_KEY!,
);

export async function POST(req: NextRequest) {
  const { userId, title, body, url } = await req.json();
  const subscriptions = await getSubscriptionsByUser(userId);
  if (subscriptions.length === 0) {
    return NextResponse.json({ sent: 0, reason: 'no-subscription' });
  }

  const payload = JSON.stringify({ title, body, url });

  // 并发推送,逐条 catch——单条失败不能拖垮整批
  const results = await Promise.allSettled(
    subscriptions.map((sub) =>
      webPush.sendNotification(
        {
          endpoint: sub.endpoint,
          keys: { p256dh: sub.p256dh, auth: sub.auth },
        },
        payload,
        {
          TTL: 60 * 60 * 24, // 24 小时:用户离线时消息最多保留多久
          urgency: 'normal', // high / normal / low,影响耗电与投递策略
          topic: 'chat-msg', // 同 topic 的消息会折叠,只显示最新一条
        },
      ),
    ),
  );

  // 清理失效订阅(410 Gone / 404)——否则下次还会白白重试
  await Promise.all(
    results
      .map((r, i) => ({ r, sub: subscriptions[i] }))
      .filter(({ r }) => r.status === 'rejected' && isGone(r.reason))
      .map(({ sub }) => deleteSubscription(sub.endpoint)),
  );

  return NextResponse.json({
    sent: results.filter((r) => r.status === 'fulfilled').length,
  });
}

function isGone(err: unknown): boolean {
  return err instanceof webPush.WebPushError && [404, 410].includes(err.statusCode);
}

几点工程经验:

  • TTL 决定离线投递窗口。比如用户关掉电脑三天,设 TTL: 86400 的消息会在他重新上线时投递;设 TTL: 0 则是"现在收不到就丢",适合 IM 已撤回场景。
  • urgency 影响 APNs / FCM 的投递策略。low 时系统可能合并、延迟投递以省电;high 适合重要告警。多数业务场景用 normal 即可。
  • topic 是折叠键。同 topic 的新消息会覆盖未读的旧消息,适合"未读消息数:3 → 4"这类递进通知。
  • 必须清理失效订阅。用户清了站点数据、卸载了浏览器、订阅自然过期,都会让 endpoint 变 410。不清理就是慢性自杀——每条推送都白白重试一堆死链。

web-push 库底层做了三件你不想碰的事:用 p256dh + auth 对 payload 做 aes128gcm 加密、用 VAPID 私钥对请求头签名、按 endpoint 的域名走对应的推送服务协议(FCM 用旧的 web-push 协议,APNs 用 HTTP/2)。手写一遍可以当练手,生产里别造轮子。


五、Service Worker:接收与展示

推送到达后的戏在 SW 里。这一段代码独立成 /public/sw.js(注意:SW 必须由根域提供,不能走打包器的模块解析,所以通常放 public/ 下以原样静态托管)。

js
// public/sw.js
self.addEventListener('install', (e) => self.skipWaiting());
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));

// 核心:push 事件处理
self.addEventListener('push', (event) => {
  let data = { title: '新消息', body: '你有一条新通知', url: '/' };
  try {
    if (event.data) data = event.data.json();
  } catch {
    /* payload 不是 JSON 时退回默认 */
  }

  const options = {
    body: data.body,
    icon: '/icon-192.png',
    badge: '/badge-72.png',
    data: { url: data.url }, // 点击时用
    tag: data.tag, // 同 tag 的通知会合并
    renotify: true, // tag 合并时是否重新震动 / 响铃
    requireInteraction: false, // true = 不自动消失,适合重要告警
    vibrate: [100, 50, 100],
  };

  event.waitUntil(self.registration.showNotification(data.title, options));
});

// 用户点击通知
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,
      });

      // 优先聚焦已打开的同源页面
      for (const client of allClients) {
        if (client.url.includes(targetUrl) && 'focus' in client) {
          return client.focus();
        }
      }

      // 否则新开一个
      if (self.clients.openWindow) {
        return self.clients.openWindow(targetUrl);
      }
    })(),
  );
});

这里有几个"线上才会踩"的坑:

  • event.waitUntil(...) 不能省。浏览器会在事件循环空闲后立刻杀掉 SW,waitUntil 是告诉它"我还有活没干完,等我"。不加的话,通知可能根本不弹。
  • payload 解析要做容错。服务端可能只发了个空 push(叫 "push ping"),event.data 为 null,直接 .json() 会炸。
  • notificationclick 一定要自己处理聚焦 / 打开页面。不写的话,点击通知什么都不发生——用户会觉得通知坏了。
  • iOS / iPadOS Safari 对 SW 通知能力有额外限制(下面坑章节会展开),showNotification 在 iOS 上必须配合已安装到主屏幕的 PWA 使用。

六、权限请求的体验设计

Notification.requestPermission() 是 Web 上少有的"一锤子买卖":用户一旦点了"拒绝",你之后调多少次 requestPermission() 都不会再弹窗,只能让用户手动进浏览器设置改回来——而几乎没人会那么做。

所以默认情况下,你不应该在页面 onload 就弹权限请求。这是新手最常犯的错,转化率极低,还会被 Chrome 标记为"骚扰"。

正确的姿势是分两步:

  1. 先用你自己的 UI 解释价值。在用户做了某个高意愿动作后(比如点了"关注作者"、"加入提醒"、"消息回复被点赞"),弹一个自己画的浮层:"我们想在有人回复你时第一时间通知你,可以吗?"
  2. 用户点了"好的",再调 Notification.requestPermission()。这时候请求是用户主动触发的,带着上下文,授权率会高一个数量级。

一个实用的封装:

tsx
// components/PushOptIn.tsx
'use client';
import { useState } from 'react';
import { subscribeToPush } from '@/lib/web-push';

export function PushOptIn() {
  const [asked, setAsked] = useState(false);

  // 用户先点这个按钮,而不是一进来就弹系统权限
  if (!asked) {
    return (
      <button onClick={() => setAsked(true)}>
        开启消息提醒
      </button>
    );
  }

  return (
    <div className="opt-in-card">
      <p>有人回复你的评论时,我们会在第一时间通知你。</p>
      <button onClick={() => subscribeToPush()}>允许通知</button>
      <button onClick={() => setAsked(false)}>以后再说</button>
    </div>
  );
}

记一句话:系统权限请求应该总是"用户已经点了两次同意"之后的结果。


七、踩坑清单

Web Push 是那种"本地一切正常,上线全炸"的典型,踩坑集中在以下几处。

1. iOS / iPadOS Safari 的特殊门槛

这是被问最多的:Safari 支不支持 Web Push?

答案:16.4+ 支持,但有条件。

  • 必须 iOS / iPadOS 16.4 及以上。
  • 用户必须把站点"添加到主屏幕"(作为 PWA 安装),独立 Safari 标签页里无法订阅推送——这是 Apple 故意的限制。
  • 必须有 manifest.json 且含 display: standalone(或 fullscreen)等 PWA 必备字段。
  • 必须通过 SW 调 showNotification,不支持静默推送。

实操建议:检测到 iOS 但未安装 PWA 时,引导用户"添加到主屏幕",而不是直接 subscribe(会失败)。

2. 必须 HTTPS

SW 只能注册在 HTTPS 站点(或 localhost)下。本地开发用 http://localhost:3000 可以,但一旦部署到任何非 localhost 的 HTTP 环境,SW 直接拒绝注册,Web Push 链路从第一步就断。预发布环境也要上 HTTPS(用自签证书的内部域名或 ngrok 都行)。

3. Service Worker 注册时机与作用域

  • SW 脚本路径决定作用域。/sw.js 默认管辖整个根域;放在 /app/sw.js 则只能管 /app/*。需要根作用域时,要么放根目录,要么在注册时传 { scope: '/' } 并配 Service-Worker-Allowed 响应头。
  • 不要每次进站都强制更新 SW。register('/sw.js') 浏览器会自己按字节比对缓存,重复注册是幂等的。频繁 update() 反而会让你陷入"老 SW 还在处理 push,新 SW 没激活"的尴尬期。
  • SW 改了代码不会立即生效。默认要等所有老标签页关闭、再开新页面才接管。开发时勾上 DevTools → Application → Service Workers → "Update on reload" 可以绕过。

4. payload 大小限制

推送服务对加密后的 payload 有限制(FCM 约 4KB,APNs 4KB,Mozilla 4KB)。别想着塞整篇正文,推一条"有新消息,拉一下"的轻量 payload,正文让前端打开后通过你的 API 拉。

5. 同一用户多设备

用户手机订阅一条、电脑订阅一条,这很正常。推送目标永远是"设备"而非"用户",所以服务端的 PushSubscription 表必须以 endpoint 为粒度,按 userId 一对多管理。给一个用户发推送 = 给他的所有订阅并发发。

一个好消息:Web Push 不依赖 Cookie,FCM / APNs 的投递走的是系统级通道。即使在逐步禁用第三方 Cookie 的 Chrome 里,Web Push 也依然工作。


八、Next.js 实战集成

把上面的碎片拼到一个 Next.js 16 项目里,大致是这样:

环境变量(.env.local):

bash
# 用 web-push 库生成:npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY=BGh...
VAPID_PRIVATE_KEY=Z3I...
# 注意公钥要给前端用,前缀必须是 NEXT_PUBLIC_
NEXT_PUBLIC_VAPID_PUBLIC_KEY=BGh...

目录结构:

text
public/
  sw.js                  # SW 脚本,根作用域
  icon-192.png           # 通知图标
src/
  app/api/push/subscribe/route.ts   # 接收前端上报的 subscription
  app/api/push/send/route.ts        # 触发推送
  lib/web-push.ts                  # 前端订阅工具
  lib/push-store.ts                # subscription 存取(数据库)
  components/PushOptIn.tsx         # 权限引导 UI

接收订阅的路由:

ts
// src/app/api/push/subscribe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { upsertSubscription } from '@/lib/push-store';
import { getSessionUserId } from '@/lib/auth';

export async function POST(req: NextRequest) {
  const userId = await getSessionUserId();
  if (!userId) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });

  const sub = await req.json();
  await upsertSubscription({
    userId,
    endpoint: sub.endpoint,
    p256dh: sub.keys.p256dh,
    auth: sub.keys.auth,
  });

  return NextResponse.json({ ok: true });
}

触发推送的触发点:不要直接暴露 POST /api/push/send 给前端随便调——它应当由业务事件驱动(评论被回复、价格变动定时任务、订单状态变更),在服务端内部调用。给前端暴露的只能是一个"设置提醒"的入口,真正发送永远在你的可信后端。

关于 next/web-push:目前生态里没有官方的 next/web-push 模块(网上有些资料会提到,但多数是第三方封装或对老版本的误传)。主流做法就是直接用 web-push 这个库,它和 Next.js Route Handler 配合没有任何障碍——Next 16 的 Route Handler(app/api/.../route.ts 里 export async function POST)是标准 Web API,web-push 这种纯 Node 库可以直接 import 调用。注意把 setVapidDetails 放在模块顶层或初始化函数里,不要每个请求都重新 set。

部署注意:

  • Next.js 默认会把 /public/sw.js 原样吐到根路径,正合 SW 作用域要求。别在 next.config.ts 里给 /public 加奇怪的 rewrite。
  • 若用边缘函数(Vercel Edge Runtime)部署发送路由,web-push 用到 Node 的 crypto,需要跑在 Node.js runtime(export const runtime = 'nodejs'),Edge Runtime 目前缺部分 API。
  • 生产环境务必校验:VAPID 私钥只在服务端、applicationServerKey 用的是和私钥配对的公钥、SW 文件确实可被根域访问(curl https://yoursite.com/sw.js 能拿到)。

小结

回到最初那个问题——"用户关掉了标签页,还能收到通知吗?" 能。但要敬畏这条链路:

  • Web Push 是三件套的合奏:VAPID 解决"你是谁",Push API 解决"订阅和投递",Service Worker 解决"接收和展示"。三者缺一不可,理解分工比背 API 重要。
  • 订阅代码要幂等、要查重:先 getSubscription() 再决定是否新建,服务端以 endpoint 为唯一键 upsert,否则会堆积死订阅。
  • 发送代码要清理失效订阅:410 / 404 必须从库里删掉,Promise.allSettled + 逐条 catch 是标配;payload 加密 / 签名交给 web-push 库,别造轮子。
  • 权限请求是产品决策不是技术决策:永远在"用户点了两次同意"之后再弹系统权限,本地浮层先解释价值。
  • iOS Safari 是硬骨头:16.4+ 还得"添加到主屏幕",这不是 bug 是 Apple 的策略,检测到就引导安装,别硬上。

最后送一句:Web Push 的复杂度不在任何一段代码,而在它跨了三个规范、三个浏览器厂商、两套操作系统通知体系。 把这条链路在你脑子里完整跑通一次——从私钥签名到 SW 唤醒——你才算真的会用它,而不只是抄对了代码。

Related

相关推荐