消息推送与安装
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)
- [ ] 跨浏览器兼容
- [ ] 支持安装提示
Cao Note