去年年底接手一个 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拿注册对象再调showNotification,Android 端立刻就正常了。桌面端因为依然支持这条路,所以同一份代码不用做分支判断,这是最省事的写法。
1.2 浏览器兼容性对照:谁支持哪条路径
与其凭感觉猜,不如直接对照下面这张表。这张表是我在项目里踩了一圈之后总结的,包含了几种最常见的目标环境。
| 运行环境 | new Notification() | registration.showNotification() | 前置条件 |
|---|---|---|---|
| 桌面 Chrome / Edge | 可用 | 可用 | HTTPS 或 localhost |
| Android Chrome | 抛 Illegal constructor | 可用 | 需注册 Service Worker,HTTPS |
| iOS 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:push@your-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 / 403 | VAPID 签名验证失败 | 检查密钥配置是否一致,密钥是否被更换 |
| 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 Router
notification.data是一个可以放任意可序列化对象的字段,它就是通知和页面之间传递信息的通道。我的习惯是只放一个url和一个messageId,url指向应用内的路由路径,比如/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 to="body"> <div class="notify-layer"> <TransitionGroup name="notify-slide"> <div v-for="item in visibleList" :key="item.id" class="notify-card" @click="handleClick(item)" > <img class="notify-card__icon" :src="item.icon" alt="" /> <div class="notify-card__main"> <p class="notify-card__title">{{ item.title }}</p> <p class="notify-card__body">{{ item.body }}</p> </div> <button class="notify-card__close" @click.stop="remove(item.id)">×</button> <div class="notify-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 缓存了旧版本 |
| 图标路径在子目录下正确 | 真机看通知左侧图标是否显示 | 用了./相对路径导致 404 |
| Android 真机弹出正常 | 用真实安卓设备测一次 | 只在桌面端测过 |
| iOS 已添加主屏后测试 | 添加到主屏再从图标打开 | 普通标签页下无法弹通知 |
| 点击通知跳转正确 | 分别测页面开着和关着两种情况 | 每次新开窗口造成重复标签 |
| 订阅上报成功 | 看服务端数据库是否有记录 | endpoint 字段长度不够被截断 |
| 推送失败有清理逻辑 | 手动造一个失效订阅测试 | 失败没有捕获导致任务中断 |
| 通知权限被拒时界面有引导 | 手动拒绝后看按钮文案 | 用户不知道怎么恢复 |
我把这张表直接贴在了项目 README 里,每次发版前过一遍,比事后救火省事得多。特别是图标路径这一项,做过子目录部署的项目几乎都踩过,通知能弹出来但左边是空白,用户会以为是什么可疑消息。
这套东西我从最初的一行new Notification演进到现在,中间改了三四个版本。印象最深的一次是上线第二天收到反馈说"安卓用户完全收不到消息",查了半天发现是运维把 VAPID 私钥配错了环境,签名对不上,推送服务全部返回 403。从那以后我在服务端加了一个启动自检,用固定的测试订阅在启动时发一条自测消息,失败就直接报警,虽然会多花几秒钟启动时间,但至少不会等到用户投诉才发现。另一个体会是,通知这件事一定要尽早和产品对齐"哪些场景该推、一天最多推几条",技术上能做到不等于应该做,用户被骚扰几次之后直接关掉通知权限,这个渠道就永久废了,再想找回来非常难。