
在 Vue 项目里做消息提醒很多团队一开始都会走“站内小红点 轮询接口”这条路等到用户量上来服务器被无意义的请求压得喘不过气产品又跑来说“能不能像原生 App 那样消息直接弹在手机通知栏里”。我去年接的一个后台管理系统配套的移动端 H5 就遇到了这个需求Vue 移动端页面要能把消息推到手机推送通知栏同时电脑端打开同一套页面也要能收到桌面通知。整套下来只需要浏览器原生能力不依赖任何原生壳电脑端和移动端通用纯前端加一个轻量 Node 服务就能跑通。这篇文章就把我踩过的坑、验证过的代码、以及那些文档里不会写的细节完整摊开讲一遍适合已经会写 Vue、但对推送机制还比较陌生的同学照着复现。1. 先想清楚为什么选 Web Push 而不是轮询1.1 四种常见消息触达方案的真实成本对比做消息触达摆在面前的无非就那么几条路前端定时轮询、SSE 长连接、WebSocket、以及浏览器原生的推送通道。很多人在选型时只比较“能不能实现”但真正决定项目能不能长期跑下去的是成本、稳定性和用户打扰度这三个维度。我做过一个粗略的对比把当时项目里实际测到的数据填了进去方案实时性服务端开销页面关闭后能否收到手机通知栏电脑端支持实现复杂度定时轮询取决于间隔5s 起步高请求量与在线人数成正比不能不能不能低SSE好中每条连接常驻不能页面关闭即断不能不能中WebSocket最好中高需要维护心跳与重连不能页面关闭即断不能不能中高Web Push好极低只在发消息时调用一次推送接口能能能中这张表里最关键的一列是“页面关闭后能否收到”。前面三种方案的共同前提是页面必须活着用户只要切到别的应用、把浏览器进程清掉消息就永远送不到了。而 Web Push 走的是浏览器厂商自己的推送服务页面关掉、浏览器进程还在后台时消息依然能弹到系统通知栏里。这就是它和另外三种方案的本质差别。1.2 Web Push 到底帮我们省掉了什么从架构上看Web Push 把“长连接维护”这件事从我们自己的服务器上彻底剥离了。我们不需要维护几十万条常驻连接不需要写心跳超时逻辑不需要担心连接数把文件描述符吃满。整个链路变成了这样前端订阅一次把订阅凭证存到数据库后端要发消息时拿凭证调用一次推送服务推送服务负责把消息投递到目标设备设备上的 Service Worker 被唤醒弹出通知。我实测下来一个日活几万的系统推送服务的调用量基本等于消息发送量服务器压力几乎可以忽略。对比之前轮询方案里每秒几百次的无效查询省下来的机器成本是很直观的。还有一点容易被忽略轮询方案下用户只要开着页面请求就一直在跑手机电量掉得肉眼可见。而 Web Push 在没有消息的时候是完全静默的用户设备上不会有任何网络活动。这一点在移动端尤其重要。1.3 什么场景适合这套方案什么场景别硬上Web Push 不是银弹我总结下来它适合这几类场景后台管理系统需要把工单、审批、告警推给负责人内容型站点做新文章、新评论提醒电商或社区做订单状态变更通知内部工具做值班群聊的消息触达。这些场景的共同点是消息不是强实时到毫秒级但要求“用户不在页面上也能知道”。不适合的场景也要说清楚。如果业务要求毫秒级双向通信比如在线协作白板、实时语音房、游戏对战那还是老老实实上 WebSocketWeb Push 的投递延迟受厂商通道影响从几百毫秒到几秒都有可能做不到稳定低延迟。另外如果消息本身是敏感的隐私内容把明文写进通知栏要谨慎最好只推“你有一条新消息”这样的脱敏提示。提示Web Push 的通知内容会经过浏览器厂商的推送服务中转涉及敏感信息时只推摘要和跳转链接详细内容让用户点进页面后再拉取。2. 动手之前三个绕不开的硬性条件2.1 HTTPS、Service Worker 与用户授权这套方案能跑起来依赖三个硬性条件缺一个都不行。第一是 HTTPS。除了localhost这个特例所有涉及 Service Worker 和通知权限的 API 都要求页面处于安全上下文。这意味着本地开发用http://localhost没问题但测试环境必须配好证书。我见过不少团队在这一步卡住本地跑得好好的一部署到内网 IP 就报Notification is not defined排查半天发现是http://192.168.x.x的问题。解决方式很简单要么给测试域名配证书要么用工具把本地端口映射成一个 HTTPS 域名。第二是 Service Worker 支持。现代浏览器基本都支持但有一点要注意Service Worker 必须由同源的脚本注册跨域的 CDN 上的 sw.js 是注册不上的。所以sw.js这个文件一般放在项目根目录走同源路径。第三是用户授权。通知权限必须由用户手势触发申请不能页面一加载就弹。这是浏览器的硬性规定早期那种自动弹窗的做法现在会被直接忽略甚至拉黑。所以权限申请的按钮设计要有讲究后面会专门讲。2.2 各平台支持现状与真实差异很多人以为“移动端支持 Web Push”是一句统一的话实际上各平台差异非常大我整理了一份实际测过的矩阵平台支持情况备注安卓 Chrome/Edge支持需要用户授权页面关闭后仍可接收安卓国产浏览器差异较大部分内核支持部分阉割了推送能力iOS Safari16.4 及以上支持必须先把网页“添加到主屏幕”以图标启动才生效桌面 Chrome/Edge支持浏览器进程需在运行页面可关闭桌面 Firefox支持行为与 Chrome 基本一致桌面 Safari16.4 及以上支持同样要求较新系统版本这里最需要单独拿出来说的是 iOS。iOS 16.4 之前网页在 iPhone 上是完全无法接收系统级通知的。16.4 之后虽然支持了但有个前置条件用户必须把网页添加到主屏幕从主屏图标启动的 Web App 才有推送权限。在 Safari 普通标签页里调用订阅接口会直接失败。这个限制对产品设计影响很大。如果你的用户主要是 iOS 用户就要在页面上引导他们“添加到主屏幕”否则推送功能对他们来说等于不存在。我的做法是检测到 iOS 且未处于独立窗口模式时把推送开关先隐藏单独弹一个图文引导说明怎么添加到主屏。2.3 VAPID 密钥生成一次长期保管VAPID 是这套方案里的身份凭证作用类似于“服务器给浏览器推送服务出示的名片”证明这次推送确实来自你运营的这个站点。没有它浏览器厂商的推送服务不会认你的请求。生成方式很直接在项目里装好依赖后执行一次npm install web-push -g web-push generate-vapid-keys --json输出的是一对 Base64 字符串公钥给前端用私钥留在后端。公钥可以写在前端代码里它本身不敏感私钥必须放在环境变量里绝对不能提交到代码仓库。有个坑我踩过本地生成了一套密钥测试环境用了另一套结果测试环境订阅的凭证拿到生产环境去推送全部报UnauthorizedRegistration。原因是公钥和私钥是配对的换了密钥之前所有订阅关系都会失效。所以密钥生成后要妥善保存尽量在项目初期就确定下来避免上线后更换。注意更换 VAPID 密钥会导致所有存量订阅全部作废用户需要重新授权订阅。如果确实要换最好做一次全量推送引导通知老用户重新开启。3. Vue 前端从注册 Service Worker 到拿到订阅对象3.1 sw.js 放哪里、作用域怎么定sw.js必须放在它要控制的页面路径的“上一层或同层”。如果你的页面都在根路径下那sw.js就放在public目录Vue CLI或者public目录Vite 也是 public构建后会输出到站点根目录作用域自然是/。我习惯在项目里这样组织public/sw.js推送接收和点击处理逻辑src/utils/push.js封装订阅、退订、权限判断src/main.js注册 Service Worker 并挂载消息监听有一点要注意sw.js是不能被打包工具处理成带 hash 文件名的。它必须是固定路径的静态文件否则每次构建后路径都变浏览器找不到更新。所以放在public目录里让它原样拷贝出去是最稳的做法。3.2 权限申请的时机设计权限申请弹窗只能弹一次用户点了“禁止”之后再次调用申请接口浏览器会直接返回denied不会再弹窗。这意味着你不能随便找个地方就弹。我的做法是在设置页放一个“开启消息通知”的开关默认关闭旁边写清楚“开启后可及时收到工单、审批等提醒”。用户主动点击开关时才触发权限申请。这样用户有心理预期通过率明显比页面一加载就弹高得多。申请之前还要先判断当前状态export function getPermissionState() { if (!(Notification in window)) return unsupported; return Notification.permission; // default | granted | denied }如果是denied就别再调申请接口了直接在界面上提示“通知权限已被浏览器拒绝请在浏览器设置里手动开启”并给出图示。如果是granted说明之前授权过直接走订阅流程即可。3.3 封装一个能复用的 push.js下面这段是我在项目里实际用的去掉业务逻辑后精简出来的版本。它处理了浏览器兼容、Base64 转换、重复订阅复用这几个常见问题。// src/utils/push.js const VAPID_PUBLIC_KEY import.meta.env.VITE_VAPID_PUBLIC_KEY; function urlBase64ToUint8Array(base64String) { const padding .repeat((4 - (base64String.length % 4)) % 4); const base64 (base64String padding) .replace(/-/g, ) .replace(/_/g, /); const rawData window.atob(base64); const output new Uint8Array(rawData.length); for (let i 0; i rawData.length; i) { output[i] rawData.charCodeAt(i); } return output; } export function isPushSupported() { return ( serviceWorker in navigator PushManager in window Notification in window ); } export async function registerServiceWorker() { const reg await navigator.serviceWorker.register(/sw.js, { scope: / }); await navigator.serviceWorker.ready; return reg; } export async function getOrCreateSubscription(reg) { // 已有订阅直接复用避免重复创建 let sub await reg.pushManager.getSubscription(); if (sub) return sub; sub await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) }); return sub; } export async function syncSubscriptionToServer(sub) { const payload { endpoint: sub.endpoint, keys: { p256dh: sub.toJSON().keys.p256dh, auth: sub.toJSON().keys.auth }, ua: navigator.userAgent }; return fetch(/api/push/subscribe, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${localStorage.getItem(token) || } }, body: JSON.stringify(payload) }); }这里有两个细节值得说。userVisibleOnly: true是 Chrome 的强制要求意思是每条推送都必须产生用户可见的通知不允许后台静默推送不写这个参数会直接报错。另外getSubscription()一定要先调用因为用户可能之前已经订阅过重复subscribe()在某些浏览器上会抛异常导致开关状态和真实订阅状态不一致。3.4 把订阅信息同步给后端时要注意什么订阅对象里的endpoint是一个很长的 URL包含推送服务的地址和设备标识。数据库字段长度一定要给够我吃过一次亏字段设了varchar(255)结果某些浏览器的 endpoint 长度超过了这个值插入直接被截断后续推送全部失败。后来改成varchar(500)才稳定。另外endpoint要加唯一索引。用户可能在同一个浏览器上反复开关通知如果不做去重数据库里会堆积大量重复记录推送时同一条消息会弹很多次。我的处理方式是endpoint唯一插入时用ON DUPLICATE KEY UPDATE更新last_active_at和用户 ID。还有一点订阅和用户身份要绑定。同一台设备上换了账号登录订阅记录应该归属到新用户不然会出现“张三的消息推给了李四”这种事故。我的做法是在登录成功后如果本地存在订阅就静默地重新同步一次订阅把 user_id 更新掉。3.5 不经过服务器也能弹的本地通知有一种情况不需要后端参与用户就在页面上做得某个操作需要即时反馈。这时候直接调new Notification()就行速度快、零延迟。export function notifyLocal(title, body, onClick) { if (Notification.permission ! granted) return null; const n new Notification(title, { body, icon: /icons/icon-192.png, tag: local- Date.now() }); n.onclick () { window.focus(); onClick onClick(); n.close(); }; return n; }这个能力在桌面端体验特别好页面切到后台标签页时本地通知能把用户拉回来。但要注意它只在页面存活时有效页面一关就没了。所以它和 Web Push 是互补关系不是替代关系。实际项目里我一般这么分工页面内即时反馈用本地通知跨会话、离线触达用 Web Push。4. Node 后端加密推送与订阅管理4.1 依赖安装与 VAPID 配置后端这块我用的是 Node Express核心依赖就一个web-push。npm install web-push express body-parser初始化时把 VAPID 信息设进去注意邮箱要填真实的某些推送服务在异常时会往这个邮箱发告警const webpush require(web-push); webpush.setVapidDetails( mailto:opsexample.com, process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY );密钥通过环境变量注入不要硬编码。我在 CI 里见过密钥被写进 Dockerfile 的那基本等于泄露。4.2 订阅表怎么设计才够用表结构不用复杂但几个字段不能省CREATE TABLE push_subscription ( id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, endpoint VARCHAR(500) NOT NULL, p256dh VARCHAR(255) NOT NULL, auth VARCHAR(255) NOT NULL, ua VARCHAR(255) DEFAULT NULL, fail_count INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_active_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_endpoint (endpoint(255)), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;p256dh和auth是加密推送内容用的密钥材料缺了这两个后端就没法把消息加密传出去。fail_count是我后来加的用来做失败计数连续失败多次的订阅会被标记为不可用避免每次群发都去重试一堆废弃设备。还要说明一点payload 是有大小限制的浏览器普遍限制在 4KB 左右。超了会推送失败所以消息体只传标题、摘要、跳转地址这些必要字段需要完整内容时让前端点击后拉取。4.3 发送推送的完整实现这是核心方法我把关键参数都注释了async function sendToSubscription(sub, payload) { const pushConfig { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }; const options { // 消息在推送服务上的存活时间单位秒。24 小时没送达就丢弃 TTL: 60 * 60 * 24, // 紧急程度影响是否唤醒省电模式下的设备 urgency: high, // 同一 topic 的消息会互相覆盖适合订单状态这类只需看最新值的场景 topic: payload.topic || undefined }; try { await webpush.sendNotification(pushConfig, JSON.stringify(payload), options); await db.updateLastActive(sub.endpoint); } catch (err) { if (err.statusCode 404 || err.statusCode 410) { // 订阅已失效直接删除 await db.removeSubscription(sub.endpoint); } else if (err.statusCode 413) { console.error(payload 过大, sub.endpoint); } else if (err.statusCode 429) { // 触发限流等一会儿再试 throw new Error(RATE_LIMIT); } else { await db.increaseFailCount(sub.endpoint); console.error(推送失败, err.statusCode, err.body); } } }TTL这个参数值得多解释一句。它表示消息在推送服务端的排队时间如果用户设备一直离线超过 TTL 消息就作废了。对于“审批提醒”这种有时效性的消息我一般设 24 小时对于“系统公告”可以设长一点比如 3 天。设成 0 表示立即投递设备不在线就丢掉。topic参数也很实用。同一个 topic 的消息会覆盖比如订单状态从“已付款”变到“已发货”用户只需要看到最新状态用同一个 topic 就能避免通知栏堆积一堆过期消息。4.4 失效订阅的清理策略404和410这两个状态码在推送里出现的频率比想象中高。用户卸载了浏览器、清了站点数据、换了设备都会让旧订阅变成死信。如果不清每次群发都要为这些死信消耗一次请求量大了很浪费。我的策略是三个层次一是收到404/410立即删除二是fail_count超过 5 次的订阅在群发时跳过三是每周跑一次清理任务把 90 天没有last_active_at更新的订阅归档。这三条加起来订阅表能一直保持在比较健康的规模。5. 通知点击后的跳转从系统通知栏回到 Vue 路由5.1 移动端和电脑端的行为差异用户点了通知我们当然希望他直接进到对应的业务页面。但这里移动端和电脑端的行为不一样。电脑端上如果页面已经开着我们更希望复用现有的标签页并让它聚焦然后由 Vue Router 做一次push跳转这样能保留页面状态、避免重复加载。如果页面没开那就新开一个窗口打开目标地址。移动端的情况更复杂一些。安卓上点击通知一般会新开或聚焦浏览器窗口iOS 上从主屏图标启动的 Web App点击通知会回到那个独立窗口。华为鸿蒙等机型上如果是从浏览器内打开的通知行为又略有不同有些会回到浏览器有些会回到最近的任务。所以代码要做兼容不能假设只有一种情况。5.2 用 postMessage 让已打开的页面走路由核心思路是Service Worker 里拦截notificationclick事件先找有没有已打开的窗口有就聚焦并用postMessage通知页面页面收到消息后调用 Vue Router 跳转。Service Worker 侧self.addEventListener(notificationclick, (event) { event.notification.close(); const target (event.notification.data event.notification.data.url) || /; event.waitUntil((async () { const clientList await clients.matchAll({ type: window, includeUncontrolled: true }); for (const client of clientList) { if (focus in client) { await client.focus(); client.postMessage({ type: PUSH_NAVIGATE, url: target }); return; } } if (clients.openWindow) { return clients.openWindow(target); } })()); });Vue 侧在main.js或路由初始化后挂监听if (serviceWorker in navigator) { navigator.serviceWorker.addEventListener(message, (event) { const data event.data || {}; if (data.type PUSH_NAVIGATE data.url) { router.push(data.url).catch(() {}); } }); }这里有个细节router.push对同一个路径重复跳转可能抛NavigationDuplicated加.catch(() {})兜一下就行。另外目标 url 最好是站内的相对路径比如/order/detail/123这样路由能直接匹配上。提示includeUncontrolled: true这个参数别漏否则可能匹配不到当前页面的客户端导致每次点击都新开窗口而不是复用。5.3 完整的 sw.js 示例把接收推送和点击处理合在一起完整的sw.js长这样// public/sw.js self.addEventListener(install, (event) { self.skipWaiting(); }); self.addEventListener(activate, (event) { event.waitUntil(self.clients.claim()); }); self.addEventListener(push, (event) { let data { title: 新消息, body: , url: /, tag: default }; if (event.data) { try { data Object.assign(data, event.data.json()); } catch (e) { data.body event.data.text(); } } const options { body: data.body, icon: data.icon || /icons/icon-192.png, badge: /icons/badge-72.png, tag: data.tag, renotify: true, data: { url: data.url, ts: Date.now() }, vibrate: [80, 40, 80] }; event.waitUntil(self.registration.showNotification(data.title, options)); }); self.addEventListener(notificationclick, (event) { event.notification.close(); const target (event.notification.data event.notification.data.url) || /; event.waitUntil((async () { const clientList await clients.matchAll({ type: window, includeUncontrolled: true }); for (const client of clientList) { if (focus in client) { await client.focus(); client.postMessage({ type: PUSH_NAVIGATE, url: target }); return; } } if (clients.openWindow) return clients.openWindow(target); })()); });renotify: true配上固定的tag能实现“同一类消息只保留最新一条但每次来都震动一下”的效果很符合聊天或告警场景的预期。6. 踩坑实录与排查速查表6.1 权限申请弹窗不出现的几种情况这是我被问得最多的问题“代码里调了申请但浏览器根本不弹窗”。原因通常有这几种。第一种页面不是安全上下文。http://开头的非 localhost 页面Notification.requestPermission()会直接静默失败返回值是default什么提示都没有。这是最常见的。第二种用户在之前已经点了“禁止”。这种情况下浏览器记住了拒绝状态再次调用不会弹窗返回值直接是denied。解决办法是引导用户手动去浏览器地址栏左边的图标里把通知权限改成允许页面上可以做一段图形引导。第三种没有用户手势。有些浏览器要求申请必须在点击事件的处理函数里同步调用如果你在await了别的接口之后才调申请就会被认为是非用户手势弹窗被拦截。第四种iOS 上没有添加到主屏幕。Safari 标签页里调用相关接口要么报错要么无响应必须从主屏图标启动才行。6.2 订阅成功但一条消息都收不到订阅对象拿到了后端也存了但推送就是没反应。按我的排查顺序一般从这几处找。先确认后端是不是真的发出了请求。我习惯在发送方法里打一行日志把statusCode记下来正常情况下是201。如果是401或403基本就是 VAPID 密钥不匹配检查前后端用的公钥是不是同一套。如果是400多半是applicationServerKey格式问题前端要把 Base64Url 转成Uint8Array直接传字符串在某些浏览器上会失败。如果后端返回201但设备没反应就要看 Service Worker 有没有被正确激活。打开开发者工具的 Application 面板看 Service Worker 状态是不是activated。如果是waiting说明有旧版本没释放可以在install里加self.skipWaiting()。还有一个容易忽略的点桌面端浏览器必须处于运行状态。Chrome 关闭后是收不到推送的需要浏览器进程在后台运行。移动端则是浏览器的后台进程或系统推送服务接管这一点在上面平台矩阵里也提到了。6.3 常见问题速查表现象可能原因排查动作申请权限无弹窗非 HTTPS、已拒绝、非用户手势检查协议、查 permission 值、改到点击回调里同步调用订阅返回异常公钥格式、浏览器不支持确认 Base64 转 Uint8Array、判断 API 是否存在后端 401/403VAPID 密钥不匹配核对前后端公钥、检查私钥环境变量后端 404/410订阅已失效删除该条订阅记录让用户重新订阅后端 413payload 超过 4KB精简消息体只传摘要和跳转地址推送成功但不弹SW 未激活、系统通知被屏蔽查 SW 状态、查操作系统通知设置iOS 无法订阅未添加到主屏幕引导用户添加主屏后重新打开点击通知跳错页url 格式、路由未匹配用站内相对路径、加 catch 兜底同一消息弹多次订阅重复存储endpoint 加唯一索引、插入前去重通知不显示图标图标路径错误或尺寸不符用 192 和 512 两种规格检查绝对路径6.4 几条文档里不会写的经验第一群发要控速。我最初做活动通知一口气给两万条订阅发推送结果触发了推送服务的限流返回一堆429。后来改成每批 200 条、批间隔 1 秒就再没出过问题。这个经验在压测里是测不出来的只有真跑到量了才会遇到。第二通知文案比技术实现更影响留存。标题超过 20 个字在手机上会被截断正文超过 40 个字会被折叠。我做过对比把“您有一条新的待办事项”改成“待办合同审批待您处理”点击率明显上升。技术同学做推送时最好和产品一起把文案模板定下来。第三别在深夜发。就算功能上没问题运营层面也要加时间窗口控制。我一般设成早 8 点到晚 10 点之外的消息进队列第二天早上再发。这个规则看似简单但如果没有用户很容易因为骚扰直接关闭通知权限而这个权限一旦关了再想拿回来就很难。7. 上线前的体验细节与部署配置7.1 通知的图标、振动与分组图标这块有两个字段icon是大图badge是小图标。安卓上badge会显示在状态栏必须是纯色带透明通道的 PNG尺寸 72x72 左右彩色图会被系统处理成灰色块很难看。icon一般用 192x192。iOS 上对图标要求更严格路径必须是绝对路径相对路径会不显示。振动用vibrate数组[80, 40, 80]表示振动 80 毫秒、停 40 毫秒、再振 80 毫秒。安卓支持得比较好桌面端基本忽略这个参数。别设太长超过 1 秒的振动会让用户觉得烦躁。分组靠tag。同一个tag的通知会相互覆盖配合renotify: true每次新消息来会覆盖旧通知但重新提醒一次。聊天场景里我按会话 ID 设 tag这样同一个会话的消息不会刷屏但不同会话分别保留。7.2 打包部署时最容易踩的缓存坑sw.js这个东西最怕被缓存。如果你的站点用 Nginx 做了强缓存或者套了 CDN浏览器可能一直拿到旧版本的 sw.js导致你的推送逻辑更新了但线上没生效。我一般这样配 Nginxlocation /sw.js { add_header Cache-Control no-cache, no-store, must-revalidate; add_header Service-Worker-Allowed /; default_type application/javascript; try_files $uri 404; }no-store是关键让浏览器每次都去服务器校验。另外如果sw.js不在根目录需要用Service-Worker-Allowed响应头声明允许的作用域否则注册会被拒绝。这个头部我见过有人漏配结果 sw.js 放在/static/js/下怎么都注册不上。还有一点Vue 打包后如果用了 History 模式路由sw.js里clients.openWindow(target)的 target 如果是/order/123这样的路径服务器要配好 fallback 到index.html否则会 404。这个配置和路由模式是配套的。7.3 免打扰、频率控制与用户预期管理技术上能推不等于应该随时推。我在项目里加了三层控制。第一层是用户侧的开关设置页里可以按消息类型分别开关比如“审批提醒”开、“营销通知”关。这个粒度比一个总开关好用得多。第二层是服务端的频率限制同一个用户 5 分钟内最多收到 3 条推送超出的合并成一条“你有 N 条新消息”。合并逻辑不复杂就是查一下最近有没有同类型消息在队列里有就累加计数。第三层是静默时段上面提到的晚 10 点到早 8 点。这个时段的消息进延迟队列用一个定时任务在早 8 点统一发。这三层加完用户关闭通知权限的比例明显下降。我的体会是推送功能做得再稳定如果打扰用户最后都会被关掉那技术做得再漂亮也没意义。最后分享一个我在实际使用中发现的小技巧调试推送时不要每次都走完整的后端流程可以直接在浏览器控制台里手动构造一次推送来验证 Service Worker 的逻辑。打开开发者工具的 Application 面板找到 Service Workers 那一栏里面有个 Push 输入框填上 JSON 内容点一下就能触发push事件比等后端跑一轮快得多。这个入口在排查“消息发出去了但通知没弹”这类问题时特别省时间能立刻把问题范围缩小到 Service Worker 内部还是后端加密环节。