第六章 消息推送

6.1 与用户互动

推送通知最大的优点是即使用户没有浏览你的网站也会收到这些通知内容。

要给用户发送推送通知,首先需要用户的授权。

6.2 Weather Channel

天气预报网站 Weather Channel 的数据,略。

6.3 浏览器支持

Web 推送标准:http://www.w3.org/TR/push-api

6.4 第一个推送通知

发送推送通知需要三个步骤:

  1. 提示用户并获得他们的订阅细节
  2. 将这些细节信息保存在服务器上
  3. 在需要时发送任何消息

6.4.1 订阅通知

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
var endpoint
var key
var authSecret

var vapidPublicKey = '...' // 客户端和服务器端都需要公钥,以确保消息是加密过的

function urlBase64ToUnit8Array(base64String) { // 将VAPID密钥从base64字符串转换成Unit8数组,这是VAPID规范要求的
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/')

const rawData = window.atob(base64)
const outputArray = new Unit8Array(rawData.length)

for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}

return outputArray
}

if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(function (registration) {
return registration.pushManager.getSubscription() // 获取任何已存在的订阅
.then(function (subscription) {
if (subscription) return // 如果已经订阅过了,则无需再次注册
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUnit8Array(vapidPublicKey)
})
.then(function (subscription) {
var rawKey = subscription.getKey ? subscription.getKey('p256dh') : '' // 从订阅对象中获取密钥和authSecret
key = rawKey ? btoa(String.fromCharCode.apply(null, new Unit8Array(rawKey))) : ''
var rawAuthSecret = subscription.getKey ? subscription.getKey('auth') : ''
authSecret = rawAuthSecret ? btoa(String.fromCharCode.apply(null, new Unit8Array(rawAuthSecret))) : ''

endpoint = subscription.endpoint

return fetch('./register', { // 将详细信息发送给服务器以注册用户
method: 'post',
headers: new Headers({
'content-type': 'application/json'
}),
body: JSON.stringify({
endpoint: subscription.endpoint,
key: key,
authSecret: authSecret
})
})
})
})
}).catch(function (err) {
console.log('serviceWorker registration failed: ', err)
})
}

VAPID(Voluntary Application Server Identification,自主应用服务器标识)协议,本质上定义了应用服务器和推送服务之间的握手,并允许推送服务器确认哪个站点正在发送消息。

成功注册了 Service Worker 之后,可以使用 registration 对象中的 pushManager 来检测用户是否已经订阅过了。如果用户在这台机器上已经订阅过了,便不需要再发送信息给服务器。每个订阅对象包含一个唯一的订阅 ID。

如果用户还没订阅,就用 pushManager.subscribe() 函数来提示用户订阅,该函数使用 VAPID 公钥识别自己。

最后,使用 Fetch API 来发送 POST 请求到服务器上的端点,密钥和 authSecret 将用于存储用户的详细信息。

6.4.2 发送通知

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
const webpush = require('web-push') // 添加必要的依赖
const express = require('express')
const bodyParser = require('body-parser')
const app = express()

webpush.setVapidDetails( // 设置VAPID详情
'mailto:contact@deanhume.com',
'BAyb_WgaR0L0pODaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY',
'p6YVD7t8HkABoez1CvVJ5bl7BnEdKUu5bSyVjyxMBh0'
)

app.post('/register', function (req, res) { // 监听指向 /register 的POST请求
var endpoint = req.body.endpoint
saveRegistrationDetails(endpoint, key, authSecret) // 保存用户注册详情,这样可以在稍后阶段向他们发送消息

const pushSubscription = { // 构建pushSubscription对象
endpoint: req.body.endpoint,
keys: {
auth: req.body.authSecret,
p256dh: req.body.key
}
}

var body = 'Thank you for registering'
var iconUrl = 'https://example.com/images/homescreen.png'

webpush.sendNotification(pushSubscription, // 发送Web推送消息
JSON.stringify({
msg: body,
url: 'http://localhost: 3111',
icon: iconUrl
})
)
.then(result => res.sendStatus(201))
.catch(err => console.log(err))
})

app.listen(3111, function () {
console.log('Web push app listening on port 3111!')
})

6.4.3 接收通知并与之互动

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 在 Service Worker 中接收推送通知
self.addEventListener('push', function (event) {
var payload = event.data ? JSON.parse(event.data.text()) : 'no payload' // 检查服务器端是否发送了任何有效载荷数据
var title = 'Progressive Times'

event.waitUntil(
self.registration.showNotification(title, { // 使用提供的信息来显示Web推送通知
body: payload.msg,
url: payload.url,
icon: payload.icon
})
)
})
// 注:payload定义是数据类型,为什么当对象来用?

// 处理用户与推送通知的交互
self.addEventListener('click', function (event) {
event.notification.close() // 一旦单击了通知标题,它便会关闭

event.waitUntil( // 检查当前窗口是否已经打开,如果已打开则切换至当前窗口
clients.matchAll({ type: 'window' })
.then(function (clientList) {
for (var i = 0; i < clientList.length; i++) {
var client = clientList[i]
if (client.url === '/' && 'focus' in client) return client.focus()
}
if (clients.openWindow) return clients.openWindow('http://localhost:3111')
})
)
})

// 添加通知动作以及自定义振动模式
self.addEventListener('push', function (event) {
var payload = event.data ? JSON.parse(event.data.text()) : 'no payload'
var title = 'Progressive Times'

event.waitUntil(
self.registration.showNotification(title, {
body: payload.msg,
url: payload.url,
icon: payload.icon,
actions: [
{ action: 'voteup', title: 'Vote Up' }, // 出现在通知中的操作
{ action: 'votedown', title: 'Vote Down' }
],
vibrate: [300, 100, 400] // 振动300ms,暂停100ms,再振动400ms
})
)
})


// 在 Service Worker 中处理通知动作
self.addEventListener('click', function (event) {
event.notification.close() // 一旦单击了通知标题,它便会关闭

if (event.action === 'voteup') { // 确定用户选择了哪个操作
clients.openWindow('http://localhost:/voteup')
} else { // 根据用户的选择,将他们引导至正确的URL
clients.openWindow('http://localhost:/votedown')
}
}, false)

6.4.4 取消订阅

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
navigator.serviceWorker.ready
.then(serviceWorkerRegistration => {
serviceWorkerRegistration.pushManager.getSubscription() // 检查用户是否已经订阅
.then(subscription => {
if (!subscription) return
subscription.unsubscribe() // 如果用户已订阅就取消订阅
.then(function () {
console.log('Successfully unsubscribed!')
})
.catch(e => {
logger.error('Error thrown while unsubscribing from push messaging', e)
})
})
})

document.getElementById('unsubscribe').addEventListener('click', unsubscribe) // 为取消订阅按钮添加单击事件的事件监听器

6.5 第三方推送通知

如果不想自己搭建推送通知服务器,而是使用 SaaS 产品,有一些现成的第三方解决方案。如 OneSignal、Roost 和 Aimtell 等。