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 工作流程

  1. 用户允许网站发送通知
  2. 浏览器生成推送订阅信息
  3. 网站将订阅信息发送到服务器保存
  4. 服务器使用订阅信息向推送服务发送消息
  5. 推送服务将消息传递给用户设备
  6. 设备上的 Service Worker 接收并显示通知

3. VAPID 密钥生成

VAPID(Voluntary Application Server Identification)密钥用于服务器身份验证。可以使用以下方式生成:

# 使用 web-push 库生成 VAPID 密钥
npm install -g web-push
web-push generate-vapid-keys

4. 前端实现(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

  • 封装可复用的 usePushNotifications Composable
  • 支持订阅管理和权限检查
  • 添加类型支持和错误处理

4. 综合练习:完整的推送通知系统

  • 实现用户通知偏好设置
  • 创建管理界面,发送定向推送
  • 添加推送统计和分析功能
  • 实现批量推送和个性化推送

5. 挑战练习:高级推送功能

  • 实现后台同步与推送结合
  • 添加通知点击跟踪
  • 实现通知分组和折叠
  • 支持静默推送和数据更新

总结

Web Push API 为 Vue 3 应用提供了强大的推送通知功能,可以显著提升用户参与度和留存率。通过合理设计通知内容、优化推送策略、尊重用户选择,可以为用户提供有价值的通知体验,同时避免过度打扰用户。掌握 Web Push API 的实现原理和最佳实践,对于构建现代化的 Vue 3 应用至关重要。

« 上一篇 Vue 3与Web Share API - 实现原生设备分享功能的核心技术 下一篇 » Vue 3与Web Authentication API - 实现无密码安全认证的核心技术