Notification API介绍。- 关闭浏览器后,点击历史通知仍能打开站点并跳转目标页,如何实现。
1. 先说结论
只用 new Notification() 不够。要覆盖"旧通知点击跳转",必须:
- 发送阶段:优先
ServiceWorkerRegistration.showNotification() - 点击阶段:在
public/notification-sw.js监听notificationclick - 跳转策略:先找已有窗口并
focus(),没有再openWindow()
一句话:把点击处理从页面 JS 移到 Service Worker。
2. Notification API 参数
2.1 new Notification(title, options) 的核心参数
title:通知标题(必填)body:正文内容icon:大图标(建议 192x192 或 256x256)badge:小徽标(Android 常见,建议单色清晰图)tag:通知分组标识;相同tag会覆盖旧通知data:自定义数据载荷(本方案用来传url)requireInteraction:true表示通知不自动关闭(浏览器行为可能有差异)silent:是否静音(不同浏览器支持度不同)
示例(占位链接):
ts
new Notification('系统提醒', {
body: '您有一条待处理消息',
icon: 'https://example.com/assets/notify-icon.png',
badge: 'https://example.com/assets/notify-badge.png',
tag: 'todo-1001',
requireInteraction: true,
data: {
url: 'https://example.com/app/todo?id=1001'
}
})
2.2 常用事件
notification.onclick:页面存活时可用notification.onclose:通知关闭回调notification.onerror:创建或展示失败回调
页面被关闭后,
onclick不可靠,所以才需要 SW 的notificationclick。
2.3 权限相关 API
Notification.permission:default/granted/deniedNotification.requestPermission():请求授权(需要用户手势触发更稳)
3. 项目落地实现(3 步)
3.1 注册通知 Service Worker
文件:src/main.ts
ts
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/notification-sw.js').catch((error: unknown) => {
console.warn('[NotificationSW] register failed:', error)
})
})
}
作用:让浏览器知道通知点击事件由 public/notification-sw.js 接管。
3.2 统一封装通知发送(优先 SW,失败降级)
文件:src/composables/useBrowserNotification.ts
项目实现的关键点:
- 权限不是
granted直接拦截 - 先拿 SW registration,再
showNotification - 通过
data.url传跳转目标 - SW 发送失败再降级到
new Notification
核心片段:
ts
const notificationOptions: NotificationOptions = {
body: options.body,
icon: notificationIcon,
badge: notificationBadgeIcon,
requireInteraction: options.requireInteraction ?? false,
tag: options.tag,
data: {
url: options.clickUrl ?? ''
}
}
await registration.showNotification(options.title, notificationOptions)
3.3 在 SW 中处理点击(关键中的关键)
文件:public/notification-sw.js
js
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const targetUrl = String(event.notification?.data?.url || '').trim()
if (!targetUrl) return
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if (client.url === targetUrl && 'focus' in client) {
return client.focus()
}
}
return self.clients.openWindow(targetUrl)
})
)
})
这段逻辑保证:
- 有现成页面:聚焦现有页
- 没有页面:新开页并跳转
- 浏览器关闭后点击历史通知:依然可回站
4. 为什么"旧通知点击可跳转"
点击系统通知时,事件发给的是 Service Worker,不依赖页面是否还活着。
因此即使用户关了页面,只要 SW 生效,仍可完成 focus/openWindow。
5. 注意
- 使用 HTTPS 或
localhost,否则 Notification/SW 都可能不可用 clickUrl建议绝对地址,避免路由 base 造成解析偏差tag要按业务维度设计(例如module-item-123),防止通知刷屏- 对
denied状态给出 UI 引导,提示去浏览器设置中手动开启 requireInteraction行为在不同浏览器有差异,需实机验证
useBrowserNotification 全量源码
ts
import { computed, ref, type ComputedRef, type Ref } from 'vue'
import notificationBadgeIcon from '@/assets/images/notify-badge-placeholder.png'
import notificationIcon from '@/assets/images/notify-icon-placeholder.png'
type NotifyPermission = NotificationPermission | 'unsupported'
interface SendBrowserNotificationOptions {
title: string
body: string
clickUrl?: string
tag?: string
requireInteraction?: boolean
autoCloseMs?: number
onClick?: () => void
}
interface UseBrowserNotification {
message: Ref<string>
isSupported: Ref<boolean>
permissionState: Ref<NotifyPermission>
supportText: ComputedRef<string>
permissionLabel: ComputedRef<string>
requestNotifyPermission: () => Promise<void>
sendBrowserNotification: (options: SendBrowserNotificationOptions) => void
}
export const useBrowserNotification = (): UseBrowserNotification => {
const message = ref('等待操作')
const isSupported = ref<boolean>(typeof window !== 'undefined' && 'Notification' in window)
const permissionState = ref<NotifyPermission>(isSupported.value ? Notification.permission : 'unsupported')
const supportText = computed(() => (isSupported.value ? '是' : '否'))
const permissionLabel = computed(() => {
if (permissionState.value === 'unsupported') return '浏览器不支持'
if (permissionState.value === 'granted') return '已授权'
if (permissionState.value === 'denied') return '已拒绝'
return '未授权(default)'
})
const updatePermissionState = (): void => {
permissionState.value = isSupported.value ? Notification.permission : 'unsupported'
}
const requestNotifyPermission = async (): Promise<void> => {
if (!isSupported.value) {
message.value = '当前浏览器不支持 Notification API'
return
}
try {
const result = await Notification.requestPermission()
permissionState.value = result
message.value = `权限申请结果:${result}`
} catch (error) {
message.value = '申请通知权限失败,请稍后重试'
console.error('Notification.requestPermission failed:', error)
}
}
const getServiceWorkerRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) return null
try {
return await navigator.serviceWorker.getRegistration()
} catch {
return null
}
}
const sendBrowserNotification = (options: SendBrowserNotificationOptions): void => {
if (!isSupported.value) {
message.value = '当前浏览器不支持 Notification API'
return
}
updatePermissionState()
if (permissionState.value !== 'granted') {
message.value = '请先授权通知权限后再发送'
return
}
const autoCloseMs = options.autoCloseMs ?? 4000
;(async () => {
const registration = await getServiceWorkerRegistration()
if (registration) {
try {
const notificationOptions: NotificationOptions = {
body: options.body,
icon: notificationIcon,
badge: notificationBadgeIcon,
requireInteraction: options.requireInteraction ?? false,
tag: options.tag,
data: {
url: options.clickUrl ?? ''
}
}
await registration.showNotification(options.title, notificationOptions)
message.value = `通知已发送:${options.title}`
return
} catch (error) {
console.warn('ServiceWorker showNotification failed, fallback to page notification:', error)
}
}
try {
const notificationOptions: NotificationOptions = {
body: options.body,
icon: notificationIcon,
badge: notificationBadgeIcon,
requireInteraction: options.requireInteraction ?? false
}
// 不传 tag 时允许系统通知叠加显示;传 tag 时按 tag 覆盖同组通知
if (options.tag) {
notificationOptions.tag = options.tag
}
const notice = new Notification(options.title, notificationOptions)
const shouldAutoClose = !(options.requireInteraction ?? false)
const autoCloseTimer = shouldAutoClose
? window.setTimeout(() => {
notice.close()
}, autoCloseMs)
: null
notice.onclick = () => {
window.focus()
notice.close()
if (options.clickUrl) {
window.open(options.clickUrl, '_blank', 'noopener,noreferrer')
}
options.onClick?.()
message.value = '已点击通知,窗口已尝试聚焦'
}
notice.onclose = () => {
if (autoCloseTimer !== null) {
window.clearTimeout(autoCloseTimer)
}
}
notice.onerror = () => {
if (autoCloseTimer !== null) {
window.clearTimeout(autoCloseTimer)
}
message.value = '通知发送失败,请检查浏览器通知设置'
}
message.value = `通知已发送:${options.title}`
} catch (error) {
message.value = '创建通知失败,请检查浏览器设置'
console.error('Notification constructor failed:', error)
}
})().catch((error: unknown) => {
message.value = '创建通知失败,请检查浏览器设置'
console.error('sendBrowserNotification failed:', error)
})
}
return {
message,
isSupported,
permissionState,
supportText,
permissionLabel,
requestNotifyPermission,
sendBrowserNotification
}
}
public/notification-sw.js 全量源码
js
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const targetUrl = String(event.notification?.data?.url || '').trim()
if (!targetUrl) return
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if (client.url === targetUrl && 'focus' in client) {
return client.focus()
}
}
return self.clients.openWindow(targetUrl)
}),
)
})