1. 先把推送通知栏这件事拆明白移动端实现手机推送通知栏本质上是让浏览器或混合应用在系统层面弹出一条可点击、可携带数据、能唤起指定页面的消息。这件事听起来简单真做起来坑不少尤其是想让同一套 Vue 代码在手机和电脑端通用的时候。我自己第一次做的时候以为调个new Notification()就完事了结果发现权限拿不到、息屏收不到、点击跳不动一路踩过来才把整条链路理清楚。这篇内容适合三类人看一是正在做 Vue 项目、想给用户加消息提醒的前端二是做移动端 H5、需要一个类原生通知体验的开发者三是想搞清楚浏览器通知能力边界、判断值不值得投入的产品和负责人。我会从能力边界讲起一路讲到可复制的封装代码、后端触发链路、双端差异适配最后把常见坑整理成速查表。先说一个最重要的前提认知浏览器通知不是原生推送。它依赖浏览器的通知权限、依赖系统对浏览器的后台保活策略、依赖推送通道Push Service的可用性。这三样任何一样不满足通知就出不来。理解了这一点后面所有的设计取舍才有依据。1.1 浏览器通知和原生推送到底差在哪原生 App 的推送是操作系统级别的应用被杀死、手机重启消息依然能通过系统通道送达。浏览器通知没这个待遇。它走的是 W3C 标准里的 Web Push 协议消息先到浏览器厂商的推送服务再由浏览器唤醒你的 Service Worker 来处理。这意味着设备必须能连通对应的推送服务浏览器进程必须有机会被唤醒。具体差异我列个表看得清楚些对比项原生 App 推送浏览器 Web 推送应用被杀死后能否收到能部分能依赖浏览器后台状态是否需要权限系统权限弹窗浏览器通知权限弹窗消息通道厂商推送服务浏览器厂商推送服务点击跳转唤起 App 指定页面打开网页或已存在的窗口频率限制基本无部分浏览器有配额用户可关闭位置系统设置站点设置 / 权限管理看到这个表你就明白了浏览器通知适合做锦上添花的提醒不适合做关键业务必须送达的通道。如果你的场景是订单支付结果、验证码这类必须触达的消息浏览器通知只能作为补充不能作为唯一手段。1.2 三类典型场景的取舍我在实际项目里遇到过三种典型需求处理方式完全不同。第一类是页面开着的时候提醒比如后台管理系统里新工单进来、聊天窗口有新消息。这种最省事页面在前台Notification直接用就行不需要 Service Worker也不需要推送订阅。缺点是页面一切到后台尤其是移动端浏览器很快就会冻结定时器和网络请求消息就断了。第二类是页面切走甚至关闭后还要提醒这就必须上 Service Worker Web Push。用户在订阅的时候浏览器会生成一个 endpoint你把 endpoint 和密钥存到后端后端发消息时通过推送服务投递浏览器被动接收。这是真正意义上的推送。第三类是手机浏览器里做一个接近 App 的体验通常是 PWA 场景。这条路在国内的移动端环境里限制很多尤其是 iOS必须添加到主屏幕之后才支持 Web Push而且支持的版本也有门槛。做之前一定要先确认目标用户的设备和系统占比。提示如果你的用户主要在手机浏览器里以普通网页形式访问不要对通知到达率抱太高期待。这块能力受系统策略影响很大先做小范围灰度验证再全量。1.3 权限模型整条链路最容易翻车的一环浏览器通知有四个状态default还没问过、granted已授权、denied已拒绝、以及部分浏览器的prompt。这里有两个铁律一是权限必须由用户手势触发。也就是说你不能在页面加载时自动弹权限框浏览器会直接拦截。必须放在一次点击事件里调用Notification.requestPermission()。二是权限一旦被拒绝代码层面无法再次主动弹出。用户只能自己去浏览器设置里改。所以什么时候问用户要权限这件事直接决定了你的通知功能能不能跑起来。我踩过最惨的一次坑是把权限申请放在了应用启动时。结果用户第一次进来还没搞清楚这网站干嘛的就被弹了一个权限框大部分人条件反射地点了拒绝。后来改成用户主动点击订阅按钮才申请通过率从百分之十几提到了接近一半。这个差距值得你花时间设计交互。2. 技术选型Notification、Service Worker和推送通道怎么分工搞清楚能力边界之后就该定方案了。Vue 项目里做通知有三种主流路线我用表格先摆出来然后讲我为什么最后选了第三种以及电脑端为什么反而更省心。2.1 三条路线横向对比路线实现方式能否后台提醒双端通用性上手难度纯 Notification API页面里直接调否好低WebSocket 长连接页面维护连接 Notification页面关了就断中中Service Worker Web Push订阅推送通道是需分支处理高纯 Notification 路线适合页面必定在前台的场景代码三五行走完但能力也到此为止。WebSocket 路线适合需要双向实时通信的场景比如客服、协同编辑它的问题是移动端切后台后连接会被系统杀掉需要重连和保活逻辑而且页面完全关闭后依然收不到。Service Worker Web Push 是唯一能做到页面关闭后仍可能收到的方案代价是复杂度陡增要注册 SW、要处理订阅、要写后端投递、要处理密钥轮换。2.2 为什么我最终押注 Service Worker Push API原因很简单只有这条路能覆盖用户离开页面后还想提醒他这个核心诉求。其他两条路只是把通知当作前台功能的补充。而且 Push API 有几个被低估的好处。第一订阅是持久化的浏览器会帮你保存 endpoint只要用户不主动取消长期有效。第二Service Worker 在后台被唤醒时执行不占用你页面主线程的资源。第三这套标准是 W3C 定义的Chrome、Edge、Firefox 桌面端支持都很稳定电脑端基本零适配。代价是要处理推送服务连通性这个不可控因素。国内网络环境复杂不同浏览器走的推送服务不一样连通效果也不同。所以我在设计时永远留一条降级通道如果订阅失败或者推送到达率低自动切回 WebSocket 保活保证页面前台时消息不断。2.3 Vue 项目里的目录规划目录结构规划得清楚后面维护会轻松很多。我一般这样组织src/ composables/ useNotification.js # 通知能力封装对外统一 API usePushSubscribe.js # 订阅管理负责注册与上报 utils/ notification.js # 纯函数工具处理权限、展示 workers/ sw.js # Service Worker 源文件 views/ MessageCenter.vue # 消息中心页面sw.js单独放因为构建时要把它原样输出到站点根目录不能被打包器改写路径。这个细节很多人第一次做会卡住Service Worker 的作用域由它的路径决定放在/js/sw.js里它就只能接管/js/下的请求。提示如果用的是 Vite把sw.js放到public/目录下构建后会原样复制到根目录这是最省心的做法。3. 从零落地Vue 项目里的通知模块怎么写理论讲完直接上代码。我会按工具层封装 - 订阅管理 - Service Worker - 后端投递的顺序把每个环节的意图和坑都讲清楚。所有代码都是 Vue 3 组合式写法Vue 2 换一下语法即可。3.1 基础能力封装一个通知工具类先做一层薄封装把浏览器差异和权限判断收拢到一个地方。这样页面里调用永远只有几个简单方法。// src/utils/notification.js export function isNotificationSupported() { return Notification in window serviceWorker in navigator } export function getPermission() { if (!isNotificationSupported()) return unsupported return Notification.permission } export async function requestPermission() { if (!isNotificationSupported()) return unsupported if (Notification.permission granted) return granted if (Notification.permission denied) return denied // 必须在用户手势调用栈里执行否则会被拦截 const result await Notification.requestPermission() return result } // 前台场景直接展示简单场景够用 export function showLocalNotification(title, options {}) { if (getPermission() ! granted) return null const n new Notification(title, { body: options.body || , icon: options.icon || /icon-192.png, tag: options.tag || default, data: options.data || {}, ...options }) return n }这里有个关键点值得解释tag参数。同一 tag 的通知会互相覆盖不会重复堆叠。这个特性解决了一个很烦人的问题——用户连续收到五条同类消息通知栏刷出来五条重复的。加上固定的 tag通知栏永远只留最新一条。data字段是留给点击跳转用的你可以塞任何可序列化的内容比如{ url: /order/123, orderId: 123 }。后面notificationclick里能取到。3.2 权限申请时机与交互设计我在权限这块总结了三条经验都是被用户教育出来的。第一条不要在首屏自动弹。前面说过会被浏览器拦截而且用户体验差。正确做法是准备一个显眼的开启消息提醒按钮放在消息中心或者设置页。第二条弹之前先做一次预引导。用自身 UI 弹一个小提示说明开启后可以第一时间收到订单通知用户点去开启之后再调浏览器的权限接口。这样即使浏览器弹窗风格和页面不一致用户心理上也有预期。第三条做好被拒绝后的兜底。一旦是denied状态要在页面上持续展示一条提示告诉用户去浏览器设置的哪个位置手动打开。别指望用户自己会找。!-- MessageCenter.vue 里的权限按钮 -- script setup import { ref, onMounted } from vue import { getPermission, requestPermission } from /utils/notification const permission ref(default) onMounted(() { permission.value getPermission() }) async function handleSubscribe() { // 先展示自己的引导提示这里省略 UI const result await requestPermission() permission.value result if (result granted) { // 拿到权限后再去建立推送订阅 await subscribePush() } } /script3.3 Service Worker 注册与消息处理sw.js是整条链路的核心。它独立于页面运行负责接收推送、展示通知、处理点击。我把它拆成三块来理解。第一块是安装和激活阶段负责把旧缓存清掉保证新版本 SW 能接管。// public/sw.js const CACHE_VERSION notify-v1 self.addEventListener(install, (event) { self.skipWaiting() }) self.addEventListener(activate, (event) { event.waitUntil(self.clients.claim()) })skipWaiting让新 SW 立即生效clients.claim让它接管已有页面。不写这两个更新会拖到用户关掉所有标签页才生效调试时特别容易迷惑。第二块是接收推送。这里要注意推送的数据可能为空也可能不是 JSON所以一定要做防御。self.addEventListener(push, (event) { let payload { title: 新消息, body: , url: / } try { if (event.data) { payload { ...payload, ...event.data.json() } } } catch (e) { payload.body event.data ? event.data.text() : } const options { body: payload.body, icon: /icon-192.png, badge: /badge.png, tag: payload.tag || default, renotify: true, data: { url: payload.url } } event.waitUntil(self.registration.showNotification(payload.title, options)) })event.waitUntil是必须的。不然浏览器可能在通知展示完成之前就把 Service Worker 干掉导致通知不出现。这个坑我第一次做的时候排查了很久代码看起来完全正确就是偶尔不弹。第三块是处理点击。用户在通知栏点一下应该跳到对应页面。self.addEventListener(notificationclick, (event) { event.notification.close() const targetUrl event.notification.data?.url || / event.waitUntil( self.clients.matchAll({ type: window, includeUncontrolled: true }).then((clientList) { // 已有窗口就聚焦并导航 for (const client of clientList) { if (focus in client) { client.navigate(targetUrl) return client.focus() } } // 没有窗口就新开一个 if (self.clients.openWindow) { return self.clients.openWindow(targetUrl) } }) ) })这段逻辑有个细节优先复用已有窗口而不是每次都新开标签页。不然用户点十次通知开了十个标签体验就崩了。3.4 订阅推送并上报到后端订阅需要在页面里发起拿到 endpoint 之后交给后端保存。// src/composables/usePushSubscribe.js // VAPID 公钥配置在环境变量里不要硬编码 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 atob(base64) return Uint8Array.from([...rawData].map((c) c.charCodeAt(0))) } export async function subscribePush() { const reg await navigator.serviceWorker.ready let sub await reg.pushManager.getSubscription() if (!sub) { sub await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY) }) } // 上报到后端绑定当前用户 await fetch(/api/push/subscribe, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ endpoint: sub.endpoint, keys: sub.toJSON().keys }) }) return sub }这段代码有两个必须理解的点。userVisibleOnly: true是硬性要求它承诺每条推送都会展示一条可见通知浏览器强制要求这个参数为真否则订阅直接失败。所有静默后台推送的玩法在标准浏览器里是行不通的。另一个点是applicationServerKey也就是 VAPID 公钥。后端发消息时用配对私钥签名推送服务据此验证身份。公钥放前端没问题私钥必须严守在后端。3.5 后端触发与前端接收的完整链路后端我用 Node.js 的web-push库示意其他语言有对应实现。// server/push.js const webPush require(web-push) webPush.setVapidDetails( mailto:youremail.com, process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY ) async function sendToUser(subscription, payload) { try { await webPush.sendNotification(subscription, JSON.stringify(payload)) } catch (err) { // 410/404 说明订阅已失效应该从库里删掉 if (err.statusCode 410 || err.statusCode 404) { await removeSubscription(subscription.endpoint) } } }这里最重要的是错误处理。用户卸载浏览器、清空数据、取消订阅后旧的 endpoint 就失效了。后端如果不清理会不断往无效地址发消息既浪费资源也掩盖了真实问题。我一般会在收到 410 时立即删除同时记录日志观察失效频率。完整链路串起来就是用户点击订阅 - 浏览器生成 endpoint - 前端上报 - 后端入库 - 业务触发 - 后端发消息 - 推送服务投递 - 浏览器唤醒 SW - SW 展示通知 - 用户点击 - 跳转指定页面。任何一环断了通知就出不来所以排查时要从头到尾看一遍。4. 移动端与电脑端的差异处理标题里特意提了电脑端通用这不是随便加的。实际上电脑端做这件事比移动端顺畅得多但两端行为差异大到必须分开考虑。4.1 移动端浏览器通知的限制清单移动端的坑集中在这几个方面我按影响程度排一下。第一是后台冻结。移动端浏览器为了省电切到后台后很快就会冻结页面的定时器和网络请求。这意味着任何依赖页面里跑个轮询的方案都会失效必须靠 Service Worker 接收推送。第二是权限入口隐蔽。安卓浏览器的权限弹窗有时候会被系统级弹窗盖住或者用户误点拒绝后很难找回。iOS Safari 里普通网页模式下根本不支持 Web Push 权限申请必须先添加到主屏幕。第三是息屏和省电模式。很多手机在深度休眠时浏览器进程会被压制推送到达时间延迟甚至丢失。这个不是代码能完全解决的只能通过多渠道触达来弥补。第四是通知样式。移动端系统通知栏对图标、字数、折叠方式都有自己的规范长文本会被截断富媒体支持度参差。设计文案时要按最多两行、核心信息前置来写。限制项具体表现应对思路后台冻结切后台后定时器停止走 Service Worker权限门槛iOS 需添加到主屏引导安装到桌面深度休眠消息延迟或丢失多渠道兜底样式受限长文被截断文案精简前置4.2 电脑端为什么反而更好做桌面端浏览器几乎没有后台冻结问题浏览器长期运行推送服务连接稳定Service Worker 被唤醒的成功率高得多。权限弹窗位置固定、样式统一用户操作路径稳定。所以实际项目中我给电脑端和移动端准备了不同的默认策略。电脑端默认打开推送订阅流程顺畅通知能稳定到达。移动端则先做能力探测测到不支持就自动隐藏订阅入口避免让用户点了没反应。这个策略转换我用一个环境探测函数来决定export function getPlatformStrategy() { const ua navigator.userAgent const isMobile /Android|iPhone|iPad|iPod/i.test(ua) const isStandalone window.matchMedia((display-mode: standalone)).matches if (!isMobile) return { mode: full, autoSubscribe: true } if (isMobile isStandalone) return { mode: full, autoSubscribe: false } return { mode: limited, autoSubscribe: false } }display-mode: standalone这个媒体查询能判断页面是不是以 PWA 独立窗口模式打开也就是用户有没有把网页添加到主屏幕。这是判断 iOS 是否支持推送的关键依据。4.3 一套代码兼容两端的适配策略我的做法是把差异全部收在策略层页面组件不感知平台。页面只需要调用useNotification暴露的几个方法内部的实现分支自动处理。具体有三条规则。第一能力探测先行不支持就直接屏蔽入口绝不让用户点了报错。第二订阅失败静默降级到 WebSocket保证页面前台时消息不断。第三通知文案统一但两端可以用不同的tag策略电脑端允许多条并排移动端强制合并。5. 通知点击跳转与路由联动通知点一下能不能跳到正确的页面是用户对这套功能体感最直接的地方。这块我单独拎出来讲因为它涉及页面路由和 Service Worker 的跨上下文通信。5.1 从通知回到应用内页面的实现核心思路是在通知的data里带上目标路径notificationclick里读取并导航。// 发送通知时带上目标路径 showLocalNotification(订单已发货, { body: 点击查看物流详情, tag: order, data: { url: /order/detail?id123 } })Service Worker 那边拿到event.notification.data.url之后优先复用已有窗口并导航。注意这里用的是client.navigate()它会让已存在的标签页跳转到新地址比openWindow体验更好。有个细节容易忽略如果你的应用是单页应用直接改client.navigate会导致整页刷新路由状态丢失。更优雅的做法是通过postMessage通知页面让页内路由处理。// sw.js 里改成消息通信 const client await self.clients.matchAll({ type: window }) if (client.length 0) { client[0].postMessage({ type: NOTIFICATION_CLICK, url: targetUrl }) client[0].focus() } else { self.clients.openWindow(targetUrl) }页面里监听这个消息交给 Vue Router 处理navigator.serviceWorker.addEventListener(message, (event) { if (event.data?.type NOTIFICATION_CLICK) { router.push(event.data.url) } })这样跳转就是路由级的页面不刷新状态保留体验接近原生 App。5.2 冷启动与热启动的两种路径热启动也就是应用已经在后台或已经打开走上面的消息通信即可。冷启动应用完全没开matchAll拿不到任何窗口只能openWindow。这时候带过去的 URL 会作为初始地址加载页面启动后需要从 URL 里解析出目标参数再决定初始路由。这就要求你把目标页面做成可以直接通过 URL 访问的不能依赖前端状态。我在做这个的时候踩过一个坑目标页面依赖登录态冷启动时用户 token 还没恢复页面直接跳到登录页通知链接丢失。解决办法是在路由守卫里把未完成跳转的目标地址存起来登录完成后重放。5.3 移动端唤起应用的边界这里要说清楚一件事纯网页的通知点击只能打开网页无法直接唤起一个原生 App。想让用户点通知后进入原生应用需要 App 侧做深度链接配置并且系统、浏览器、App 三方都要支持。这在移动端各平台差异很大不是纯前端能决定的。所以如果你做的是 H5 页面合理的目标是点击后打开对应网页。如果你做的是混合应用可以让原生层接管通知网页只负责业务数据的传递。这个边界提前和团队对齐能省掉大量无用功。6. 常见问题与排查技巧实录这部分是我最想写的因为通知功能的坑几乎都集中在不生效这三个字上而排查路径又特别分散。我把遇到过的典型问题整理出来配上定位思路。6.1 权限弹窗不出现的几种原因权限框该弹没弹一般有四个原因。一是没在用户手势的调用栈里。requestPermission必须直接由点击、触摸这类事件触发如果中间隔了setTimeout、await了其他异步操作浏览器可能判定不是用户手势直接静默拒绝。这个特别隐蔽代码看起来完全正常。二是页面不在安全上下文。通知能力要求页面运行在 HTTPS 下本地开发时的localhost是例外。如果你的测试环境用了自签证书或者走的是内网 IP权限接口可能直接不可用。三是浏览器或系统级限制。某些浏览器的隐私模式、某些系统的通知总开关关闭、企业策略管控都会让权限接口直接返回denied或者根本不弹。四是站点权限被批量拒绝。用户之前拒绝过Notification.permission就是denied再调也不会弹。这种情况只能引导用户去站点设置里改。提示排查时先打印Notification.permission和window.isSecureContext这两个值能快速排除一半问题。6.2 息屏后收不到消息这是移动端最常见的抱怨。先按下面顺序确认第一确认设备能连通推送服务。这块受网络环境影响较大不同浏览器走的服务不同连通情况也不一样。可以在订阅成功后手动触发一条测试推送观察是否到达。第二确认系统省电策略没有限制浏览器后台活动。很多安卓系统会把不常用的应用加入省电名单浏览器在名单里就收不到唤醒。第三确认订阅没有过期。长时间未使用的订阅可能被推送服务回收需要定期检查和重新订阅。第四确认消息不是被合并了。同一tag的消息会互相覆盖如果你短时间内发了多条用户只会看到最后一条。6.3 通知重复弹出的几种坑重复弹出通常是三个原因造成的。一是页面和 Service Worker 同时展示。有些代码在页面里监听推送又在 SW 里展示导致一次推送出两条。标准做法是统一交给 SW 处理页面不要插手。二是tag没设置或者设置成随机值。没有固定 tag 的通知不会互相覆盖自然就堆叠了。三是后端重复投递。消息队列重试、任务重复触发都可能导致同一条消息发多次。建议在 payload 里带一个唯一 ID前端在 SW 里做一次去重缓存。// sw.js 里做简单去重 const recentIds new Set() self.addEventListener(push, (event) { const payload event.data ? event.data.json() : {} if (payload.msgId recentIds.has(payload.msgId)) return if (payload.msgId) { recentIds.add(payload.msgId) setTimeout(() recentIds.delete(payload.msgId), 60000) } // 后续展示逻辑 })6.4 排查速查表把上面的内容整理成一张表出问题的时候按行对号入座。现象可能原因定位方式权限框不弹非用户手势 / 非 HTTPS打印 permission 与 isSecureContext订阅失败VAPID 公钥格式错检查 base64 转换函数通知不展示未用 waitUntil检查 push 事件回调点击没反应data.url 为空打印 data 内容重复弹出tag 缺失或随机固定 tag 值息屏丢失省电策略限制检查系统后台权限跳转刷新整页用了 client.navigate改用 postMessage老用户收不到订阅失效未清理检查后端 410 处理7. 几个实测下来的参数与经验最后聊聊那些文档里不会写、但实际会影响效果的东西。这些是我在多个项目里反复调整出来的。7.1 心跳与重连策略如果你保留了 WebSocket 作为降级通道断线重连的策略要讲究。我试过几种方案最后定下来的是指数退避第一次失败等 1 秒第二次 2 秒第三次 4 秒上限 30 秒。为什么不用固定间隔因为页面切到后台再回来时如果固定用 3 秒重连会有大量连接在同一时刻发起服务端压力陡增。指数退避能把请求摊开同时保证网络恢复后能在半分钟内重连成功。另外要注意重连成功之后要重新发送一次订阅信息。因为重连可能发生在网络切换之后网络环境变化可能导致推送订阅失效重新上报一次是最稳妥的。7.2 通知内容与展示时长通知文案长度直接影响点击率。我做过一个小对比把您有一条新消息改成张三给您发了一条消息点击率有明显提升。核心原则是让用户在不点开的情况下就知道值不值得点。标题建议控制在 20 个字符以内正文控制在 40 个字符以内移动端系统通知栏一般就展示这么多超出的部分会被省略号截掉关键信息如果放在后面就等于没说。requireInteraction: true这个参数可以让通知在桌面端常驻不自动消失适合重要提醒。但移动端支持度不一而且用户手动关闭前一直占位别滥用。我的做法是只在需要用户确认操作的消息上加。7.3 灰度与降级通知功能上线我建议按这三步走。第一步只对电脑端用户开放观察权限通过率和订阅成功率。电脑端环境变量少能最快验证代码本身有没有问题。第二步开放移动端 PWA 用户。这批用户已经安装了到桌面环境相对可控。第三步开放移动端普通网页用户但只作为可选功能不强制引导。同时开启 WebSocket 降级保证页面前台时消息不断。整个过程要在后端记录三个指标订阅发起数、订阅成功数、推送到达数。用到达数除以成功数就是实际到达率。这个数字低于某个阈值的时候就应该考虑加强其他触达手段而不是在这条路上死磕。我在最近一个项目里观察到的规律是电脑端的到达率明显高于移动端同一套代码的两端表现差异能有三成以上。这不是代码问题是系统策略决定的接受它比对抗它更省力。说到底Vue 本身只是组织代码的方式通知这个能力最终取决于浏览器、系统、网络这三层。把每一层的边界摸清楚把每一层都留好降级方案功能才不至于上线就翻车。我现在的习惯是任何通知相关的改动都会先在真机上手动跑一遍完整的订阅、推送、点击、跳转流程模拟器上看到的不算数。这个流程走顺了剩下的就是长时间的到达率观察和持续微调了。