Open xingbofeng opened 6 years ago
在上一篇文章Service Worker学习与实践(二)——PWA简介中,已经讲到PWA的起源,优势与劣势,并通过一个简单的例子说明了如何在桌面端和移动端将一个PWA安装到桌面上,这篇文章,将通过一个例子阐述如何使用Service Worker的消息推送功能,并配合PWA技术,带来原生应用般的消息推送体验。
PWA
Service Worker
说到底,PWA的消息推送也是服务端推送的一种,常见的服务端推送方法,例如广泛使用的轮询、长轮询、Web Socket等,说到底,都是客户端与服务端之间的通信,在Service Worker中,客户端接收到通知,是基于Notification来进行推送的。
Web Socket
那么,我们来看一下,如何直接使用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中的使用方法为:
API
// 添加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中的代码:
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方法,向用户申请消息推送的权限:
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,则表示用户拒绝了应用程序的消息推送。如下图所示:
resolve
serviceWorkerRegistration.pushManager.subscribe方法通常需要传递两个参数:
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有以下三个取值,:
Notification.permission
granted
denied
default
if (Notification.permission === 'granted') { // 用户允许消息推送 } else { // 还不允许消息推送,向用户申请消息推送的权限 }
上述代码中的applicationServerPublicKey通常情况下是由服务端生成的公钥,在页面初始化的时候就会返回给客户端,服务端会保存每个用户对应的公钥与私钥,以便进行消息推送。
applicationServerPublicKey
在我的示例演示中,我们可以使用Google配套的实验网站web-push-codelab生成公钥与私钥,以便发送消息通知:
Google
在Service Worker中,通过监听push事件来处理消息推送:
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来展示消息推送。
event.data.text()
self.registration.showNotification
那么,如何在服务端识别指定的用户,向其发送对应的消息推送呢?
在调用swReg.pushManager.subscribe方法后,如果用户是允许消息推送的,那么该函数返回的Promise将会resolve,在then中获取到对应的subscription。
swReg.pushManager.subscribe
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-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中申请,申请教程可参考这篇博文。
GCM API Key
在这个我写的示例Demo中,我把subscription写死了:
Demo
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对象。
self.clients
Client
Worker
SharedWorker
Window
WindowClient
Clients.matchAll()
Clients.get()
Client/WindowClient
使用clients.openWindow在新窗口打开一个网页:
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()进行聚焦。没有的话,则新打开页面即可。
cilents
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是否处于聚焦状态。
windowClient.focused
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,进行消息合并。
registration.getNotifications() API
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方法来向用户申请权限,如果用户拒绝了消息推送,应用程序也需要相关处理。
Notification API
消息推送是基于谷歌云服务的,因此,在国内,收到GFW的限制,这一功能的支持并不好,Google提供了一系列推送相关的库,例如Node.js中,使用web-push来实现。一般原理是:在服务端生成公钥和私钥,并针对用户将其公钥和私钥存储到服务端,客户端只存储公钥。Service Worker的swReg.pushManager.subscribe可以获取到subscription,并发送给服务端,服务端利用subscription向指定的用户发起消息推送。
GFW
Node.js
消息推送功能可以配合clients API做特殊处理。
clients API
如果用户安装了PWA应用,即使用户关闭了应用程序,Service Worker也在运行,即使用户未打开应用程序,也会收到消息通知。
在下一篇文章中,我将尝试在我所在的项目中使用Service Worker,并通过Webpack和Workbox配置来讲述Service Worker的最佳实践。
Webpack
Workbox
在上一篇文章Service Worker学习与实践(二)——PWA简介中,已经讲到
PWA
的起源,优势与劣势,并通过一个简单的例子说明了如何在桌面端和移动端将一个PWA
安装到桌面上,这篇文章,将通过一个例子阐述如何使用Service Worker
的消息推送功能,并配合PWA
技术,带来原生应用般的消息推送体验。Notification
说到底,
PWA
的消息推送也是服务端推送的一种,常见的服务端推送方法,例如广泛使用的轮询、长轮询、Web Socket
等,说到底,都是客户端与服务端之间的通信,在Service Worker
中,客户端接收到通知,是基于Notification来进行推送的。那么,我们来看一下,如何直接使用
Notification
来发送一条推送呢?下面是一段示例代码:在控制台敲下上述代码后,则会弹出以下通知:
然而,
Notification
这个API
,只推荐在Service Worker
中使用,不推荐在主线程中使用,在Service Worker
中的使用方法为:读者可以在MDN Web Docs关于
Notification
在Service Worker
中的相关用法,在本文就不浪费大量篇幅来进行较为详细的阐述了。申请推送的权限
如果浏览器直接给所有开发者开放向用户推送通知的权限,那么势必用户会受到大量垃圾信息的骚扰,因此这一权限是需要申请的,如果用户禁止了消息推送,开发者是没有权利向用户发起消息推送的。我们可以通过serviceWorkerRegistration.pushManager.getSubscription方法查看用户是否已经允许推送通知的权限。修改
sw-register.js
中的代码:上面的代码调用了
swReg.pushManager
的getSubscription
,可以知道用户是否已经允许进行消息推送,如果swReg.pushManager.getSubscription
的Promise
被reject
了,则表示用户还没有订阅我们的消息,调用subscribeUser
方法,向用户申请消息推送的权限:上面的代码通过serviceWorkerRegistration.pushManager.subscribe向用户发起订阅的权限,这个方法返回一个
Promise
,如果Promise
被resolve
,则表示用户允许应用程序推送消息,反之,如果被reject
,则表示用户拒绝了应用程序的消息推送。如下图所示:serviceWorkerRegistration.pushManager.subscribe
方法通常需要传递两个参数:userVisibleOnly
,这个参数通常被设置为true
,用来表示后续信息是否展示给用户。applicationServerKey
,这个参数是一个Uint8Array
,用于加密服务端的推送信息,防止中间人攻击,会话被攻击者篡改。这一参数是由服务端生成的公钥,通过urlB64ToUint8Array
转换的,这一函数通常是固定的,如下所示:关于服务端公钥如何获取,在文章后续会有相关阐述。
处理拒绝的权限
如果在调用
serviceWorkerRegistration.pushManager.subscribe
后,用户拒绝了推送权限,同样也可以在应用程序中,通过Notification.permission获取到这一状态,Notification.permission
有以下三个取值,:granted
:用户已经明确的授予了显示通知的权限。denied
:用户已经明确的拒绝了显示通知的权限。default
:用户还未被询问是否授权,在应用程序中,这种情况下权限将视为denied
。密钥生成
上述代码中的
applicationServerPublicKey
通常情况下是由服务端生成的公钥,在页面初始化的时候就会返回给客户端,服务端会保存每个用户对应的公钥与私钥,以便进行消息推送。在我的示例演示中,我们可以使用
Google
配套的实验网站web-push-codelab生成公钥与私钥,以便发送消息通知:发送推送
在
Service Worker
中,通过监听push
事件来处理消息推送:在上面的代码中,在
push
事件回调中,通过event.data.text()
拿到消息推送的文本,然后调用上面所说的self.registration.showNotification
来展示消息推送。服务端发送
那么,如何在服务端识别指定的用户,向其发送对应的消息推送呢?
在调用
swReg.pushManager.subscribe
方法后,如果用户是允许消息推送的,那么该函数返回的Promise
将会resolve
,在then
中获取到对应的subscription
。subscription
一般是下面的格式:使用
Google
配套的实验网站web-push-codelab,发送消息推送。web-push
在服务端,使用web-push-libs,实现公钥与私钥的生成,消息推送功能,Node.js版本。
上面的代码中,
GCM API Key
需要在Firebase console中申请,申请教程可参考这篇博文。在这个我写的示例
Demo
中,我把subscription
写死了:交互响应
默认情况下,推送的消息点击后是没有对应的交互的,配合clients API可以实现一些类似于原生应用的交互,这里参考了这篇博文的实现:
新窗口打开
使用
clients.openWindow
在新窗口打开一个网页:聚焦已经打开的页面
利用
cilents
提供的相关API
获取,当前浏览器已经打开的页面URLs
。不过这些URLs
只能是和你SW
同域的。然后,通过匹配URL
,通过matchingClient.focus()
进行聚焦。没有的话,则新打开页面即可。检测是否需要推送
如果用户已经停留在当前的网页,那我们可能就不需要推送了,那么针对于这种情况,我们应该怎么检测用户是否正在网页上呢?
合并消息
该场景的主要针对消息的合并。比如,当只有一条消息时,可以直接推送,那如果该用户又发送一个消息呢? 这时候,比较好的用户体验是直接将推送合并为一个,然后替换即可。 那么,此时我们就需要获得当前已经展示的推送消息,这里主要通过
registration.getNotifications() API
来进行获取。该API
返回的也是一个Promise
对象。通过Promise
在resolve
后拿到的notifications
,判断其length
,进行消息合并。小结
本文通过一个简单的例子,讲述了
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
的最佳实践。