伴隨着今年 Google I/O 大會的召開,一個很火的概念--Progressive Web Apps 誕生了。這表明着咱們 web 端有了和原生 APP 媲美的能力。可是,有一個很重要的痛點,web 一直不能使用消息推送,雖然,後面提出了 Notification
API,但這須要網頁持續打開,這對於常規 APP 實現的推送,根本就不是一個量級的。因此,開發者一直在呼籲能不能退出一款可以在網頁關閉狀況下的 web 推送呢?
如今,Web 時代已經到來!
爲了作到在網頁關閉的狀況下,還能繼續發送 Notification,咱們就只能使用駐留進程。而如今 Web 的駐留進程就是如今正在大力普及的 Service Worker。換句話說,咱們的想要實現斷線 Notification 的話,須要用的技術棧是:html
Pushnode
Notificationgit
Service Workergithub
這裏,我先一個簡單的 demo 樣式。web
說實在的,我其實 TM 很煩的這 Noti。通常使用 PC 端的,也沒見有啥消息彈出來,可是,如今好了 Web 一搞,結果三端通用。你若是不由用的話,保不許每天彈。。。算法
SW(Service Worker) 我已經在前一篇文章裏面講清楚了。這裏主要探究一下另外兩個技術 Push
和 Notification
。首先,有一個問題,這兩個技術是用來幹嗎的呢?chrome
這兩個技術,咱們能夠理解爲就是 server 和 SW 之間,SW 和 user 之間的消息通訊。json
push: server 將更新的信息傳遞給 SWapi
notification: SW 將更新的信息推送給用戶數組
能夠看出,兩個技術是緊密鏈接到一塊兒的。這裏,咱們先來說解一下 notification
的相關技術。
那如今,咱們想給用戶發送一個消息的話應該怎麼發送呢?
代碼很簡單,我直接放了:
self.addEventListener('push', function(event) { var title = 'Yay a message.'; var body = 'We have received a push message.'; var icon = '/images/icon-192x192.png'; var tag = 'simple-push-demo-notification-tag'; var data = { doge: { wow: 'such amaze notification data' } }; event.waitUntil( self.registration.showNotification(title, { body: body, icon: icon, tag: tag, data: data }) ); });
你們一開始看見這個代碼,可能會以爲有點陌生。實際上,這裏是結合 SW 來完成的。push
是 SW 接收到後臺的 push 信息而後出發。固然,咱們獲取信息的主要途徑也是從 event
中獲取的。這裏爲了簡便,就直接使用寫死的信息了。大體解釋一下 API。
event.waitUntil(promise): 該方法是用來延遲 SW 的結束。由於,SW 可能在任什麼時候間結束,爲了防止這樣的狀況,須要使用 waitUntil 監聽 promise,使系統不會在 promise 執行時就結束 SW。
ServiceWorkerRegistration.showNotification(title, [options]): 該方法執行後,會發回一個 promise 對象。
不過,咱們須要記住的是 SW 中的 notification 只是很早之前就退出的桌面 notification 的繼承對象。這意味着,你們若是想要嘗試一下 notification,並不須要手動創建一個 notification,而只要使用
// 桌面端 var not = new Notification("show note", { icon: "newsong.svg", tag: "song" }); not.onclick = function() { dosth(this); }; // 在 SW 中使用 self.registration.showNotification("New mail from Alice", { actions: [{action: 'archive', title: "Archive"}] }); self.addEventListener('notificationclick', function(event) { event.notification.close(); if (event.action === 'archive') { silentlyArchiveEmail(); } else { clients.openWindow("/inbox"); } }, false);
不過,若是你想設置本身想要的 note 效果的話,則須要瞭解一下,showNotification 裏面具體每次參數表明的含義,參考 Mozilla,咱們能夠了解到基本的使用方式。如上,API 的基本格式爲 showNotification(title, [options])
title: 很簡單,就是該次 Not(Notification) 的標題
options: 這個而是一個對象,裏面能夠接受不少參數。
actions[Array]:該對象是一個數組,裏面包含一個一個對象元素。每一個對象包含內容爲:
action[String]: 表示該 Not 的行爲。後面是經過監聽 notificationclick
來進行相關處理
title[String]: 該 action 的標題
icon[URL]: 該 action 顯示的 logo。大小一般爲 24*24
actions 的上限值,一般根據 Notification.maxActions
肯定。經過在 Not 中定義好 actions 觸發,最後咱們會經過,監聽的 notificationclick
來作相關處理:
self.addEventListener('notificationclick', function(event) { var messageId = event.notification.data; event.notification.close(); // 經過設置的 actions 來作適當的響應 if (event.action === 'like') { silentlyLikeItem(); } else if (event.action === 'reply') { clients.openWindow("/messages?reply=" + messageId); } else { clients.openWindow("/messages?reply=" + messageId); } }, false);
body[String]: Not 顯示的主體信息
dir[String]: Not 顯示信息的方向,一般能夠取:auto, ltr, or rtl
icon[String]:Not 顯示的 Icon 圖片路徑。
image[String]:Not 在 body 裏面附帶顯示的圖片 URL,大小最好是 4:3 的比例。
tag[String]:用來標識每一個 Not。方便後續對 Not 進行相關管理。
renotify[Boolean]:當重複的 Not 觸發時,標識是否禁用振動和聲音,默認爲 false
vibrate[Array]:用來設置振動的範圍。格式爲:[振動,暫停,振動,暫停...]。具體取值單位爲 ms。好比:[100,200,100]。振動 100ms,靜止 200ms,振動 100ms。這樣的話,咱們能夠設置本身 APP 都有的振動提示頻率。
sound[String]: 設置音頻的地址。例如: /audio/notification-sound.mp3
data[Any]: 用來附帶在 Not 裏面的信息。咱們通常能夠在 notificationclick
事件中,對回調參數進行調用event.notification.data
。
針對於推送的圖片來講,可能會針對不一樣的手機用到的圖片尺寸會有所區別,例如,針對不一樣的 dpi。
具體參照:
看下 MDN 提供的 demo:
function showNotification() { Notification.requestPermission(function(result) { if (result === 'granted') { navigator.serviceWorker.ready.then(function(registration) { registration.showNotification('Vibration Sample', { body: 'Buzz! Buzz!', icon: '../images/touch/chrome-touch-icon-192x192.png', vibrate: [200, 100, 200, 100, 200, 100, 200], tag: 'vibration-sample' }); }); } }); }
固然,簡單 API 的使用就是上面那樣。可是,若是咱們不加剋制的使用 Not,可能會讓用戶徹底屏蔽掉咱們的推送,得不償失。因此,咱們須要遵循必定的原則去發送。
推送必須簡潔
遵循時間,地點,人物要素進行相關信息的設置。
儘可能不要讓用戶打開網頁查看
雖然這看起來有點違背咱們最初的意圖。不過,這樣確實可以提升用戶的體驗。好比在信息回覆中,直接顯示:XX回覆:...
這樣的格式,能夠徹底省去用戶的打開網頁的麻煩。
不要在 title 和 body 出現同樣的信息
好比:
correct:
incorrect
不要推薦原生 APP
由於頗有可能形成推送信息重複
不要寫上本身的網址
由於,Not 已經幫你寫好了
儘可能讓 icon 和推送有關聯
沒用的 icon:
實用的 icon:
實際上,Not 並不全在 SW 中運行,對於設計用戶初始權限,咱們須要在主頁面中,作出相關的響應。固然,在設置推送的時候,咱們須要考慮到用戶是否會禁用,這裏影響仍是特別大的。
咱們,獲取用戶權限通常能夠直接使用 Notification
上掛載的 permission
屬性來獲取的。
defualt: 表示須要進行詢問。默認狀況是不顯示推送
denied: 不顯示推送
granted: 顯示推送
簡單的來講爲:
function initialiseState() { if (!('showNotification' in ServiceWorkerRegistration.prototype)) { return; } // 檢查是否能夠進行服務器推 if (!('PushManager' in window)) { return; } // 是否被禁用 if (Notification.permission === 'denied') { return; } if (Notification.permission === 'granted') { // dosth(); return; } // 若是還處於默認狀況下,則進行詢問 navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) { // 檢查訂閱 serviceWorkerRegistration.pushManager.getSubscription() .then(function(subscription) { // 檢查是否已經被訂閱 if (!subscription) { // 沒有 return; } // 有 // doSth(); }) .catch(function(err) { window.Demo.debug.log('Error during getSubscription()', err); }); }); }
咱們在加載的時候,須要先進行檢查一遍,若是是默認狀況,則須要發起訂閱的請求。而後再開始進行處理。
那,咱們上面的那段代碼該放在哪一個位置呢?首先,這裏使用到了 SW,這意味着,咱們須要將 SW 先註冊成功才行。實際代碼應放在 SW 註冊成功的回調中:
window.addEventListener('load', function() { if ('serviceWorker' in navigator) { navigator.serviceWorker.register('./service-worker.js') .then(initialiseState); } else { window.Demo.debug.log('Service workers aren\'t supported in this browser.'); } });
爲了更好的顯示信息,咱們還能夠將受權代碼放到後面去。好比,將 subscribe 和 btn 的 click 事件進行綁定。這時候,咱們並不須要考慮 SW 是否已經註冊好了,由於SW 的註冊時間遠遠不及用戶的反應時間。
例如:
var pushButton = document.querySelector('.js-push-button'); pushButton.addEventListener('click', function() { if (isPushEnabled) { unsubscribe(); } else { subscribe(); } });
咱們具體看一下 subscribe 內容:
function subscribe() { var pushButton = document.querySelector('.js-push-button'); pushButton.disabled = true; navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) { // 請求訂閱 serviceWorkerRegistration.pushManager.subscribe({userVisibleOnly: true}) .then(function(subscription) { isPushEnabled = true; pushButton.textContent = 'Disable Push Messages'; pushButton.disabled = false; return sendSubscriptionToServer(subscription); }) }); }
說道這裏,你們可能會看的雲裏霧裏,這裏咱們來具體看一下 serviceWorkerRegistration.pushManager
具體含義。該參數是從 SW 註冊事件回調函數獲取的。也就是說,它是咱們和 SW 交互的通道。該對象上,綁定了幾個獲取訂閱相關的 API:
subscribe(options) [Promise]: 該方法就是咱們經常用來觸發詢問的 API。他返回一個 promise 對象.回調參數爲 pushSubscription 對象。這裏,咱們後面再進行討論。這裏主要說一下 options 裏面有哪些內容
options[Object]
userVisibleOnly[Boolean]:用來表示後續信息是否展現給用戶。一般設置爲 true.
applicationServerKey: 一個 public key。用來加密 server 端 push 的信息。該 key 是一個 Uint8Array 對象。
例如:
registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: new Uint8Array([...]) });
getSubscription() [Promise]: 用來獲取已經訂閱的 push subscription 對象。
permissionState(options) [Promise]: 該 API 用來獲取當前網頁消息推送的狀態 'prompt', 'denied', 或 'granted'。裏面的 options 和 subscribe 裏面的內容一致。
爲了更好的體驗,咱們能夠將二者結合起來,進行相關推送檢查,具體的 load 中,則爲:
window.addEventListener('load', function() { var pushButton = document.querySelector('.js-push-button'); pushButton.addEventListener('click', function() { if (isPushEnabled) { unsubscribe(); } else { subscribe(); } }); if ('serviceWorker' in navigator) { navigator.serviceWorker.register('./service-worker.js') .then(initialiseState); } else { window.Demo.debug.log('Service workers aren\'t supported in this browser.'); } });
固然,這裏面還會涉及其餘的一些細節,我這裏就不過多贅述了。詳情能夠查閱: Notification demo。
咱們開啓一個 Not 詢問很簡單,但關鍵是,若是讓用戶贊成。若是咱們一開始就進行詢問,這樣成功性的可能性過低。咱們能夠在頁面加載後進行詢問。這裏,也有一些提醒原則:
經過具體行爲進行詢問
好比,當我在查詢車票時,就可讓用戶在退出時選擇是否接受推送信息。好比,國外的飛機延遲通知網頁:
讓用戶來決定是否進行推送
由於用戶不是技術人員,咱們須要將一些接口,暴露給用戶。針對推送而言,咱們可讓用戶選擇是否進行推送,而且,在提示的同時,顯示的信息應該儘可能和用戶相關。
web push 在實際協議中,會設計到兩個 server,比較複雜,這裏咱們先來看一下。client 是如何處理接受到的信息的。
當 SW 接受到 server 傳遞過來的信息時,會先觸發 push
事件。咱們一般作以下處理:
self.addEventListener('push', function(event) { if (event.data) { console.log('This push event has data: ',event.data.text()); } else { console.log('This push event has no data.'); } });
其中,咱們經過 server push 過來的 msg 一般是掛載到 event.data
裏的。而且,該部署了 Response 的相關 API:
text(): 返回 string 的內容
json(): 返回 通過 json parse 的對象
blob(): 返回 blob 對象
arrayBuffer(): 返回 arrayBuffer 對象
咱們知道 Service Worker 並非常駐進程,有童鞋可能會問到,那怎麼利用 SW 監聽 push 事件呢?
這裏就不用擔憂了,由於瀏覽器本身會打開一個端口監聽接受到的信息,而後喚起指定的 SW(若是你的瀏覽器是關閉的,那麼你能夠洗洗睡了)。並且,因爲這樣隨機關閉的機制,咱們須要上述提到的 event.waitUntil
API 來幫助咱們完成持續 alive SW 的效果,防止正在執行的異步程序被終止。針對於咱們的 notification 來講,實際上就是一個異步,因此,咱們須要使用上述 API 進行包裹。
self.addEventListener('push', function(event) { const promiseChain = self.registration.showNotification('Hello, World.'); event.waitUntil(promiseChain); });
固然,若是你想在 SW 裏面作更多的異步事情的話,可使用 Promise.all 進行包裹。
self.addEventListener('push', function(event) { const promiseChain = Promise.all([ async1,async2 ]); event.waitUntil(promiseChain); });
以後,就是將具體信息展現推送給用戶了。上面已經將了具體 showNotification
裏面的參數有哪些。不過,這可能不夠直觀,咱們可使用一張圖來感覺一下:
(左:firefox,右:Chrome)
另外,在 showNotification options 裏面,還有一些屬性須要咱們額外注意。
對於指定的 Not 咱們可使用 tag
來代表其惟一性,這表明着當咱們在使用相同 tag
的 Not 時,上一條 Not 會被最新擁有同一個 tag 的Not 替換。即:
const title = 'First Notification'; const options = { body: 'With \'tag\' of \'message-group-1\'', tag: 'message-group-1' }; registration.showNotification(title, options);
顯示樣式爲:
接着,我顯示一個不一樣 tag 的 Not:
const title = 'Second Notification'; const options = { body: 'With \'tag\' of \'message-group-2\'', tag: 'message-group-2' }; registration.showNotification(title, options);
結果爲:
而後,我使用一個一樣 tag 的 Not:
const title = 'Third Notification'; const options = { body: 'With \'tag\' of \'message-group-1\'', tag: 'message-group-1' }; registration.showNotification(title, options);
則相同的 tag 會被最新 tag 的 Not 替換:
該屬性是 Not 裏面又一個比較尷尬的屬性,它的實際應用場景是當有重複 Not 被替換時,震動和聲音能不能被重複播放,但默認爲 false。
那何爲重複呢?
就是,上面咱們提到的 tag 被替換。通常應用場景就是和同一個對象聊天時,發送多個信息來時,咱們不可能推送多個提示信息,通常就是把已經存在的 Not 進行替換就 ok,那麼這就是上面提到的由於重複,被替換的 Not。
通常咱們對於這樣的 Not 能夠設置爲:
const title = 'Second Notification'; const options = { body: 'With "renotify: true" and "tag: \'renotify\'".', tag: 'renotify', renotify: true }; registration.showNotification(title, options);
而且,若是你設置了 renotify
而沒有設置 tag 的話,這是會報錯的 !!!
防止本身推送的 Not 發出任何額外的提示操做(震動,聲音)。默認爲 false。不過,咱們能夠在須要的時候,設置爲 true:
const title = 'Silent Notification'; const options = { body: 'With "silent: \'true\'".', silent: true }; registration.showNotification(title, options);
對於通常的 Not 來講,當展現必定時間事後,就能夠自行消失。不過,若是你的 Not 必定須要用戶去消除的話,可使用 requireInteraction
來進行長時間留存。通常它的默認值爲 false。
const title = 'Require Interaction Notification'; const options = { body: 'With "requireInteraction: \'true\'".', requireInteraction: true }; registration.showNotification(title, options);
如今,你的 Not 已經顯示給用戶,不過,默認狀況下,Not 自己是不會作任何處理的。咱們須要監聽用戶,對其的相關操做(其實就是 click 事件)。
self.addEventListener('notificationclick', function(event) { // do nothing });
另外,經過咱們在 showNotification 裏面設置的 action
,咱們能夠根據其做出不一樣的響應。
self.addEventListener('notificationclick', function(event) { if (event.action) { console.log('Action Button Click.', event.action); } else { console.log('Notification Click.'); } });
這是應該算是最經常使用的一個,只是用來提示用戶的相關信息:
self.addEventListener('notificationclick', function(event) { event.notification.close(); // Do something as the result of the notification click });
這裏,須要使用到咱們的 service 裏面的一個新的 API clients。
event.waitUntil( // examplePage 就是當前頁面的 url clients.openWindow(examplePage) );
這裏須要注意的是 examplePage
必須是和當前 SW 同域名才行。不過,這裏有兩種狀況,須要咱們考慮:
指定的網頁已經打開?
當前沒網?
這裏,咱們能夠利用 cilents 提供的相關 API 獲取,當前瀏覽器已經打開的頁面 URLs。不過這些 URLs 只能是和你 SW 同域的。而後,經過匹配 URL,經過 matchingClient.focus()
進行聚焦。沒有的話,則新打開頁面便可。
const urlToOpen = self.location.origin + examplePage; 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);
另外,若是用戶已經停留在當前的網頁,那咱們可能就不須要推送了,那麼針對於這種狀況,咱們應該怎麼檢測用戶是否正在網頁上呢?
const promiseChain = ({ 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) { return self.registration.showNotification('Had to show a notification.'); } else { console.log('Don\'t need to show a notification.'); } }); event.waitUntil(promiseChain);
固然,若是你本身的網頁已經被用戶打開,咱們一樣也能夠根據推送信息直接將信息傳遞給對應的 window。咱們經過 clients.matchAll
得到的 windowClient
對象,調用 postMessage
來進行消息的推送。
windowClient.postMessage({ message: 'Received a push message.', time: new Date().toString() });
該場景的主要針對消息的合併。好比,聊天消息,當有一個用戶給你發送一個消息時,你能夠直接推送,那若是該用戶又發送一個消息呢?
這時候,比較好的用戶體驗是直接將推送合併爲一個,而後替換便可。
那麼,此時咱們就須要得到當前已經展現的推送消息,這裏主要經過 registration.getNotifications()
API 來進行獲取。該 API 返回的也是一個 Promise 對象。
固然,咱們怎麼肯定兩個消息是同一我的發送的呢?這裏,就須要使用到,上面提到的 Not.data 的屬性。這是咱們在 showNotification
裏面附帶的,能夠直接在 Notification 對象中獲取。
return registration.getNotifications() .then(notifications => { let currentNotification; for(let i = 0; i < notifications.length; i++) { // 檢測已經存在的 Not.data.userName 和新消息的 userName 是否一致 if (notifications[i].data && notifications[i].data.userName === userName) { currentNotification = notifications[i]; } } return currentNotification; }) // 而後,進行相關的邏輯處理,將 body 的內容進行更替 .then((currentNotification) => { let notificationTitle; const options = { icon: userIcon, } if (currentNotification) { // We have an open notification, let's so something with it. const messageCount = currentNotification.data.newMessageCount + 1; options.body = `You have ${messageCount} new messages from ${userName}.`; options.data = { userName: userName, newMessageCount: messageCount }; notificationTitle = `New Messages from ${userName}`; currentNotification.close(); } else { options.body = `"${userMessage}"`; options.data = { userName: userName, newMessageCount: 1 }; notificationTitle = `New Message from ${userName}`; } return registration.showNotification( notificationTitle, options ); });
至關於從:
變爲:
上面提到了在 SW 中使用,clients 獲取窗口信息,這裏咱們先補充一下相關的知識。
咱們能夠將 Clients 理解爲咱們如今所在的瀏覽器,不過特殊的地方在於,它是遵照同域規則的,即,你只能操做和你域名一致的窗口。一樣,Clients 也只是一個集合,用來管理你當前全部打開的頁面,實際上,每一個打開的頁面都是使用一個 cilent object 進行表示的。這裏,咱們先來探討一下 cilent object:
Client.postMessage(msg[,transfer]): 用來和指定的窗口進行通訊
Client.frameType: 代表當前窗口的上下文。該值能夠爲: auxiliary, top-level, nested, 或者 none.
Client.id[String]: 使用一個惟一的 id 表示當前窗口
Client.url: 當前窗口的 url。
WindowClient.focus(): 該方法是用來聚焦到當前 SW 控制的頁面。下面幾個也是 Client,不過是專門針對 type=window
的client。
WindowClient.navigate(url): 將當前頁面到想到指定 url
WindowClient.focused[boolean]: 表示用戶是否停留在當前 client
WindowClient.visibilityState: 用來表示當前 client 的可見性。實際和 focused
沒太大的區別。可取值爲: hidden, visible, prerender, or unloaded
。
而後,Clients Object 就是用來管理每一個窗口的。經常使用方法有:
Clients.get(id): 用來得到某個具體的 client object
self.clients.get(id).then(function(client) { // 打開具體某個窗口 self.clients.openWindow(client.url); });
Clients.matchAll(options): 用來匹配當前 SW 控制的窗口。因爲 SW 是根據路徑來控制的,有可能只返回一部分,而不是同域。若是須要返回同域的窗口,則須要設置響應的 options。
includeUncontrolled[Boolean]: 是否返回全部同域的 client。默認爲 false
。只返回當前 SW 控制的窗口。
type: 設置返回 client 的類型。一般有:window, worker, sharedworker, 和 all。默認是 all
。
// 經常使用屬性爲: clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) { for(var i = 0 ; i < clients.length ; i++) { if(clientList[i].url === 'index.html') { clients.openWindow(clientList[i]); } } });
Clients.openWindow(url): 用來打開具體某個頁面
Clients.claim(): 用來設置當前 SW 和同域的 cilent 進行關聯。
先貼一張 google 關於 web push 的詳解圖:
上述圖,簡單闡述了從 server 產生信息,最終到手機生成提示信息的一系列過程。
先說一下中間那個 Message Server。這是獨立於咱們經常使用的 Server -> Client 的架構,瀏覽器能夠本身選擇 push service,開發者通常也不用關心。不過,若是你想使用本身定製的 push serivce 的話,只須要保證你的 service 可以提供同樣的 API 便可。上述過程爲:
用於打開你的網頁,而且,已經生成好用來進行 push 的 applicationServerKey
。而後,phone 開始初始化 SW
用戶訂閱該網頁的推送,此時會給 message server 發送一個請求,建立一個訂閱,而後返回 message server 的相關信息。
瀏覽器得到 message server 的相關信息後,而後在發送一個請求給該網頁的 server。
若是 server 這邊檢測到有新的信息須要推送,則它會想 message server 發送相關請求便可。
這裏,咱們能夠預先看一下 message server 返回來的內容:
{ "endpoint": "https://random-push-service.com/some-kind-of-unique-id-1234/v2/", "keys": { "p256dh" : "BNcRdreALRFXTkOOUHK1EtK2wtaz5Ry4YfYCA_0QTpQtUbVlUls0VJXg7A8u-Ts1XbjhazAkj7I99e8QcYP7DkM=", "auth" : "tBHItJI5svbpez7KI4CCXg==" } }
endpoint
就是瀏覽器訂閱的 message server 的地址。這裏的 keys 咱們放到後面講解,主要就是用來進行 push message 的加密。
根據官方解釋,Message Server 與用戶將的通訊,借用的是 HTTP/2 的 server push 協議。上面的圖,其實能夠表達爲:
+-------+ +--------------+ +-------------+ | UA | | Push Service | | Application | +-------+ +--------------+ | Server | | | +-------------+ | Subscribe | | |--------------------->| | | Monitor | | |<====================>| | | | | | Distribute Push Resource | |-------------------------------------------->| | | | : : : | | Push Message | | Push Message |<---------------------| |<---------------------| | | | |
接下來,咱們就須要簡單的來看一下使用 Web Push 的基本原則。
首先,server 發送的 push msg 必須被加密,由於這防止了中間的 push service 去查看咱們的推送的信息。
經過 server 發送的 msg 須要設置一個失效時間,覺得 Web Push 真正可以做用的時間是當用戶打開瀏覽器的時候,若是用戶沒有打開瀏覽器,那麼 push service 會一直保存該信息直到該條 push msg 過時。
那麼若是咱們想讓用戶訂閱咱們的 push service 咱們首先須要獲得用戶是否進行提示的許可。固然,一開始咱們還須要判斷一下,該用戶是否已經受權,仍是拒絕,或者是還未處理。這裏,能夠參考上面提到的推送權限一節中的 initialiseState
函數方法。
這裏咱們主要研究一下具體的訂閱環節(假設用戶已經贊成推送)。基本格式爲:
navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) { const subscribeOptions = { userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array( 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U' ) }; return registration.pushManager.subscribe(subscribeOptions); )} .then(function(subscription) { return subscription })
這裏有兩個參數 userVisibleOnly
和 applicationServerKey
。這兩個屬性值具體表明什麼意思呢?
該屬性能夠算是強制屬性(你必須填,並且只能填 true)。由於,一開始 Notification 的設計是 能夠在用戶拒絕的狀況下繼續在後臺執行推送操做,這形成了另一種狀況,開發者能夠在用戶關閉的狀況下,經過 web push 獲取用戶的相關信息。因此,爲了安全性保證,咱們通常只能使用該屬性,而且只能爲 true(若是,不呢?瀏覽器就會報錯)。
前面說過它是一個 public key。用來加密 server 端 push 的信息。該 key 是一個 Uint8Array 對象,並且它 須要符合 VAPID 規範實際,因此咱們通常能夠叫作 application server keys
或者 VAPID keys
,咱們的 server 其實有私鑰和公鑰兩把鑰匙,這裏和 TLS/SSL 協商機制相似,不過不會協商出 session key,直接經過 pub/pri key 進行信息加/解密。不過,它還有其餘的用處:
對於信息
進行加密/解密,加強安全性
對於 push service
保證惟一性,由於 subscribe 會將該 key 發送過去。在 push service 那邊,會根據該 key 針對每次發送生成獨一無二的 endpoint,而後根據該 endpoint 給某些指定用戶信息 push message。
整個流程圖爲:
另外,該 key 還有一個更重要的用途是,當在後臺 server 須要進行 push message,向 push service 發送請求時,會有一個 Authorization
頭,該頭的內容時由 private key 進行加密的。而後,push service 接受到以後,會根據配套的 endpoint 的 public key 進行解密,若是解密成功則表示該條信息是有效信息(發送的 server 是合法的)。
流程圖爲:
經過 subscribe() 異步調用返回的值 subscription
的具體格式爲:
{ "endpoint": "https://some.pushservice.com/something-unique", "keys": { "p256dh": "BIPUL12DLfytvTajnryr2PRdAgXS3HGKiLqndGcJGabyhHheJYlNGCeXl1dn18gSJ1WAkAPIxr4gK0_dQds4yiI=", "auth":"FPssNDTKnInHVndSTdbKFw==" } }
簡單說一下參數,endpoint 就是 push service 的 URL,咱們的 server 若是有消息須要推送,就是想該路由發送請求。而 keys 就是用來對信息加密的鑰匙。獲得返回的 subscription
以後,咱們須要發送給後臺 server 進行存儲。由於,每一個用戶的訂閱都會產生獨一無二的 endpoint,因此,咱們只須要將 endpoint 和關聯用戶存儲起來就 ok 了。
fetch('/api/save-subscription/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription) })
接下來就到了 server 推送 msg 的環節了。
當服務器有新的消息須要推送時,就須要向 push service 發送相關的請求進行 web push。不過,這裏咱們須要瞭解,從服務器到 push service
的請求,實際上就是 HTTP 的 post method。咱們看一個具體的請求例子:
POST /push-service/send/dbDqU8xX10w:APA91b... HTTP/1.1 Host: push.example.net Push-Receipt: https://push.example.net/r/3ZtI4YVNBnUUZhuoChl6omU TTL: 43200 Content-Type: text/plain;charset=utf8 Content-Length: 36 Authorization: WebPush eyJ0eXAiOiJKV1QiLCJErtm.ysazNjjvW2L9OkSSHzvoD1oA Crypto-Key: p256ecdsa=BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU\_RCPCfA5aq9ojSwk5Y2EmClBPsiChYuI3jMzt3ir20P8r\_jgRR-dSuN182x7iB
固然,變化的是裏面推送的具體的 Headers 和 body 內容。咱們能夠看一下具體頭部表明的意思:
Header | Content | |||
---|---|---|---|---|
Authorization | 能夠理解該頭是一個 JSON Web Token,用來驗證是不是真實的訂閱 server | |||
Crypto-Key | 用來表示加密的 key。它由兩部分組成:dh=publicKey,p256ecdsa=applicationServerKey 。其中 p256ecdsa 就是由 pub key 加密的 base64 的 url |
|||
Encryption | 它用來放置加鹽祕鑰。用來加密 payload | |||
Content-Type | 若是你沒發送 payload 的話,那麼就不用發送該頭。若是發送了,則須要將其設置爲 application/octet-stream 。這是爲了告訴瀏覽器我發送的是 stream data |
|||
Content-Length | 用來描述 payload 的長度(沒有 payload 的不用) | |||
Content-Encoding | 該頭必須一直是 aesgcm 不論你是否發送 payload |
|||
TTL (Time to Live) | 表示該 message 能夠在 push service 上停留多長時間(爲何停留?由於用戶沒有打開指定瀏覽器,push service 發佈過去)。若是 TTL 爲 0,表示當有推送信息時,而且此時 push service 可以和用戶的瀏覽器創建聯繫,則第一時間發送過去。不然當即失效 | |||
Topic | 該頭實際上和 Notification 中的 tag 頭相似。若是 server 前後發送了兩次擁有相同 Topic 的 message 請求,若是前一條 topic 正在 pending 狀態,則會被最新一條 topic 代替。不過,該 Topic 必須 <= 32 個字符 |
|||
Urgency[實驗特性] | 表示該消息的優先級,優先級高的 Notification 會優先發送。默認值爲: default 。可取值爲: "very-low" |
"low" | "normal" | "high" |
一般,push service 接受以後,會返回相關的狀態碼,來表示具體操做結果:
statusCode | Description |
---|---|
201 | 表示推送消息在 push service 中已經成功建立 |
429 | 此時,push service 有太多的推送請求,沒法響應你的請求。而且,push service 會返回 Retry-After 的頭部,表示你下次重試的時間。 |
400 | 無效請求,表示你的請求中,有不符合規範的頭部 |
413 | 你的 payload 過大。最小的 payload 大小爲 4kb |
能夠從上面頭部看出,push service 須要的頭很複雜,若是咱們純原生手寫的話,估計很快就寫煩了。這裏推薦一下 github 裏面的庫,能夠直接根據 app server key 來生成咱們想要的請求頭。這裏,咱們打算細節的瞭解一下每一個頭部內容產生的相關協議。
首先,這個 key 是怎麼拿到的?須要申請嗎?
答案是:不須要。這個 key 只要你符合必定規範就 ok。不過一旦生成以後,不要輕易改動,由於後面你會一直用到它進行信息交流。規則簡單來講爲:
它是 server 端生成 pub/pri keys 的公鑰
它是能夠經過 crypto
加密庫,依照 P-256
曲線,生成`ECDSA` 簽名方式。
該 key 須要是一個 8 位的非負整型數組(Unit8Array)
簡單 demo 爲:
function generateVAPIDKeys() { var curve = crypto.createECDH('prime256v1'); curve.generateKeys(); return { publicKey: curve.getPublicKey(), privateKey: curve.getPrivateKey(), }; } // 也能夠直接根據 web-push 庫生成 const vapidKeys = webpush.generateVAPIDKeys();
具體頭部詳細信息以下:
Authorization 頭部的值(上面也提到了)是一個 JSON web token(簡稱爲 JWT)。基本格式爲:
Authorization: WebPush <JWT Info>.<JWT Payload>.<Signature>
實際上,該頭涵蓋了不少信息(手寫很累的。。。)。因此,咱們這裏能夠利用現有的一些 github 庫,好比 jsonwebtoken。專門用來生成,JWT 的。咱們看一下它顯示的例子:
簡單來講,上面 3 部分都是將對象經過 private key 加密生成的字符串。
info 表明:
{ "typ": "JWT", "alg": "ES256" }
用來表示 JWT 的加密算法是啥。
Payload 表明:
{ "aud": "https://some-push-service.org", "exp": "1469618703", "sub": "mailto:example@web-push-book.org" }
其中
aud 表示,push service 是誰
exp(expire)表示過時時間,而且是以秒爲單位,最多隻能是一天。
sub 用來表示 push service 的聯繫方式。
Signature 表明:
它是用來驗證信息安全性的頭。它是前面兩個,JWT.info + '.' + JWT.payload
的字符串經過私有 key 加密的生成的結果。
這就是咱們公鑰的內容,簡單格式爲:
Crypto-Key: dh=<URL Safe Base64 Encoded String>, p256ecdsa=<URL Safe Base64 Public Application Server Key> // 兩個參數分別表明: dh=publicKey,p256ecdsa=applicationServerKey
這幾個頭是涉及 payload 傳輸時,須要用到的。基本格式爲:
Content-Length: <Number of Bytes in Encrypted Payload> Content-Type: 'application/octet-stream' Content-Encoding: 'aesgcm'
其中,只有 Content-Length
是可變的,用來表示 payload 的長度。
這幾個頭上面已經說清楚了,我這裏就不贅述了。
最後放一張關於 SW 的總結圖: