去年年底接手一个 Vue 3 的移动端项目产品在评审会上提了个需求用户收到新消息时手机的通知栏里要弹出一条提示点一下能直接跳到对应的详情页。我当时心想这有什么难的new Notification()一行代码的事桌面端早就玩烂了。结果在电脑上 Chrome 里跑得飞起一上真机Android 端点击按钮直接白屏控制台甩给我一句Failed to construct Notification: Illegal constructor。折腾了两天才算把整条链路理清楚原来移动端的推送通知栏和桌面端根本不是一个玩法。这篇就把 Vue 项目里实现移动端通知栏的完整路径拆开讲核心是 Notification API 的两条调用分支、Service Worker 的注册与作用域、基于 Push API 加 VAPID 的服务端推送、通知点击后的路由落地以及一套页面内兜底的通知栏组件。桌面端同样通用反过来桌面端能跑的代码在手机上有时候跑不通这个坑我会重点说。不管你是 Vue 2 还是 Vue 3只要涉及网页往系统通知栏弹消息这件事下面这些内容都能直接抄作业。1. 桌面端一把跑通的代码为什么到了手机上直接抛异常1.1 Notification 构造函数与 showNotification 的分水岭先说清楚这件事的本质。浏览器里往系统通知栏弹消息有两条完全不同的路一条是页面上下文里的new Notification(title, options)另一条是通过 Service Worker 注册对象调用registration.showNotification(title, options)。桌面端 Chrome、Edge、Firefox 上这两条路都能走所以你随便写哪一条本地测都是通的这就造成了代码没问题的错觉。Android 上的 Chrome 出于体验一致性考虑把页面上下文的构造函数给禁掉了。原因是移动端的通知必须由系统统一管理弹出来的通知要能进通知中心、要能被系统聚合、要在后台也能收到页面级的构造函数做不到这些所以直接抛Illegal constructor。这不是 bug是设计如此。也就是说只要你的目标环境包含 Android 浏览器就必须走 Service Worker 那条路没有商量余地。注意new Notification()在 Android Chrome 上不是不支持是构造函数被废弃。你写try/catch包起来只能避免报错但通知依然弹不出来所以别指望靠降级绕过老老实实上 Service Worker。我后来把项目里所有直接new Notification的地方都换成了走navigator.serviceWorker.ready拿注册对象再调showNotificationAndroid 端立刻就正常了。桌面端因为依然支持这条路所以同一份代码不用做分支判断这是最省事的写法。1.2 浏览器兼容性对照谁支持哪条路径与其凭感觉猜不如直接对照下面这张表。这张表是我在项目里踩了一圈之后总结的包含了几种最常见的目标环境。运行环境new Notification()registration.showNotification()前置条件桌面 Chrome / Edge可用可用HTTPS 或 localhostAndroid Chrome抛 Illegal constructor可用需注册 Service WorkerHTTPSiOS Safari 16.4 已添加到主屏可用但能力有限可用必须是 PWA 添加到主屏iOS Safari 普通标签页不可用不可用系统限制无解应用内浏览器各类 App 内置 WebView通常不可用通常不可用需原生桥接HTTP 局域网地址测试不可用不可用非安全上下文从表里能看出两个关键结论。第一Android 必须用showNotification第二iOS 上如果你只是普通地在 Safari 标签页里打开网页那通知能力是完全不存在的用户必须先把这个页面添加到主屏幕变成一个独立的应用图标才谈得上通知。这个限制对产品设计影响很大很多需求方以为网页就能推实际上在 iOS 上需要引导用户多走一步。这个引导流程我后面会讲怎么做。至于各类 App 内置的 WebView比如你在某个社交软件或办公软件里点开的链接通知能力基本上是被阉割的页面上下文没有 Service Worker 的完整权限这条路走不通只能靠原生那边配合后面第 4 章会单独说。1.3 安全上下文与被忽略的用户手势有两个隐形的门槛能让你的代码一行都不报错但通知就是不弹。第一个是安全上下文。浏览器规定只有 HTTPS 或者 localhost / 127.0.0.1 才允许使用通知能力。我见过不少人用 Vite 的--host参数把开发服务器暴露成http://192.168.1.x:5173然后拿手机连同一 WiFi 访问结果通知死活弹不出来翻遍代码也找不到问题。原因就是这个 IP 地址不是安全上下文window.Notification直接就是undefined。解决办法要么配个本地 HTTPS 证书要么用内网穿透工具拿个临时域名要么干脆用chrome://inspect做端口转发把手机的 5173 映射到电脑的 localhost这样手机访问的就是安全上下文了。第二个是用户手势。Notification.requestPermission()必须在用户主动操作的调用栈里执行比如点击回调、触摸事件里。你要是放在mounted钩子里或者setTimeout里自动执行Chrome 会直接忽略这次请求权限状态停留在default看起来就像弹窗没弹出来。iOS 上更严格连pushManager.subscribe()都必须由用户手势触发否则直接抛错。所以正确的做法是在页面上放一个开启消息提醒的按钮用户点的时候再去申请权限和订阅这个按钮本身也成了告知用户的功能入口。1.4 权限申请被拒之后还能不能救Notification.permission有三个值default表示还没问过granted表示允许denied表示拒绝。这里有个特别容易被忽略的行为一旦用户点了拒绝状态变成denied之后再调用requestPermission()会立刻返回denied浏览器不会再弹任何窗口。很多同事调试的时候手滑点了一次拒绝然后死活等不到弹窗以为是代码问题其实是权限已经被永久记住了。应对方式有三层。第一层是把当前权限状态记下来比如存到localStorage或者 Pinia 里当状态是denied时按钮文案从开启消息提醒改成通知权限已被关闭点这里查看开启方法点击后弹一个自定义的说明弹窗图文告诉用户去哪里改——浏览器地址栏左侧的站点设置或者手机系统设置里的应用通知权限。第二层是不要把申请权限和业务动作绑死别在用户刚打开页面、还没理解这个站是干什么的时候就弹窗被拒的概率极高可以等用户完成一次核心操作之后再引导。第三层是永远准备好兜底方案就算权限是denied页面内的通知栏组件依然可以工作用户至少不会漏掉消息。提示Chrome 桌面端从某个版本开始对滥用通知的站点有惩罚机制如果用户频繁忽略你的权限弹窗浏览器可能会自动把它降级为静默拦截。所以申请权限这件事宁晚勿早宁少勿多。2. 把 Service Worker 装进 Vue 工程2.1 sw.js 的存放位置与注册入口Service Worker 是一个独立的脚本文件运行在浏览器后台跟主线程完全隔离不能访问 DOM但可以访问self.registration和self.clients。在 Vue 项目里最省事的做法是把它放到public目录下因为public里的文件会被原样复制到打包产物的根目录构建工具不会去处理它也不会给它加 hash 后缀。如果你的项目用 Vite路径就是public/sw.js打包后对应dist/sw.js。如果用 Vue CLI就是public/sw.js对应dist/sw.js完全一样。注册代码一般放在应用的入口文件里比如main.js或者一个专门的启动模块里。要注意是注册时机别在main.js顶部无条件执行最好是等应用挂载完成、页面空闲的时候再注册避免和首屏资源抢带宽。// src/main.js import { createApp } from vue import App from ./App.vue const app createApp(App) app.mount(#app) // 等首屏渲染完成后再注册避免影响加载性能 window.addEventListener(load, () { if (serviceWorker in navigator) { navigator.serviceWorker .register(${import.meta.env.BASE_URL}sw.js) .catch((err) console.warn(Service Worker 注册失败, err)) } })这里import.meta.env.BASE_URL是 Vite 提供的基础路径默认是/如果你在vite.config.js里配了base: /h5/它会自动变成/h5/这样注册路径就跟着对了。用 Vue CLI 的项目对应的是process.env.BASE_URL作用完全一样。2.2 部署到子目录时 scope 踩空的问题Service Worker 有个作用域的概念默认作用域是脚本所在目录。也就是说如果你的sw.js放在/h5/sw.js那它默认只能控制/h5/下的页面。这个规则本身很合理但它带来一个非常隐蔽的坑当你把sw.js放在子目录同时又想让它控制根路径时注册会直接失败。我遇到过一次真实案例项目部署在https://example.com/mobile/下面sw.js放在public/里一切正常。后来运维调整了静态资源目录结构sw.js被挪到了/mobile/static/sw.js结果通知全挂控制台报scope 超出允许范围。原因是脚本在/mobile/static/下默认作用域就是/mobile/static/而代码里想要/mobile/超出了脚本所在目录浏览器直接拒绝。解决办法很简单要么把sw.js放回足够靠上的目录要么在注册的时候显式指定 scope并且用一个Service-Worker-Allowed响应头来放宽限制。实践中我倾向于第一种把sw.js始终放在站点根目录或者基础路径的根上别乱挪。注册时也可以顺手把 scope 写清楚navigator.serviceWorker.register(${import.meta.env.BASE_URL}sw.js, { scope: import.meta.env.BASE_URL, updateViaCache: none // 让浏览器每次都去服务器检查脚本更新 })updateViaCache: none这个参数很多人不知道它的作用是忽略 HTTP 缓存每次注册都去服务器拉最新的sw.js。默认情况下浏览器会遵循 HTTP 缓存头如果服务器给sw.js返回了长时间的Cache-Control那你更新了 Service Worker 逻辑用户可能一整天都拿不到新版本。2.3 用组合式函数封装通知能力项目里通知的调用点会散落在各个页面直接写navigator.serviceWorker.ready.then(...)到处都是重复代码。我的做法是封装一个组合式函数把注册、权限、发送三件事收拢到一起对外只暴露一个简单的调用接口。// src/composables/useNotify.js import { ref, computed } from vue const supported typeof window ! undefined Notification in window serviceWorker in navigator const permission ref(supported ? Notification.permission : denied) export function useNotify() { const canAsk computed(() supported permission.value ! granted) const isDenied computed(() permission.value denied) async function requestPermission() { if (!supported) return unsupported // 已经是 granted 或 denied 时浏览器不会再弹窗 const result await Notification.requestPermission() permission.value result return result } async function show({ title, body, icon, badge, tag, data }) { if (!supported || permission.value ! granted) return false const options { body, icon: icon || ${import.meta.env.BASE_URL}icons/icon-192.png, badge: badge || ${import.meta.env.BASE_URL}icons/badge-72.png, tag, // 相同 tag 的通知会互相覆盖用于去重 renotify: Boolean(tag), // 覆盖时是否再次震动/提示 data: data || {} } // 统一走 showNotification桌面端同样支持无需分支 const registration await navigator.serviceWorker.ready await registration.showNotification(title, options) return true } return { supported, permission, canAsk, isDenied, requestPermission, show } }这段代码里有几个设计取舍值得说明。为什么不判断环境去走new Notification因为showNotification在桌面端现代浏览器上全部支持统一走一条路能减少分支也避免以后维护时忘了改某一支。为什么把supported定义成模块级常量而不是放在函数里因为它的值在一次页面生命周期内不会变放在外面可以避免重复计算同时多个组件引用时能共享同一个permission引用权限变化后界面能同步更新。icon和badge都拼了BASE_URL这是为了让子目录部署时图片路径不丢通知图标加载失败是很常见的问题基本都出在路径上。2.4 前台和后台两层通知策略真正上生产之后你会发现一味依赖系统通知栏体验并不好。用户正在你的页面上浏览突然系统通知栏又弹一条同样的消息等于同一个信息出现了两次很烦。我后来改成两层策略页面处于前台可见状态时走页面内的通知栏组件第 5 章会讲样式可控、能带操作按钮页面切到后台或者被关掉时才依赖 Service Worker 的系统通知。判断前台还是后台用document.visibilityState就够了。页面隐藏时它等于hidden切回来变成visible。可以在visibilitychange事件里维护一个状态标记发送通知前先看一眼。这里有个细节移动端浏览器切到后台之后setTimeout会被节流甚至冻结所以别指望靠定时器在后台做事情后台的活只能交给 Service Worker 和真正的服务端推送。还有一个去重问题。假设页面在后台时收到了服务端推送系统通知栏弹了一条用户点开页面前台的组件又弹了一条同样的消息。为了避免这种重复我给每条消息都带了一个业务侧的messageId前台组件用tag属性配合renotify: false系统通知那边也用同一个tag同一标识的通知会自动覆盖而不是叠加。这套规则我写在文档里前端和消息服务端共用同一份定义谁都不用猜。3. 服务端推送链路订阅上报、VAPID 与发送3.1 一份 subscription 里到底存了什么前面讲的都是通知怎么显示但通知的内容从哪来如果是页面自己触发的比如用户点了某个按钮那本地调用就行。但真正的推送场景是用户已经把页面关掉了服务端还得能把消息送到手机上这就需要 Push API 配合服务端的推送服务。流程是这样前端向浏览器申请一个推送订阅浏览器把它转发给对应厂商的推送服务拿回一个订阅对象前端把这个对象上报给你的服务端服务端拿着它向推送服务发一条加密消息推送服务负责把消息递送到设备设备的 Service Worker 被唤醒触发push事件在事件里调showNotification把消息显示到通知栏。订阅对象长这样{ endpoint: https://推送服务提供的唯一地址/xxxxxxxx, expirationTime: null, keys: { p256dh: 客户端的公钥字符串, auth: 双方约定的认证密钥 } }endpoint是这个订阅的唯一投递地址不同浏览器对应的推送服务地址不一样但格式和用法是一致的前端不需要关心它具体是谁家的原样上报就行。keys里的两个值是端到端加密用的p256dh是浏览器生成的公钥auth是一个认证密钥。服务端用这两个值把消息内容加密之后再发给推送服务推送服务只负责搬运看不到明文内容。这个设计的意义在于中间的推送服务是第三方内容加密之后它无法读取安全性由加密保证所以推送内容里不要放敏感信息这种担心其实是多余的但依然建议只推必要的提示文案正文留在接口里让客户端自己去拉。前端订阅的代码需要把 VAPID 公钥从 base64 转成Uint8Array这一步很多新手会漏掉直接传字符串会报applicationServerKey 格式错误// src/utils/push.js function urlBase64ToUint8Array(base64String) { const padding .repeat((4 - (base64String.length % 4)) % 4) const base64 (base64String padding).replace(/-/g, ).replace(/_/g, /) const raw window.atob(base64) return Uint8Array.from([...raw].map((c) c.charCodeAt(0))) } export async function subscribePush(api) { if (!(serviceWorker in navigator) || !(PushManager in window)) return null const registration await navigator.serviceWorker.ready // 已有订阅直接复用避免重复创建导致服务端存一堆废地址 let subscription await registration.pushManager.getSubscription() if (!subscription) { subscription await registration.pushManager.subscribe({ userVisibleOnly: true, // 必须为 true表示每条推送都必须可见 applicationServerKey: urlBase64ToUint8Array( import.meta.env.VITE_VAPID_PUBLIC_KEY ) }) } await api.post(/api/push/subscribe, subscription.toJSON()) return subscription }userVisibleOnly: true这个参数不是可选的。浏览器要求订阅者承诺每一条推送都会产生一条用户可见的通知不允许偷偷在后台发静默消息。如果你在push事件里不调showNotification浏览器会在几次之后取消你的订阅并提示站点在后台偷偷运行。所以 Service Worker 里收到 push 事件的第一件事就是弹通知先弹再说。3.2 VAPID 密钥的生成与保管VAPID 是一套用来标识谁在发推送的机制。服务端每次发送时用自己的私钥对请求签名推送服务用对应的公钥验签从而确认请求来自合法的站点防止有人拿别人的订阅地址乱推。公钥给前端私钥留在服务端两边配对使用。生成密钥对用web-push这个库自带的命令就够了npm install web-push --save npx web-push generate-vapid-keys --json执行之后会输出一对 base64 字符串公钥写进前端的环境变量VITE_VAPID_PUBLIC_KEY私钥写进服务端的.env绝对不能提交到代码仓库。这里有个非常重要的经验一对 VAPID 密钥要长期固定使用中途更换会导致所有已存在的订阅全部失效。因为浏览器在订阅时把公钥写进了订阅信息里服务端换了私钥签名就对不上推送服务会直接返回 403。我踩过这个坑某次部署时环境变量忘了同步新机器上重新生成了一对密钥结果已经订阅的用户全部收不到消息排查了半天才定位到。所以正确做法是把这对密钥当作和数据库密码同等重要的配置在部署平台上配置一次所有实例共用并且做好备份。同时在前端加一层保险拿服务端下发的公钥和本地订阅时用的公钥做比对如果不一致就退订重订。3.3 服务端最小可用实现服务端只要能存订阅、能发消息就够了用 Node Express web-push 写一个最小实现大概三四十行。存储上用数据库表按endpoint建唯一索引最合适一个用户可能有多台设备、多个浏览器所以是一对多的关系。// server/push.js const webpush require(web-push) webpush.setVapidDetails( mailto:pushyour-domain.com, // 联系方式推送服务异常时会用它通知你 process.env.VAPID_PUBLIC_KEY, process.env.VAPID_PRIVATE_KEY ) async function sendPush(subscription, payload) { try { await webpush.sendNotification( subscription, JSON.stringify({ title: payload.title, body: payload.body, icon: payload.icon, tag: payload.tag, data: { url: payload.url, messageId: payload.messageId } }), { TTL: 3600, // 消息最长保留一小时超时丢弃 urgency: high // 高优先级移动端在省电模式下也会尽快投递 } ) } catch (err) { if (err.statusCode 404 || err.statusCode 410) { await Subscription.destroy({ where: { endpoint: subscription.endpoint } }) } else { console.error(推送失败, err.statusCode, err.body) } } }TTL这个参数值得展开说说。它表示这条消息在推送服务那里最多排队多久单位秒。如果设备正好离线推送服务会帮你在TTL时间内重试超时就把消息丢掉。对于你有一条新消息这种有时效性的提示一个小时比较合适如果是账号异地登录这种必须送达的告警可以设得长一点比如 86400。但要注意TTL太长会导致用户第二天早上开机收到一堆昨天的旧通知体验很糟所以要根据业务场景权衡。urgency是给移动端省电策略用的。手机在息屏或者低电量模式下会对后台任务做严格限制标记为high的消息会被优先唤醒投递代价是更耗电。提醒类的消息用high营销类的建议用normal别所有消息都标high那等于没标。3.4 订阅失效与错误码处理服务端推送必然要处理失败。除了上面代码里的 404 和 410还有几个常见的状态码要认识。状态码含义处理方式201投递成功正常返回404 / 410订阅已失效服务端已删除该订阅从数据库删除这条订阅记录401 / 403VAPID 签名验证失败检查密钥配置是否一致密钥是否被更换413载荷过大减小推送内容体积建议控制在 2KB 以内429请求过于频繁加队列做限流退避重试实际运维中最常见的是 410。用户卸载了浏览器、清了数据、换了手机、长期不使用导致浏览器主动清理订阅都会让订阅失效。不能因为一条失败就整个任务崩掉必须逐条 try/catch失败就清理下一轮不再重试。另外建议在数据库里给订阅记录加一个lastSuccessAt字段长期没有成功记录的订阅可以定期清理避免表里堆积大量僵尸记录每次群发都要空跑一遍。注意推送内容体积有限制各推送服务不太一样但普遍在 4KB 左右。超了会返回 413。所以别把整篇文章塞进 payload只推标题和跳转所需的 ID点开之后客户端自己调接口拿详情这样既快又稳。4. 点击通知之后落地页跳转与窗口聚焦4.1 notificationclick 里 clients 的两种结果通知如果点不开那整个功能等于白做。点击逻辑写在 Service Worker 的notificationclick事件里通过self.clients来处理。这里要分两种情况如果用户的页面还开着我们希望能复用已有的窗口直接导航到目标页并把它切到前台如果页面已经关了就需要新开一个窗口。// public/sw.js self.addEventListener(install, () self.skipWaiting()) self.addEventListener(activate, (event) event.waitUntil(self.clients.claim())) self.addEventListener(push, (event) { let payload { title: 新消息, body: } if (event.data) { try { payload { ...payload, ...event.data.json() } } catch (e) { payload.body event.data.text() } } // 必须同步调用 showNotification不能在异步回调里才调用 event.waitUntil( self.registration.showNotification(payload.title, { body: payload.body, icon: payload.icon || /icons/icon-192.png, badge: /icons/badge-72.png, tag: payload.tag, renotify: Boolean(payload.tag), data: payload.data || {} }) ) }) self.addEventListener(notificationclick, (event) { event.notification.close() const url (event.notification.data event.notification.data.url) || / event.waitUntil( self.clients .matchAll({ type: window, includeUncontrolled: true }) .then((clientList) { for (const client of clientList) { // 只接管同源窗口避免误操作到其他标签页 if (new URL(client.url).origin self.location.origin) { if (navigate in client) client.navigate(url) return client.focus() } } return self.clients.openWindow(url) }) ) })两个细节容易翻车。第一matchAll一定要带includeUncontrolled: true否则那些还没被当前 Service Worker 接管的页面找不到结果就是每次都新开窗口用户会看到一堆重复标签。第二event.waitUntil包裹的范围要正确showNotification需要放进waitUntil里而在push事件中如果先await了一些异步操作再去弹通知浏览器有可能在这期间把 Service Worker 判定为空闲而回收导致通知不弹。稳妥写法是先把通知弹出来再去处理其他异步逻辑。4.2 把 data 里的路径交给 Vue Routernotification.data是一个可以放任意可序列化对象的字段它就是通知和页面之间传递信息的通道。我的习惯是只放一个url和一个messageIdurl指向应用内的路由路径比如/detail/1024。Service Worker 负责把窗口导航到这个路径剩下的交给 Vue Router 处理。如果项目用的是 hash 模式路由路径就要写成/#/detail/1024因为client.navigate接受的是完整的相对路径hash 部分不能丢。用 history 模式的话直接写/detail/1024就行但前提是服务器配置了 history 模式的回退规则把未匹配的路径都指向index.html否则navigate过去会 404。跳进去之后页面通常需要拉取详情数据这时候有个体验问题用户点击通知到看到内容之间会有一次接口请求中间是白屏。我的做法是在路由守卫里判断如果带着messageId参数进来先在本地缓存里查这个 ID 对应的摘要信息有就先渲染一个骨架同时后台请求完整数据再替换。这样用户感知到的等待时间会短很多。还有一个进阶玩法把路由路径和通知的tag关联起来如果用户已经点了某条通知并进入了详情页后续同一业务的消息推送可以先查一下用户当前在哪个页面如果已经在对应页面里就只更新页面内的组件状态不再弹通知。这个判断需要页面把当前路由上报给 Service Worker可以用postMessage实现稍微复杂一点但能显著减少打扰。4.3 原生壳与厂商通道下的桥接思路有一部分项目不是纯网页而是跑在原生 App 的 WebView 里。这种情况下网页的通知能力基本不可用真正的推送由原生那边通过厂商通道完成网页只负责接收点击后的跳转和把用户身份告诉原生。通用的桥接思路是这样网页在初始化时通过约定的 JSBridge 把当前用户的标识推给原生原生拿这个标识去注册推送原生收到通知并且用户点击之后把通知里携带的业务参数比如一个页面路径和一个资源 ID通过桥接方法回传给网页网页接到消息后调用 Vue Router 跳转到对应页面。整个链路上网页只参与两头中间的推送投递完全由原生负责这也是为什么这类项目里网页侧看不到任何推送相关代码。实现的时候要注意几个坑。桥接方法是异步的网页必须在桥接就绪之后才能调用通常原生会在页面加载完后注入一个全局对象或者触发一个自定义事件网页要监听这个就绪信号别在mounted里直接调用那时候桥可能还没准备好。另外原生回传的参数需要做一次合法性校验路径必须在应用内路由白名单里否则恶意构造的链接可能跳到不该去的地方。最后安卓和 iOS 的桥接实现细节不同但接口设计应该统一让上层的业务代码不感知平台差异。5. 兜底方案页面内通知栏组件怎么写5.1 哪些环境必须降级前面讲了那么多但真实世界里有一大批用户根本用不了系统通知。比如在各类 App 内置浏览器里打开的页面、iOS 上没有添加到主屏幕的普通标签页、HTTP 环境下的测试页面、以及明确拒绝过授权的那部分用户。如果这些场景下什么都不做功能就等于对这部分用户失效了。我的方案是在应用内实现一个页面内通知栏从屏幕顶部滑下来一条卡片样式和系统通知接近点击后跳转对应页面。它虽然不能穿透到系统层但用户只要停留在你的页面上就能看到作为兜底已经足够了。而且因为它完全由我们自己控制可以做得比系统通知更丰富加操作按钮、加倒计时进度条、加缩略图、加分组折叠。判断是否需要降级用之前封装好的supported和permission就够了。我的策略是双通道并行有能力弹系统通知就弹系统通知没有能力或者权限被拒就一律走页面内组件两边的数据模型完全一致只是渲染位置不同。5.2 组件结构、队列与去重组件本身不复杂关键是队列管理。同一时间可能来好几条消息不能全堆在屏幕上我用的是一个最多显示三条的队列超出部分排队等待。template Teleport tobody div classnotify-layer TransitionGroup namenotify-slide div v-foritem in visibleList :keyitem.id classnotify-card clickhandleClick(item) img classnotify-card__icon :srcitem.icon alt / div classnotify-card__main p classnotify-card__title{{ item.title }}/p p classnotify-card__body{{ item.body }}/p /div button classnotify-card__close click.stopremove(item.id)×/button div classnotify-card__progress :style{ width: progress % } / /div /TransitionGroup /div /Teleport /template script setup import { ref, computed, onMounted, onUnmounted } from vue import { useRouter } from vue-router const MAX_VISIBLE 3 const DURATION 4000 const queue ref([]) const visibleList computed(() queue.value.slice(0, MAX_VISIBLE)) const router useRouter() function push(item) { // 同 tag 的消息只保留最新一条避免刷屏 if (item.tag) { const idx queue.value.findIndex((x) x.tag item.tag) if (idx -1) queue.value.splice(idx, 1) } const record { ...item, id: item.id || ${Date.now()}-${Math.random()} } queue.value.push(record) setTimeout(() remove(record.id), DURATION) } function remove(id) { const idx queue.value.findIndex((x) x.id id) if (idx -1) queue.value.splice(idx, 1) } function handleClick(item) { remove(item.id) if (item.url) router.push(item.url) } defineExpose({ push }) /script队列去重用的是tag同类型的消息比如来自同一个会话的聊天消息只保留最新一条避免用户一进页面看到五条同样的提示。这个逻辑和系统通知的tag覆盖行为是一致的两套通道用同一套规则产品侧的心智模型统一不用解释两遍。5.3 动画、生命周期与无障碍细节移动端的通知栏动画有两个原则进场要快退场要自然。进场我用transform: translateY(-100%)到0持续时间 220 毫秒左右带一点轻微的过冲退场直接向上滑出200 毫秒。用TransitionGroup的时候记得给.notify-slide-leave-active加position: absolute否则元素在退出的瞬间会让后面的卡片跳位看起来一顿一顿的。层级上要小心。这个组件通过Teleport挂到body上z-index给一个比较高的值比如 9999但别盲目给 999999某些第三方弹窗组件的层级也很高容易打架。更好的做法是在项目里统一维护一套 z-index 变量通知栏用其中最高的一档。触摸区域也要注意。手机顶部有状态栏和刘海卡片距离顶部至少要留出安全区的高度用env(safe-area-inset-top)处理不然在全面屏手机上文字会被状态栏挡住。卡片整体的高度建议不低于 64 像素这是拇指点击的舒适下限关闭按钮用click.stop阻止事件冒泡避免点关闭的时候误触发跳转。如果页面有明显的滚动区域通知栏出现时不应该阻塞用户操作所以别加全屏遮罩只让卡片本身可点就行。内容上还有一个细节值得做给每条通知加一个不超过 4 秒的自动消失倒计时鼠标或者手指悬停在卡片上时暂停计时。这在 PC 端尤其有用用户正在阅读的时候通知突然消失会很恼火。实现方式是用一个定时器记录剩余时间mouseenter时清除定时器mouseleave时按剩余时间重新启动。6. 联调与线上排查的实操清单6.1 用 DevTools 手动触发一次 push开发阶段不可能每次都真去服务端发一条推送DevTools 提供了很顺手的手动触发入口。打开 Chrome 开发者工具切到 Application 面板左侧找到 Service Workers在顶部能看到一个 Push 输入框填上一段 JSON 字符串点 Push 按钮你的 Service Worker 的push事件就会被触发效果和真实推送几乎一致。这个入口有个前提页面必须已经注册成功 Service Worker并且状态是 activated。如果列表里看不到你的脚本多半是注册路径不对或者作用域不匹配先去 Network 面板看看sw.js有没有正常返回 200。另外提醒一句DevTools 里还有 Offline 和 Update on reload 两个勾选项Update on reload打开之后每次刷新都会重新拉取并激活最新脚本开发阶段强烈建议勾上能省掉大量改了没生效的困惑。测试点击跳转的时候可以在 DevTools 的 Service Workers 面板里找到你注册的脚本用控制台调用self.registration.showNotification(测试, { data: { url: /detail/1 } })然后去点系统弹出的那条通知观察窗口行为。要注意的是如果你是在 DevTools 打开的状态下点的通知聚焦行为可能会被 DevTools 窗口干扰最好把 DevTools 关掉再测一次确认最终效果。6.2 sw.js 改了不生效的缓存陷阱这个问题几乎每个做 Service Worker 的人都遇到过。原因通常是两个一是浏览器对sw.js本身做了 HTTP 缓存二是新的 Service Worker 处于 waiting 状态没有被激活。前者要靠服务器配置解决给sw.js的响应加上Cache-Control: no-cache让浏览器每次都去校验同时在注册时传updateViaCache: none。后者要靠代码解决。默认情况下新的 Service Worker 要等到所有由旧脚本控制的页面都关闭之后才会激活这在单页应用里几乎等于永远。所以要在install事件里调用self.skipWaiting()在activate事件里调用self.clients.claim()让新脚本立刻接管。这两个调用我几乎是写好就加上从没后悔过。self.addEventListener(install, () self.skipWaiting()) self.addEventListener(activate, (event) { event.waitUntil( (async () { // 清理旧版本缓存这里按自己的缓存命名规则处理 const keys await caches.keys() await Promise.all(keys.filter((k) k ! v2).map((k) caches.delete(k))) await self.clients.claim() })() ) })还建议在页面上监听controllerchange事件当检测到 Service Worker 换了新版本时给用户一个提示条应用已更新刷新后生效。这个提示条可以复用第 5 章的页面内通知栏组件一举两得。6.3 通知栏验证不弹出时的排查顺序点了按钮但通知栏没反应是最常见的求助我整理了一套按顺序排查的清单基本能覆盖九成的情况。看权限状态。控制台执行Notification.permission返回denied说明用户拒绝过只能引导去设置里改返回default说明权限申请根本没被触发多半是没放在用户手势里。看安全上下文。控制台执行window.isSecureContext返回false说明当前不是 HTTPS 也不是 localhost通知能力直接不可用。看 API 是否存在。控制台执行serviceWorker in navigator和Notification in window任何一个是false说明当前环境不支持直接走兜底。看 Service Worker 状态。Application 面板里确认脚本是 activated 而不是 waiting 或 redundant。看系统级设置。电脑上要检查系统的通知总开关、专注助手、勿扰模式手机上要检查应用的通知权限尤其是国产手机系统对通知有额外的分级管理允许通知下面还有横幅通知的开关很多人只开了前者。看是否有异常抛出。前面几步都正常但还是不弹多半是showNotification的 Promise 被 reject 了加上.catch把错误打出来常见的是图标路径 404 或者选项字段类型不对。这六步走下来还找不到问题的通常就是业务代码逻辑的问题了比如权限申请成功后忘记重新赋值状态导致show方法一直认为权限不足直接返回。6.4 上线前的真机核对表最后把上线前必须过一遍的项目列成表这部分内容我吃过亏强烈建议照着做。核对项检查方式常见问题HTTPS 已启用手机访问确认地址栏无警告HTTP 环境下一切通知能力失效sw.js 可访问且无强缓存直接访问 sw.js 的完整地址被 CDN 缓存了旧版本图标路径在子目录下正确真机看通知左侧图标是否显示用了./相对路径导致 404Android 真机弹出正常用真实安卓设备测一次只在桌面端测过iOS 已添加主屏后测试添加到主屏再从图标打开普通标签页下无法弹通知点击通知跳转正确分别测页面开着和关着两种情况每次新开窗口造成重复标签订阅上报成功看服务端数据库是否有记录endpoint 字段长度不够被截断推送失败有清理逻辑手动造一个失效订阅测试失败没有捕获导致任务中断通知权限被拒时界面有引导手动拒绝后看按钮文案用户不知道怎么恢复我把这张表直接贴在了项目 README 里每次发版前过一遍比事后救火省事得多。特别是图标路径这一项做过子目录部署的项目几乎都踩过通知能弹出来但左边是空白用户会以为是什么可疑消息。这套东西我从最初的一行new Notification演进到现在中间改了三四个版本。印象最深的一次是上线第二天收到反馈说安卓用户完全收不到消息查了半天发现是运维把 VAPID 私钥配错了环境签名对不上推送服务全部返回 403。从那以后我在服务端加了一个启动自检用固定的测试订阅在启动时发一条自测消息失败就直接报警虽然会多花几秒钟启动时间但至少不会等到用户投诉才发现。另一个体会是通知这件事一定要尽早和产品对齐哪些场景该推、一天最多推几条技术上能做到不等于应该做用户被骚扰几次之后直接关掉通知权限这个渠道就永久废了再想找回来非常难。