Service Worker学习与实践(三)——消息推送
在上一篇文章Service Worker学习与实践(二)——PWA简介中,已经讲到[代码]PWA[代码]的起源,优势与劣势,并通过一个简单的例子说明了如何在桌面端和移动端将一个[代码]PWA[代码]安装到桌面上,这篇文章,将通过一个例子阐述如何使用[代码]Service Worker[代码]的消息推送功能,并配合[代码]PWA[代码]技术,带来原生应用般的消息推送体验。
Notification
说到底,[代码]PWA[代码]的消息推送也是服务端推送的一种,常见的服务端推送方法,例如广泛使用的轮询、长轮询、[代码]Web Socket[代码]等,说到底,都是客户端与服务端之间的通信,在[代码]Service Worker[代码]中,客户端接收到通知,是基于Notification来进行推送的。
那么,我们来看一下,如何直接使用[代码]Notification[代码]来发送一条推送呢?下面是一段示例代码:
[代码]// 在主线程中使用
let notification = new Notification('您有新消息', {
body: 'Hello Service Worker',
icon: './images/logo/logo152.png',
});
notification.onclick = function() {
console.log('点击了');
};
[代码]
在控制台敲下上述代码后,则会弹出以下通知:
[图片]
然而,[代码]Notification[代码]这个[代码]API[代码],只推荐在[代码]Service Worker[代码]中使用,不推荐在主线程中使用,在[代码]Service Worker[代码]中的使用方法为:
[代码]// 添加notificationclick事件监听器,在点击notification时触发
self.addEventListener('notificationclick', function(event) {
// 关闭当前的弹窗
event.notification.close();
// 在新窗口打开页面
event.waitUntil(
clients.openWindow('https://google.com')
);
});
// 触发一条通知
self.registration.showNotification('您有新消息', {
body: 'Hello Service Worker',
icon: './images/logo/logo152.png',
});
[代码]
读者可以在MDN Web Docs关于[代码]Notification[代码]在[代码]Service Worker[代码]中的相关用法,在本文就不浪费大量篇幅来进行较为详细的阐述了。
申请推送的权限
如果浏览器直接给所有开发者开放向用户推送通知的权限,那么势必用户会受到大量垃圾信息的骚扰,因此这一权限是需要申请的,如果用户禁止了消息推送,开发者是没有权利向用户发起消息推送的。我们可以通过serviceWorkerRegistration.pushManager.getSubscription方法查看用户是否已经允许推送通知的权限。修改[代码]sw-register.js[代码]中的代码:
[代码]if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(function (swReg) {
swReg.pushManager.getSubscription()
.then(function(subscription) {
if (subscription) {
console.log(JSON.stringify(subscription));
} else {
console.log('没有订阅');
subscribeUser(swReg);
}
});
});
}
[代码]
上面的代码调用了[代码]swReg.pushManager[代码]的[代码]getSubscription[代码],可以知道用户是否已经允许进行消息推送,如果[代码]swReg.pushManager.getSubscription[代码]的[代码]Promise[代码]被[代码]reject[代码]了,则表示用户还没有订阅我们的消息,调用[代码]subscribeUser[代码]方法,向用户申请消息推送的权限:
[代码]function subscribeUser(swReg) {
const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey);
swReg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
})
.then(function(subscription) {
console.log(JSON.stringify(subscription));
})
.catch(function(err) {
console.log('订阅失败: ', err);
});
}
[代码]
上面的代码通过serviceWorkerRegistration.pushManager.subscribe向用户发起订阅的权限,这个方法返回一个[代码]Promise[代码],如果[代码]Promise[代码]被[代码]resolve[代码],则表示用户允许应用程序推送消息,反之,如果被[代码]reject[代码],则表示用户拒绝了应用程序的消息推送。如下图所示:
[图片]
[代码]serviceWorkerRegistration.pushManager.subscribe[代码]方法通常需要传递两个参数:
[代码]userVisibleOnly[代码],这个参数通常被设置为[代码]true[代码],用来表示后续信息是否展示给用户。
[代码]applicationServerKey[代码],这个参数是一个[代码]Uint8Array[代码],用于加密服务端的推送信息,防止中间人攻击,会话被攻击者篡改。这一参数是由服务端生成的公钥,通过[代码]urlB64ToUint8Array[代码]转换的,这一函数通常是固定的,如下所示:
[代码]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;
}
[代码]
关于服务端公钥如何获取,在文章后续会有相关阐述。
处理拒绝的权限
如果在调用[代码]serviceWorkerRegistration.pushManager.subscribe[代码]后,用户拒绝了推送权限,同样也可以在应用程序中,通过Notification.permission获取到这一状态,[代码]Notification.permission[代码]有以下三个取值,:
[代码]granted[代码]:用户已经明确的授予了显示通知的权限。
[代码]denied[代码]:用户已经明确的拒绝了显示通知的权限。
[代码]default[代码]:用户还未被询问是否授权,在应用程序中,这种情况下权限将视为[代码]denied[代码]。
[代码]if (Notification.permission === 'granted') {
// 用户允许消息推送
} else {
// 还不允许消息推送,向用户申请消息推送的权限
}
[代码]
密钥生成
上述代码中的[代码]applicationServerPublicKey[代码]通常情况下是由服务端生成的公钥,在页面初始化的时候就会返回给客户端,服务端会保存每个用户对应的公钥与私钥,以便进行消息推送。
在我的示例演示中,我们可以使用[代码]Google[代码]配套的实验网站web-push-codelab生成公钥与私钥,以便发送消息通知:
[图片]
发送推送
在[代码]Service Worker[代码]中,通过监听[代码]push[代码]事件来处理消息推送:
[代码]self.addEventListener('push', function(event) {
const title = event.data.text();
const options = {
body: event.data.text(),
icon: './images/logo/logo512.png',
};
event.waitUntil(self.registration.showNotification(title, options));
});
[代码]
在上面的代码中,在[代码]push[代码]事件回调中,通过[代码]event.data.text()[代码]拿到消息推送的文本,然后调用上面所说的[代码]self.registration.showNotification[代码]来展示消息推送。
服务端发送
那么,如何在服务端识别指定的用户,向其发送对应的消息推送呢?
在调用[代码]swReg.pushManager.subscribe[代码]方法后,如果用户是允许消息推送的,那么该函数返回的[代码]Promise[代码]将会[代码]resolve[代码],在[代码]then[代码]中获取到对应的[代码]subscription[代码]。
[代码]subscription[代码]一般是下面的格式:
[代码]{
"endpoint": "https://fcm.googleapis.com/fcm/send/cSEJGmI_x2s:APA91bHzRHllE6tNoEHqjHQSlLpcQHeiGr7X78EIa1QrUPFqDGDM_4RVKNxoLPV3_AaCCejR4uwUawBKYcQLmLpUrCUoZetQ9pVzQCJSomB5BvoFZBzkSnUb-ALm4D1lqwV9w_uP3M0E",
"expirationTime": null,
"keys": {
"p256dh": "BDOx1ZTtsFL2ncSN17Bu7-Wl_1Z7yIiI-lKhtoJ2dAZMToGz-XtQOe6cuMLMa3I8FoqPfcPy232uAqoISB4Z-UU",
"auth": "XGWy-wlmrAw3Be818GLZ8Q"
}
}
[代码]
使用[代码]Google[代码]配套的实验网站web-push-codelab,发送消息推送。
[图片]
web-push
在服务端,使用web-push-libs,实现公钥与私钥的生成,消息推送功能,Node.js版本。
[代码]const webpush = require('web-push');
// VAPID keys should only be generated only once.
const vapidKeys = webpush.generateVAPIDKeys();
webpush.setGCMAPIKey('<Your GCM API Key Here>');
webpush.setVapidDetails(
'mailto:example@yourdomain.org',
vapidKeys.publicKey,
vapidKeys.privateKey
);
// pushSubscription是前端通过swReg.pushManager.subscribe获取到的subscription
const pushSubscription = {
endpoint: '.....',
keys: {
auth: '.....',
p256dh: '.....'
}
};
webpush.sendNotification(pushSubscription, 'Your Push Payload Text');
[代码]
上面的代码中,[代码]GCM API Key[代码]需要在Firebase console中申请,申请教程可参考这篇博文。
在这个我写的示例[代码]Demo[代码]中,我把[代码]subscription[代码]写死了:
[代码]const webpush = require('web-push');
webpush.setVapidDetails(
'mailto:503908971@qq.com',
'BCx1qqSFCJBRGZzPaFa8AbvjxtuJj9zJie_pXom2HI-gisHUUnlAFzrkb-W1_IisYnTcUXHmc5Ie3F58M1uYhZU',
'g5pubRphHZkMQhvgjdnVvq8_4bs7qmCrlX-zWAJE9u8'
);
const subscription = {
"endpoint": "https://fcm.googleapis.com/fcm/send/cSEJGmI_x2s:APA91bHzRHllE6tNoEHqjHQSlLpcQHeiGr7X78EIa1QrUPFqDGDM_4RVKNxoLPV3_AaCCejR4uwUawBKYcQLmLpUrCUoZetQ9pVzQCJSomB5BvoFZBzkSnUb-ALm4D1lqwV9w_uP3M0E",
"expirationTime": null,
"keys": {
"p256dh": "BDOx1ZTtsFL2ncSN17Bu7-Wl_1Z7yIiI-lKhtoJ2dAZMToGz-XtQOe6cuMLMa3I8FoqPfcPy232uAqoISB4Z-UU",
"auth": "XGWy-wlmrAw3Be818GLZ8Q"
}
};
webpush.sendNotification(subscription, 'Counterxing');
[代码]
交互响应
默认情况下,推送的消息点击后是没有对应的交互的,配合clients API可以实现一些类似于原生应用的交互,这里参考了这篇博文的实现:
[代码]Service Worker[代码]中的[代码]self.clients[代码]对象提供了[代码]Client[代码]的访问,[代码]Client[代码]接口表示一个可执行的上下文,如[代码]Worker[代码]或[代码]SharedWorker[代码]。[代码]Window[代码]客户端由更具体的[代码]WindowClient[代码]表示。 你可以从[代码]Clients.matchAll()[代码]和[代码]Clients.get()[代码]等方法获取[代码]Client/WindowClient[代码]对象。
新窗口打开
使用[代码]clients.openWindow[代码]在新窗口打开一个网页:
[代码]self.addEventListener('notificationclick', function(event) {
event.notification.close();
// 新窗口打开
event.waitUntil(
clients.openWindow('https://google.com/')
);
});
[代码]
聚焦已经打开的页面
利用[代码]cilents[代码]提供的相关[代码]API[代码]获取,当前浏览器已经打开的页面[代码]URLs[代码]。不过这些[代码]URLs[代码]只能是和你[代码]SW[代码]同域的。然后,通过匹配[代码]URL[代码],通过[代码]matchingClient.focus()[代码]进行聚焦。没有的话,则新打开页面即可。
[代码]self.addEventListener('notificationclick', function(event) {
event.notification.close();
const urlToOpen = self.location.origin + '/index.html';
const promiseChain = clients.matchAll({
type: 'window',
includeUncontrolled: true
})
.then((windowClients) => {
let matchingClient = null;
for (let i = 0; i < windowClients.length; i++) {
const windowClient = windowClients[i];
if (windowClient.url === urlToOpen) {
matchingClient = windowClient;
break;
}
}
if (matchingClient) {
return matchingClient.focus();
} else {
return clients.openWindow(urlToOpen);
}
});
event.waitUntil(promiseChain);
});
[代码]
检测是否需要推送
如果用户已经停留在当前的网页,那我们可能就不需要推送了,那么针对于这种情况,我们应该怎么检测用户是否正在网页上呢?
通过[代码]windowClient.focused[代码]可以检测到当前的[代码]Client[代码]是否处于聚焦状态。
[代码]self.addEventListener('push', function(event) {
const promiseChain = clients.matchAll({
type: 'window',
includeUncontrolled: true
})
.then((windowClients) => {
let mustShowNotification = true;
for (let i = 0; i < windowClients.length; i++) {
const windowClient = windowClients[i];
if (windowClient.focused) {
mustShowNotification = false;
break;
}
}
return mustShowNotification;
})
.then((mustShowNotification) => {
if (mustShowNotification) {
const title = event.data.text();
const options = {
body: event.data.text(),
icon: './images/logo/logo512.png',
};
return self.registration.showNotification(title, options);
} else {
console.log('用户已经聚焦于当前页面,不需要推送。');
}
});
});
[代码]
合并消息
该场景的主要针对消息的合并。比如,当只有一条消息时,可以直接推送,那如果该用户又发送一个消息呢? 这时候,比较好的用户体验是直接将推送合并为一个,然后替换即可。 那么,此时我们就需要获得当前已经展示的推送消息,这里主要通过[代码]registration.getNotifications() API[代码]来进行获取。该[代码]API[代码]返回的也是一个[代码]Promise[代码]对象。通过[代码]Promise[代码]在[代码]resolve[代码]后拿到的[代码]notifications[代码],判断其[代码]length[代码],进行消息合并。
[代码]self.addEventListener('push', function(event) {
// ...
.then((mustShowNotification) => {
if (mustShowNotification) {
return registration.getNotifications()
.then(notifications => {
let options = {
icon: './images/logo/logo512.png',
badge: './images/logo/logo512.png'
};
let title = event.data.text();
if (notifications.length) {
options.body = `您有${notifications.length}条新消息`;
} else {
options.body = event.data.text();
}
return self.registration.showNotification(title, options);
});
} else {
console.log('用户已经聚焦于当前页面,不需要推送。');
}
});
// ...
});
[代码]
[图片]
小结
本文通过一个简单的例子,讲述了[代码]Service Worker[代码]中消息推送的原理。[代码]Service Worker[代码]中的消息推送是基于[代码]Notification API[代码]的,这一[代码]API[代码]的使用首先需要用户授权,通过在[代码]Service Worker[代码]注册时的[代码]serviceWorkerRegistration.pushManager.subscribe[代码]方法来向用户申请权限,如果用户拒绝了消息推送,应用程序也需要相关处理。
消息推送是基于谷歌云服务的,因此,在国内,收到[代码]GFW[代码]的限制,这一功能的支持并不好,[代码]Google[代码]提供了一系列推送相关的库,例如[代码]Node.js[代码]中,使用web-push来实现。一般原理是:在服务端生成公钥和私钥,并针对用户将其公钥和私钥存储到服务端,客户端只存储公钥。[代码]Service Worker[代码]的[代码]swReg.pushManager.subscribe[代码]可以获取到[代码]subscription[代码],并发送给服务端,服务端利用[代码]subscription[代码]向指定的用户发起消息推送。
消息推送功能可以配合[代码]clients API[代码]做特殊处理。
如果用户安装了[代码]PWA[代码]应用,即使用户关闭了应用程序,[代码]Service Worker[代码]也在运行,即使用户未打开应用程序,也会收到消息通知。
在下一篇文章中,我将尝试在我所在的项目中使用[代码]Service Worker[代码],并通过[代码]Webpack[代码]和[代码]Workbox[代码]配置来讲述[代码]Service Worker[代码]的最佳实践。