Skip to content

消息推送与安装 ​

Web Push API ​

流程 ​

浏览器 ───→ 应用服务器
  │              │
  │ 1. 请求权限  │
  ├──────►       │
  │◄────── 同意  │
  │              │
  │ 2. 生成订阅  │
  ├──────────────► 保存 PushSubscription
  │              │
  │              │ 3. 发送推送
  │              ├──────► Push Service
  │              │          │
  │              │          ├──► 浏览器(离线也能收到)
  │              │          │
  │              │◄─────────┤
  │              │          │
  │ 4. 触发推送  │          │
  │◄─────────────┤          │
  │ 5. 通知用户  │          │
  │ (SW 中)     │          │
浏览器 ───→ 应用服务器
  │              │
  │ 1. 请求权限  │
  ├──────►       │
  │◄────── 同意  │
  │              │
  │ 2. 生成订阅  │
  ├──────────────► 保存 PushSubscription
  │              │
  │              │ 3. 发送推送
  │              ├──────► Push Service
  │              │          │
  │              │          ├──► 浏览器(离线也能收到)
  │              │          │
  │              │◄─────────┤
  │              │          │
  │ 4. 触发推送  │          │
  │◄─────────────┤          │
  │ 5. 通知用户  │          │
  │ (SW 中)     │          │

1. 订阅推送 ​

js
// main.js
async function subscribePush() {
  const permission = await Notification.requestPermission()
  if (permission !== 'granted') return

  const registration = await navigator.serviceWorker.ready

  // 生成 VAPID 公钥订阅
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlB64ToUint8Array(VAPID_PUBLIC_KEY),
  })

  // 发送到服务器
  await fetch('/api/push/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription),
  })
}

// 工具函数:将 VAPID 公钥转为 Uint8Array
function urlB64ToUint8Array(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
}
// main.js
async function subscribePush() {
  const permission = await Notification.requestPermission()
  if (permission !== 'granted') return

  const registration = await navigator.serviceWorker.ready

  // 生成 VAPID 公钥订阅
  const subscription = await registration.pushManager.subscribe({
    userVisibleOnly: true,
    applicationServerKey: urlB64ToUint8Array(VAPID_PUBLIC_KEY),
  })

  // 发送到服务器
  await fetch('/api/push/subscribe', {
    method: 'POST',
    body: JSON.stringify(subscription),
  })
}

// 工具函数:将 VAPID 公钥转为 Uint8Array
function urlB64ToUint8Array(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
}

2. 生成 VAPID 密钥 ​

bash
# 安装 web-push
npm install -g web-push
web-push generate-vapid-keys

# 输出
# Public Key: BK...
# Private Key: ...
# 安装 web-push
npm install -g web-push
web-push generate-vapid-keys

# 输出
# Public Key: BK...
# Private Key: ...

3. 服务端推送 ​

js
// Node.js 服务端
import webpush from 'web-push'

webpush.setVapidDetails(
  'mailto:your@email.com',
  VAPID_PUBLIC_KEY,
  VAPID_PRIVATE_KEY,
)

// 保存的订阅信息
const subscription = {
  endpoint: 'https://...',
  keys: { auth: '...', p256dh: '...' },
}

// 发送推送
await webpush.sendNotification(subscription, JSON.stringify({
  title: '新消息',
  body: '你有一条新通知',
  icon: '/icon-192.png',
  badge: '/badge.png',
  data: { url: '/messages' },
}))
// Node.js 服务端
import webpush from 'web-push'

webpush.setVapidDetails(
  'mailto:your@email.com',
  VAPID_PUBLIC_KEY,
  VAPID_PRIVATE_KEY,
)

// 保存的订阅信息
const subscription = {
  endpoint: 'https://...',
  keys: { auth: '...', p256dh: '...' },
}

// 发送推送
await webpush.sendNotification(subscription, JSON.stringify({
  title: '新消息',
  body: '你有一条新通知',
  icon: '/icon-192.png',
  badge: '/badge.png',
  data: { url: '/messages' },
}))

4. SW 中处理推送 ​

js
// sw.js
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {
    title: '默认通知',
    body: '你有一条新消息',
  }

  const options = {
    body: data.body,
    icon: data.icon || '/icon-192.png',
    badge: data.badge || '/badge.png',
    vibrate: [200, 100, 200],
    data: data.data || {},
    actions: [
      { action: 'open', title: '查看' },
      { action: 'close', title: '关闭' },
    ],
  }

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  )
})

// 点击通知
self.addEventListener('notificationclick', (event) => {
  event.notification.close()

  if (event.action === 'close') return

  const urlToOpen = event.notification.data?.url || '/'

  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((windowClients) => {
      // 如果已打开页面,聚焦该页面
      for (const client of windowClients) {
        if (client.url === urlToOpen && 'focus' in client) {
          return client.focus()
        }
      }
      // 否则打开新页面
      return clients.openWindow(urlToOpen)
    })
  )
})
// sw.js
self.addEventListener('push', (event) => {
  const data = event.data?.json() ?? {
    title: '默认通知',
    body: '你有一条新消息',
  }

  const options = {
    body: data.body,
    icon: data.icon || '/icon-192.png',
    badge: data.badge || '/badge.png',
    vibrate: [200, 100, 200],
    data: data.data || {},
    actions: [
      { action: 'open', title: '查看' },
      { action: 'close', title: '关闭' },
    ],
  }

  event.waitUntil(
    self.registration.showNotification(data.title, options)
  )
})

// 点击通知
self.addEventListener('notificationclick', (event) => {
  event.notification.close()

  if (event.action === 'close') return

  const urlToOpen = event.notification.data?.url || '/'

  event.waitUntil(
    clients.matchAll({ type: 'window' }).then((windowClients) => {
      // 如果已打开页面,聚焦该页面
      for (const client of windowClients) {
        if (client.url === urlToOpen && 'focus' in client) {
          return client.focus()
        }
      }
      // 否则打开新页面
      return clients.openWindow(urlToOpen)
    })
  )
})

安装到主屏幕 ​

manifest.json ​

json
{
  "name": "我的应用",
  "short_name": "我的应用",
  "description": "这是一个 PWA 应用",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3eaf7c",
  "orientation": "portrait-primary",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "categories": ["productivity"],
  "lang": "zh-CN"
}
{
  "name": "我的应用",
  "short_name": "我的应用",
  "description": "这是一个 PWA 应用",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#3eaf7c",
  "orientation": "portrait-primary",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "categories": ["productivity"],
  "lang": "zh-CN"
}

display 模式 ​

值外观
fullscreen全屏,无浏览器 UI
standalone独立窗口,无浏览器地址栏
minimal-ui显示最小浏览器 UI
browser普通浏览器标签页

安装提示 ​

js
// main.js — 监听 beforeinstallprompt 事件
let deferredPrompt = null

window.addEventListener('beforeinstallprompt', (event) => {
  // 阻止浏览器自动显示安装提示
  event.preventDefault()
  deferredPrompt = event

  // 显示自定义安装按钮
  installButton.style.display = 'block'
})

installButton.addEventListener('click', async () => {
  if (!deferredPrompt) return

  // 弹出安装对话框
  deferredPrompt.prompt()

  const { outcome } = await deferredPrompt.userChoice
  console.log('安装结果:', outcome)

  deferredPrompt = null
  installButton.style.display = 'none'
})

// 安装成功
window.addEventListener('appinstalled', () => {
  console.log('PWA 已安装')
})
// main.js — 监听 beforeinstallprompt 事件
let deferredPrompt = null

window.addEventListener('beforeinstallprompt', (event) => {
  // 阻止浏览器自动显示安装提示
  event.preventDefault()
  deferredPrompt = event

  // 显示自定义安装按钮
  installButton.style.display = 'block'
})

installButton.addEventListener('click', async () => {
  if (!deferredPrompt) return

  // 弹出安装对话框
  deferredPrompt.prompt()

  const { outcome } = await deferredPrompt.userChoice
  console.log('安装结果:', outcome)

  deferredPrompt = null
  installButton.style.display = 'none'
})

// 安装成功
window.addEventListener('appinstalled', () => {
  console.log('PWA 已安装')
})

PWA 检查清单 ​

  • [ ] HTTPS
  • [ ] Service Worker
  • [ ] manifest.json
  • [ ] 192px + 512px 图标
  • [ ] 离线页面
  • [ ] 快响应 (< 5s)
  • [ ] 跨浏览器兼容
  • [ ] 支持安装提示