Service Worker學習與實踐(三)——訊息推送
在上一篇文章 ofollow,noindex">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:[email protected]', 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:[email protected]', '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
的最佳實踐。