在上一篇文章Service Worker學習與實踐(二)——PWA簡介中,已經講到PWA
的起源,優點與劣勢,並經過一個簡單的例子說明了如何在桌面端和移動端將一個PWA
安裝到桌面上,這篇文章,將經過一個例子闡述如何使用Service Worker
的消息推送功能,並配合PWA
技術,帶來原生應用般的消息推送體驗。javascript
說到底,PWA
的消息推送也是服務端推送的一種,常見的服務端推送方法,例如普遍使用的輪詢、長輪詢、Web Socket
等,說到底,都是客戶端與服務端之間的通訊,在Service Worker
中,客戶端接收到通知,是基於Notification來進行推送的。html
那麼,咱們來看一下,如何直接使用Notification
來發送一條推送呢?下面是一段示例代碼:前端
// 在主線程中使用 let notification = new Notification('您有新消息', { body: 'Hello Service Worker', icon: './images/logo/logo152.png', }); notification.onclick = function() { console.log('點擊了'); };
在控制檯敲下上述代碼後,則會彈出如下通知:java
然而,Notification
這個API
,只推薦在Service Worker
中使用,不推薦在主線程中使用,在Service Worker
中的使用方法爲:android
// 添加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
中的相關用法,在本文就不浪費大量篇幅來進行較爲詳細的闡述了。git
若是瀏覽器直接給全部開發者開放向用戶推送通知的權限,那麼勢必用戶會受到大量垃圾信息的騷擾,所以這一權限是須要申請的,若是用戶禁止了消息推送,開發者是沒有權利向用戶發起消息推送的。咱們能夠經過serviceWorkerRegistration.pushManager.getSubscription方法查看用戶是否已經容許推送通知的權限。修改sw-register.js
中的代碼:github
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
方法,向用戶申請消息推送的權限:web
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
,則表示用戶拒絕了應用程序的消息推送。以下圖所示:json
serviceWorkerRegistration.pushManager.subscribe
方法一般須要傳遞兩個參數:api
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-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
的最佳實踐。