返回探索
指南VibeFix 编辑部更新于 2026年10月14日

从 0 到 1 接入浏览器推送:vibe 项目的 Web Push 代码实战

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

开发者站在代码与浏览器窗口前的扁平风格插画,象征 Web Push 代码实战

你的 vibe 项目上线了,用户注册完就消失了——不是产品不行,是用户根本想不起来回来。邮件会进垃圾箱、短信一条几分钱还扰民,而浏览器推送(Web Push)是独立开发者手里最便宜的"召回武器":零短信成本、用户关了页面也能收到、点一下直达页面。

但 Web Push 的坑恰恰在于"看起来简单":前端调两个 API、服务端发一个 POST,似乎半小时就能跑通。真正上线后你会遇到:iOS 用户说收不到、VAPID 配错报 401、订阅越积越多变成僵尸数据、Chrome 莫名其妙弹默认通知。这篇指南是站内《vibe 项目通知系统实战指南》(策略视角:渠道选型、频率控制与退订设计)的代码续集——那篇告诉你"要不要做、怎么做决策",这篇只讲"代码怎么写",从 VAPID 密钥生成到生产排错,一条链路全部落地。

一、先看清链路:Web Push 是四方协作,不是前后端直连

很多人第一次接 Web Push 会下意识地想:"服务端直接调浏览器 API 发通知"。错。Web Push 的标准链路里有四个角色,缺一不可:

Web Push 推送链路架构图:浏览器、推送服务、应用服务端、Service Worker 四方协作关系
  • 前端页面 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 个字符)

保管:三条铁律

  1. 私钥只进服务端环境变量,永远不进前端代码、不进 Git。公钥可以公开(前端订阅时需要),私钥泄露 = 任何人都能冒充你的站点发推送。发现泄露立刻轮换。
  2. subject 用 mailto:ops@你的域名。这是 RFC 8292 要求的"应用服务器标识",必须是合法的 mailto: 或 https: URL。写错、写成裸邮箱,某些推送服务会直接 401。
  3. 一对密钥用到底,别没事轮换。订阅是和订阅时的 applicationServerKey(VAPID 公钥)绑定的,换了密钥,存量订阅的推送签名就对不上了——等于逼所有用户重新订阅。非换不可时(比如私钥泄露),正确姿势是:前端每次打开页面时比对本地订阅用的公钥指纹,发现变化就自动 unsubscribe() 再重新 subscribe();服务端在过渡期保留旧私钥继续为旧订阅签名。

环境变量示例(Node 服务端):

VAPID_PUBLIC_KEY=BKd3f9xQ2vH7...
VAPID_PRIVATE_KEY=qW8zLp2...
VAPID_SUBJECT=mailto:ops@example.com

三、订阅流程:把 requestPermission 放在对的时机

订阅是整条链路里唯一和用户直接打交道的环节,也是授权率差异最大的环节。先看完整时序:

Web Push 订阅流程时序图:用户点击、权限申请、Service Worker 注册、订阅上报入库共 8 步

权限三态,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% 的人会踩的坑

  1. Chrome 铁律:push 事件里必须调用 showNotification。如果你收到推送却什么都不展示(想做"静默推送",见第六节),Chrome 会替你弹一条系统默认通知(类似"该网站已在后台更新"),体验极差。这是浏览器强制"用户可见"的要求,不是 bug。
  2. event.waitUntil 不是装饰品。Service Worker 是"用完即杀"的,事件回调是异步的;不包 waitUntil,浏览器可能在 showNotification 完成前就终止 Worker,通知直接丢失。
  3. 通知点击必须处理,否则点通知没反应。很多教程只写 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 会替你弹默认通知——这是写死在浏览器里的"用户可见"强制要求,绕不过。

诚实的替代方案有三条,按推荐度排序:

  1. 数据消息模式(最推荐):推送载荷里只带 { id, type } 这种最小标识,不带正文;Service Worker 收到后用 fetch 去服务端拉最新详情再展示。这样既避开了 4KB 上限,又保证用户看到的永远是最新内容(推送在离线队列里躺了半小时,正文可能已经过时了)。
  2. 接受一条"轻通知":用 tag 让同类通知互相覆盖(比如"3 条新评论"只保留一条),配合 renotify: false 不重复打扰。用户感知弱,但链路简单可靠。
  3. 后台同步(仅 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 之后松动了,但条件苛刻,必须逐条满足,缺一不可:

  1. 系统版本 iOS / iPadOS 16.4 及以上;
  2. 你的网站必须是一个合格的 PWA:有 manifest、display 为 standalone、有 Service Worker;
  3. 用户必须把你的网站添加到主屏幕,从主屏幕图标打开——在 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 代码或权限——二分法一步到位。

Web Push 排错决策流程图:从权限、Service Worker、订阅、状态码、iOS 到系统设置的六步排查

排错时按上图从上往下查,90% 的问题落在这张对照表里:

症状最可能的原因修法
点了订阅按钮没反应 / 授权框不弹requestPermission 不在用户手势里调用,或之前已拒绝(denied)绑定到按钮点击事件;denied 只能引导用户去浏览器设置手动开
register('/sw.js') 报错非 HTTPS(且非 localhost)、sw.js 跨域、scope 路径不对本机用 localhost;sw.js 放站点根目录同源
subscribe() 报 InvalidAccessErrorVAPID 公钥没转 Uint8Array,或传成了私钥用 urlBase64ToUint8Array 转公钥
服务端返回 401 / 403VAPID 私钥错、subject 格式错(非 mailto:/https:)核对环境变量;重新 generate-vapid-keys
服务端返回 410 / 404订阅过期删库(见第八节),不是 bug 是正常现象
服务端返回 413payload 超 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 项目通知系统实战指南》。代码让你能发出通知,策略决定用户愿不愿意继续收。

阅读 1评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

笔记本电脑屏幕上的 CSV 数据导入表格,vibe 项目数据导入导出实战指南封面
指南
导出别裸奔、导入别崩:vibe 项目的数据导入导出实战

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

开发工作流工具技巧后端工程
深色数据中心机房,象征灾难恢复基础设施
指南
没点过恢复按钮的备份都是纸:vibe 项目的数据库备份与灾难恢复实战

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

开发工作流数据工作流后端工程
日历预约主题封面:日程表与世界时钟元素
指南
时区是预约功能的坟场:vibe 项目的日历预约实战

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

自动化后端工程项目创作