Service Worker學習與實踐(三)——訊息推送

counterxing發表於2018-11-20

在上一篇文章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('點選了');

};
複製程式碼

在控制檯敲下上述程式碼後,則會彈出以下通知:

Service Worker學習與實踐(三)——訊息推送

然而,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關於NotificationService 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.pushManagergetSubscription,可以知道使用者是否已經允許進行訊息推送,如果swReg.pushManager.getSubscriptionPromisereject了,則表示使用者還沒有訂閱我們的訊息,呼叫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,如果Promiseresolve,則表示使用者允許應用程式推送訊息,反之,如果被reject,則表示使用者拒絕了應用程式的訊息推送。如下圖所示:

Service Worker學習與實踐(三)——訊息推送

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學習與實踐(三)——訊息推送

傳送推送

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,傳送訊息推送。

Service Worker學習與實踐(三)——訊息推送

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獲取到的subscriptionconst 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介面表示一個可執行的上下文,如WorkerSharedWorkerWindow客戶端由更具體的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物件。通過Promiseresolve後拿到的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中訊息推送的原理。Service Worker中的訊息推送是基於Notification API的,這一API的使用首先需要使用者授權,通過在Service Worker註冊時的serviceWorkerRegistration.pushManager.subscribe方法來向使用者申請許可權,如果使用者拒絕了訊息推送,應用程式也需要相關處理。

訊息推送是基於谷歌雲服務的,因此,在國內,收到GFW的限制,這一功能的支援並不好,Google提供了一系列推送相關的庫,例如Node.js中,使用web-push來實現。一般原理是:在服務端生成公鑰和私鑰,並針對使用者將其公鑰和私鑰儲存到服務端,客戶端只儲存公鑰。Service WorkerswReg.pushManager.subscribe可以獲取到subscription,併傳送給服務端,服務端利用subscription向指定的使用者發起訊息推送。

訊息推送功能可以配合clients API做特殊處理。

如果使用者安裝了PWA應用,即使使用者關閉了應用程式,Service Worker也在執行,即使使用者未開啟應用程式,也會收到訊息通知。

在下一篇文章中,我將嘗試在我所在的專案中使用Service Worker,並通過WebpackWorkbox配置來講述Service Worker的最佳實踐。

來源:https://juejin.im/post/5bf3f6b2e51d45360069e527

相關文章