Vue 3 与 Web Push API
概述
Web Push API 允许服务器向用户推送通知,即使用户没有打开网站。在 Vue 3 应用中集成 Web Push API 可以增强用户参与度,及时向用户传递重要信息,如消息通知、更新提醒、促销活动等。
核心知识
1. Web Push API 基本概念
- 作用:允许服务器向用户推送通知,无需用户打开网站
- 组成部分:
- Push Service:浏览器提供的推送服务(如 Firebase Cloud Messaging、Mozilla Push Service)
- Service Worker:在后台接收推送消息
- VAPID Keys:用于服务器和推送服务之间的身份验证
- Push Subscription:包含推送端点和加密密钥的订阅信息
2. Web Push API 工作流程
- 用户允许网站发送通知
- 浏览器生成推送订阅信息
- 网站将订阅信息发送到服务器保存
- 服务器使用订阅信息向推送服务发送消息
- 推送服务将消息传递给用户设备
- 设备上的 Service Worker 接收并显示通知
3. VAPID 密钥生成
VAPID(Voluntary Application Server Identification)密钥用于服务器身份验证。可以使用以下方式生成:
# 使用 web-push 库生成 VAPID 密钥
npm install -g web-push
web-push generate-vapid-keys4. 前端实现(Vue 3 + Service Worker)
4.1 注册 Service Worker
// main.js
import { createApp } from 'vue'
import App from './App.vue'
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('/service-worker.js')
console.log('Service Worker 注册成功:', registration)
} catch (error) {
console.error('Service Worker 注册失败:', error)
}
})
}
createApp(App).mount('#app')4.2 Service Worker 实现
// service-worker.js
self.addEventListener('push', (event) => {
const data = event.data.json()
const options = {
body: data.body,
icon: '/icon.png',
badge: '/badge.png',
vibrate: [100, 50, 100],
data: {
url: data.url || self.location.origin
}
}
event.waitUntil(
self.registration.showNotification(data.title, options)
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(
clients.openWindow(event.notification.data.url)
)
})4.3 Vue 3 组件中请求通知权限并订阅
<template>
<div>
<button @click="subscribeToPush" v-if="!isSubscribed">
允许推送通知
</button>
<button @click="unsubscribeFromPush" v-else>
取消推送通知
</button>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const isSubscribed = ref(false)
const VAPID_PUBLIC_KEY = 'YOUR_VAPID_PUBLIC_KEY'
// 将 Base64 URL 编码的字符串转换为 Uint8Array
const urlBase64ToUint8Array = (base64String) => {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
onMounted(async () => {
if ('serviceWorker' in navigator && 'PushManager' in window) {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
isSubscribed.value = !!subscription
}
})
const subscribeToPush = async () => {
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
})
// 将订阅信息发送到服务器
await sendSubscriptionToServer(subscription)
isSubscribed.value = true
} catch (error) {
console.error('订阅推送失败:', error)
}
}
const unsubscribeFromPush = async () => {
try {
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription()
if (subscription) {
await subscription.unsubscribe()
await removeSubscriptionFromServer(subscription)
isSubscribed.value = false
}
} catch (error) {
console.error('取消订阅失败:', error)
}
}
const sendSubscriptionToServer = async (subscription) => {
// 实现将订阅信息发送到服务器的逻辑
await fetch('/api/push/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(subscription)
})
}
const removeSubscriptionFromServer = async (subscription) => {
// 实现从服务器移除订阅信息的逻辑
await fetch('/api/push/unsubscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ endpoint: subscription.endpoint })
})
}
</script>5. 后端实现(Node.js)
// 使用 web-push 库发送推送消息
const webpush = require('web-push')
// 设置 VAPID 密钥
webpush.setVapidDetails(
'mailto:your-email@example.com',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
)
// 发送推送消息
const sendPushNotification = async (subscription, data) => {
try {
await webpush.sendNotification(subscription, JSON.stringify(data))
console.log('推送消息发送成功')
} catch (error) {
console.error('推送消息发送失败:', error)
// 如果订阅已过期,从数据库中移除
if (error.statusCode === 410) {
await removeSubscriptionFromDatabase(subscription.endpoint)
}
}
}6. 创建可复用的推送 Composable
// composables/usePushNotifications.js
import { ref, onMounted } from 'vue'
export function usePushNotifications(vapidPublicKey) {
const isSupported = ref(false)
const isSubscribed = ref(false)
const subscription = ref(null)
onMounted(() => {
isSupported.value = 'serviceWorker' in navigator && 'PushManager' in window
if (isSupported.value) {
checkSubscription()
}
})
const urlBase64ToUint8Array = (base64String) => {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
const checkSubscription = async () => {
try {
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.getSubscription()
isSubscribed.value = !!sub
subscription.value = sub
} catch (error) {
console.error('检查订阅状态失败:', error)
}
}
const subscribe = async () => {
if (!isSupported.value) {
throw new Error('Web Push API 不被支持')
}
try {
const registration = await navigator.serviceWorker.ready
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
})
subscription.value = sub
isSubscribed.value = true
return sub
} catch (error) {
console.error('订阅失败:', error)
throw error
}
}
const unsubscribe = async () => {
if (!isSubscribed.value || !subscription.value) {
return
}
try {
await subscription.value.unsubscribe()
subscription.value = null
isSubscribed.value = false
} catch (error) {
console.error('取消订阅失败:', error)
throw error
}
}
return {
isSupported,
isSubscribed,
subscription,
subscribe,
unsubscribe,
checkSubscription
}
}最佳实践
1. 权限请求时机
- 不要在页面加载时立即请求通知权限,而是在用户了解网站价值后
- 提供清晰的说明,告诉用户将收到什么类型的通知
- 尊重用户选择,不要反复请求权限
2. 通知内容设计
- 保持通知内容简洁明了
- 使用有意义的标题和正文
- 提供相关的图标和徽章
- 添加振动和声音效果增强用户感知
- 包含可操作的按钮(如有必要)
3. 服务器端最佳实践
- 安全存储 VAPID 私钥,不要泄露
- 定期清理过期的订阅信息
- 实现指数退避重试机制,处理推送失败
- 监控推送成功率和用户参与度
- 提供用户管理通知偏好的界面
4. 性能优化
- 限制推送通知的频率,避免打扰用户
- 优化 Service Worker 代码,减少资源消耗
- 考虑使用批量推送,减少服务器请求
5. 跨浏览器兼容性
- 测试不同浏览器的推送功能
- 处理不同推送服务的差异
- 为不支持的浏览器提供替代方案
常见问题与解决方案
1. 推送通知不显示
- 原因:
- 用户未授予通知权限
- Service Worker 未正确注册
- 推送消息格式不正确
- 浏览器处于休眠状态
- 解决方案:
- 检查权限状态
- 调试 Service Worker 日志
- 验证推送消息格式
- 使用醒目的通知设计
2. 订阅失败,报错 "NotAllowedError"
- 原因:用户拒绝了通知权限
- 解决方案:提供清晰的权限说明,引导用户在浏览器设置中重新允许
3. 推送消息延迟或丢失
- 原因:
- 推送服务延迟
- 设备网络不稳定
- Service Worker 被终止
- 解决方案:
- 实现消息重试机制
- 考虑使用优先级较高的消息
- 优化 Service Worker 性能
4. VAPID 密钥相关错误
- 原因:
- VAPID 密钥不匹配
- 公钥格式错误
- 解决方案:
- 确保前后端使用相同的 VAPID 密钥对
- 正确转换公钥格式(Base64 URL 到 Uint8Array)
5. Service Worker 无法接收推送消息
- 原因:
- Service Worker 未正确实现 push 事件监听器
- Service Worker 版本问题
- 解决方案:
- 检查 Service Worker 代码
- 确保 Service Worker 已更新到最新版本
高级学习资源
1. 官方文档
2. 深度教程
3. 相关 API
4. 视频教程
实践练习
1. 基础练习:实现简单的推送通知
- 创建 Vue 3 应用,注册 Service Worker
- 实现通知权限请求和订阅功能
- 创建简单的后端服务,发送推送消息
2. 进阶练习:自定义通知设计
- 实现富文本通知
- 添加通知操作按钮
- 自定义通知图标和徽章
- 添加振动和声音效果
3. 高级练习:创建推送通知 Composable
- 封装可复用的
usePushNotificationsComposable - 支持订阅管理和权限检查
- 添加类型支持和错误处理
4. 综合练习:完整的推送通知系统
- 实现用户通知偏好设置
- 创建管理界面,发送定向推送
- 添加推送统计和分析功能
- 实现批量推送和个性化推送
5. 挑战练习:高级推送功能
- 实现后台同步与推送结合
- 添加通知点击跟踪
- 实现通知分组和折叠
- 支持静默推送和数据更新
总结
Web Push API 为 Vue 3 应用提供了强大的推送通知功能,可以显著提升用户参与度和留存率。通过合理设计通知内容、优化推送策略、尊重用户选择,可以为用户提供有价值的通知体验,同时避免过度打扰用户。掌握 Web Push API 的实现原理和最佳实践,对于构建现代化的 Vue 3 应用至关重要。