用户切到微信回了三句,再切回来——Notification API 已经连发五条弹窗,遮住按钮、盖住表单、打断操作。这不是设计,是失控。

Vue 3 的 refwatch 组合式 API 本该帮我们管住这事,但多数人直接在 onMounted 里调 Notification.requestPermission(),再用 new Notification() 推送,压根没存状态、不判 visibility、也不攒队列。结果就是:页面不可见时通知静默丢失;切回瞬间所有积压消息像推土机一样涌上来。

后台标签页切回时的痛点——消息聚合与免打扰的真实需求

浏览器对通知的限制很实在:标签页不可见(document.hidden === true)时,Chrome 会静默丢弃部分通知(尤其高频触发),Firefox 则可能延迟投递但不保证顺序。而 事件本身不带上下文——你只知道“它回来了”,但不知道“它错过了哪几条”。

更麻烦的是权限链路:用户第一次点「拒绝」后, 永远卡在 'denied',后续调用 new Notification() 直接静默失败,控制台连 warning 都不打。没人告诉你,这和 document.hasFocus() 是两套独立状态系统。

我们真正要的不是“发通知”,而是“在对的时机、以对的方式、把对的消息交到用户眼前”。其余的,都是裸调 API 留下的补丁债。

Vue 3 composable function code snippet

用组合式API把两套独立系统焊在一起

先把散装的 API 拽进一个函数里,是组合式 API 最朴素的用法。不是造轮子,是把 这两套互不通信的系统,用 Vue 3 的 refwatch 焊在一起。

只做一件事:挂载 监听,吐出一个 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() 调用不直接执行,而是推进一个数组里存着。等 isVisiblefalse 变回 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)。不完美,但比报错强。

别指望用户感激你少发了通知——但他们绝对记得你什么时候轰炸得最狠。这玩意儿跟代码一样,少即是多。

参考与延伸阅读