用户切到微信回了三句,再切回来——Notification API 已经连发五条弹窗,遮住按钮、盖住表单、打断操作。这不是设计,是失控。
Vue 3 的 ref 和 watch 组合式 API 本该帮我们管住这事,但多数人直接在 onMounted 里调 Notification.requestPermission(),再用 new Notification() 推送,压根没存状态、不判 visibility、也不攒队列。结果就是:页面不可见时通知静默丢失;切回瞬间所有积压消息像推土机一样涌上来。
后台标签页切回时的痛点——消息聚合与免打扰的真实需求
浏览器对通知的限制很实在:标签页不可见(document.hidden === true)时,Chrome 会静默丢弃部分通知(尤其高频触发),Firefox 则可能延迟投递但不保证顺序。而 事件本身不带上下文——你只知道“它回来了”,但不知道“它错过了哪几条”。
更麻烦的是权限链路:用户第一次点「拒绝」后, 永远卡在 'denied',后续调用 new Notification() 直接静默失败,控制台连 warning 都不打。没人告诉你,这和 document.hasFocus()、 是两套独立状态系统。
我们真正要的不是“发通知”,而是“在对的时机、以对的方式、把对的消息交到用户眼前”。其余的,都是裸调 API 留下的补丁债。

用组合式API把两套独立系统焊在一起
先把散装的 API 拽进一个函数里,是组合式 API 最朴素的用法。不是造轮子,是把 和 这两套互不通信的系统,用 Vue 3 的 ref 和 watch 焊在一起。
只做一件事:挂载 监听,吐出一个 ref<boolean> 表示页面是否可见。就这么简单。但关键在于退出时得用 把监听摘掉——我见过不止一个项目把监听挂在 window 上忘了清理,导致组件销毁后还在触发回调,派发一个已经不存在的状态。
import { ref, onMounted, onBeforeUnmount } from 'vue'
export function usePageVisibility() {
const isVisible = ref(!document.hidden)
const handleVisibilityChange = () => {
isVisible.value = !document.hidden
}
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange)
})
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
return { isVisible }
}
稍微麻烦点。权限请求是异步的, 是静态字符串但浏览器不提供变化事件,你得自己兜住 'default' 到 'granted' 的过渡。我习惯把权限状态也做成 ref,这样下游组件可以 watch 它决定要不要展示引导按钮。
消息队列是核心。页面不可见时,所有 new Notification() 调用不直接执行,而是推进一个数组里存着。等 isVisible 从 false 变回 true 时,再按 FIFO 顺序一条条吐出来。这样做的好处是避免 Chrome 在后台静默丢弃通知——你攒着,等用户切回来一次性给他看,而不是让他以为消息丢了。
import { ref, watch } from 'vue'
import { usePageVisibility } from './usePageVisibility'
export function useNotification() {
const permission = ref<NotificationPermission>(Notification.permission)
const queue = ref<{ title: string; options?: NotificationOptions }[]>([])
const { isVisible } = usePageVisibility()
const requestPermission = async () => {
const result = await Notification.requestPermission()
permission.value = result
}
const notify = (title: string, options?: NotificationOptions) => {
if (permission.value !== 'granted') return
if (!isVisible.value) {
queue.value.push({ title, options })
return
}
new Notification(title, options)
}
watch(isVisible, (visible) => {
if (visible && queue.value.length > 0) {
queue.value.forEach(({ title, options }) => {
new Notification(title, options)
})
queue.value = []
}
})
return { permission, requestPermission, notify }
}
这段代码有一个隐藏的「免打扰」开关:外部可以传入一个 silent 参数,当它为 true 时,即使页面可见也不弹通知,只写入队列。用户手动开启免打扰后,切回页面看到的是队列计数,而不是糊一脸弹窗。
这种封装当然不是万能药——你把标签页晾那半小时,切回来一看队列里攒了二十条通知,浏览器照样会给你折叠成一行“此网站已隐藏 N 条通知”。但至少选择权拿回自己手里了,要不要弹、怎么弹、弹几条,由你的代码说了算,而不是让浏览器随手把消息扔进黑洞。
实战代码:消息聚合与免打扰模式
上一节的 已经能攒队列、切回时批量发——但还缺个“开关”。真正的免打扰不是不收消息,是收了不响,且能一眼看到「你漏掉了 7 条」。
加个 dndMode 响应式开关
它得是 ref,得能被组件外置控制(比如从用户设置页同步过来),还得影响 notify 的行为逻辑:
const dndMode = ref(false)
const notify = (title: string, options?: NotificationOptions) => {
if (permission.value !== 'granted') return
if (dndMode.value || !isVisible.value) {
queue.value.push({ title, options })
return
}
new Notification(title, options)
}
注意:这里没用 watch 监听 dndMode,因为它的语义是「当前是否静默」,直接参与判断比响应式触发更直白。
页面切回时别狂轰滥炸
浏览器对同一来源的高频 new Notification() 调用会折叠。我们改用计数提示 + 手动聚合:
- 切回时清空队列,但先统计数量;
- 只发一条带 badge 的聚合通知(
badge字段需传图标路径); - 点击后跳转消息中心,展示全部原始条目。
这比硬塞二十个弹窗体面得多——也更符合现代桌面端习惯。
权限状态要可观察、可重试
是只读字符串,但用户可能中途在系统设置里关掉。我们加个定时轮询(间隔 30s)检查 Notification.permission !== permission.value,触发 watch 回调供上层处理降级逻辑。
封装这事,从来不是写完就完。它得能被误操作、被系统干预、被用户反悔——然后默默扛住。
上线前必须踩的几处暗桩
把 notify 和 isVisible 封装完,你以为就能上线了?太天真。浏览器跟用户联手给你埋了好几颗暗雷,代码跑起来没错,但体验能直接翻车。
权限得等手点下去才敢要
Chrome 120+ 之后,Notification.requestPermission() 在非用户手势上下文里直接 resolve 为 'denied'。别在 onMounted 里偷偷调,更别用 setTimeout 模拟点击。我们改在「设置页开启通知」按钮的 click 回调里触发,加个 if (permission.value === 'default') 守门,既合规,又避免白跑一次。
免打扰开关得能记住人
dndMode 是 ref,但 localStorage 不是。我们用 useStorage('dndMode', false) 封装一层——注意传的是布尔值,不是字符串;否则下次读出来是 'false',真值判断就翻车了。VueUse 的 useStorage 默认用 JSON 序列化,这点很省心。
队列不设限,内存迟早飘红
见过队列堆到 300+ 条还活着的标签页吗?我们加了 queue.value.length > 50 && queue.value.splice(0, queue.value.length - 50)。不是优雅,是保命。50 条上限参考了 Slack Web 版的截断策略,再往上,用户根本不会数。
兼容性不是可选项
老旧 Edge 18 不支持 ,Safari 15.4 之前 会卡在 'default' 不更新。所以初始化时先做两件事:if (!('Notification' in window)) 直接禁用通知模块;if (!('visibilityState' in document)) 则 fallback 到 setInterval(() => { /* 检查 document.hasFocus() */ }, 3000)。不完美,但比报错强。
别指望用户感激你少发了通知——但他们绝对记得你什么时候轰炸得最狠。这玩意儿跟代码一样,少即是多。





评论