本文是《PWA學(xué)習(xí)與實踐》系列的第三篇文章。
PWA作為今年最火熱的技術(shù)概念之一癞揉,對提升Web應(yīng)用的安全纸肉、性能和體驗有著很大的意義,非常值得我們?nèi)チ私馀c學(xué)習(xí)喊熟。
本系列文章《PWA學(xué)習(xí)與實踐》會逐步拆解PWA背后的各項技術(shù)柏肪,通過實例代碼來講解這些技術(shù)的應(yīng)用方式。也正是因為PWA中技術(shù)點眾多芥牌、知識細(xì)碎烦味,因此我在學(xué)習(xí)過程中,進(jìn)行了整理壁拉,并產(chǎn)出了《PWA學(xué)習(xí)與實踐》系列文章谬俄,希望能帶大家全面了解PWA中的各項技術(shù)。對PWA感興趣的朋友歡迎關(guān)注弃理。
本文中的代碼都可以在learning-pwa的push分支上找到(
git clone
后注意切換到push分支)溃论。
1. 引言
在之前的幾篇文章中,我和大家分享了如何使用manifest(以及meta標(biāo)簽)讓你的Web App更加“native”痘昌;以及如何使用Service Worker來cache資源钥勋,加速Web App的訪問速度,提供部分離線功能辆苔。在接下來的內(nèi)容里算灸,我們會探究PWA中的另一個重要功能——消息推送與提醒(Push & Notification)。這個能力讓我們可以從服務(wù)端向用戶推送各類消息并引導(dǎo)用戶觸發(fā)相應(yīng)交互驻啤。
實際上菲驴,消息推送與提醒是兩個功能——Push API 和 Notification API。為了大家能夠更好理解其中的相關(guān)技術(shù)街佑,我也會分為Push(推送消息)與Notification(展示提醒)兩部分來介紹谢翎。在這一篇里,我們先來學(xué)習(xí)如何使用Push API進(jìn)行消息推送沐旨。
Push API 和 Notification API其實是兩個獨立的技術(shù)森逮,完全可以分開使用;不過Push API 和 Notification API相結(jié)合是一個常見的模式磁携。
2. 瀏覽器是如何實現(xiàn)服務(wù)器消息Push的
Web Push的整個流程相較之前的內(nèi)容來說有些復(fù)雜褒侧。因此,在進(jìn)入具體技術(shù)細(xì)節(jié)之前谊迄,我們需要先了解一下整個Push的基本流程與相關(guān)概念闷供。
如果你對Push完全不了解,可能會認(rèn)為统诺,Push是我們的服務(wù)端直接與瀏覽器進(jìn)行交互歪脏,使用長連接、WebSocket或是其他技術(shù)手段來向客戶端推送消息粮呢。然而婿失,這里的Web Push并非如此,它其實是一個三方交互的過程啄寡。
在Push中登場的三個重要“角色”分別是:
- 瀏覽器:就是我們的客戶端
- Push Service:專門的Push服務(wù)豪硅,你可以認(rèn)為是一個第三方服務(wù),目前chrome與firefox都有自己的Push Service Service挺物。理論上只要瀏覽器支持懒浮,可以使用任意的Push Service
- 后端服務(wù):這里就是指我們自己的后端服務(wù)
下面就介紹一下這三者在Web Push中是如何交互。
2.1. 消息推送流程
下圖來自Web Push協(xié)議草案识藤,是Web Push的整個流程:
+-------+ +--------------+ +-------------+
| UA | | Push Service | | Application |
+-------+ +--------------+ | Server |
| | +-------------+
| Subscribe | |
|--------------------->| |
| Monitor | |
|<====================>| |
| | |
| Distribute Push Resource |
|-------------------------------------------->|
| | |
: : :
| | Push Message |
| Push Message |<---------------------|
|<---------------------| |
| | |
該時序圖表明了Web Push的各個步驟砚著,我們可以將其分為訂閱(subscribe)與推送(push)兩部分來看。
-
subscribe痴昧,首先是訂閱:
- Ask Permission:這一步不再上圖的流程中赖草,這其實是瀏覽器中的策略。瀏覽器會詢問用戶是否允許通知剪个,只有在用戶允許后秧骑,才能進(jìn)行后面的操作。
- Subscribe:瀏覽器(客戶端)需要向Push Service發(fā)起訂閱(subscribe)扣囊,訂閱后會得到一個
PushSubscription
對象 - Monitor:訂閱操作會和Push Service進(jìn)行通信乎折,生成相應(yīng)的訂閱信息,Push Service會維護(hù)相應(yīng)信息侵歇,并基于此保持與客戶端的聯(lián)系骂澄;
- Distribute Push Resource:瀏覽器訂閱完成后,會獲取訂閱的相關(guān)信息(存在于
PushSubscription
對象中)惕虑,我們需要將這些信息發(fā)送到自己的服務(wù)端坟冲,在服務(wù)端進(jìn)行保存磨镶。
-
Push Message,然后是推送:
- Push Message階段一:我們的服務(wù)端需要推送消息時健提,不直接和客戶端交互琳猫,而是通過Web Push協(xié)議,將相關(guān)信息通知Push Service私痹;
- Push Message階段二:Push Service收到消息脐嫂,通過校驗后,基于其維護(hù)的客戶端信息紊遵,將消息推送給訂閱了的客戶端账千;
- 最后,客戶端收到消息暗膜,完成整個推送過程匀奏。
2.2. 什么是Push Service
在上面的Push流程中,出現(xiàn)了一個比較少接觸到的角色:Push Service学搜。那么什么是Push Service呢衬潦?
A push service receives a network request, validates it and delivers a push message to the appropriate browser.
Push Service可以接收網(wǎng)絡(luò)請求膘婶,校驗該請求并將其推送給合適的瀏覽器客戶端误证。Push Service還有一個非常重要的功能:當(dāng)用戶離線時顷蟆,可以幫我們保存消息隊列,直到用戶聯(lián)網(wǎng)后再發(fā)送給他們钉凌。
目前咧最,不同的瀏覽器廠商使用了不同的Push Service。例如御雕,chrome使用了google自家的FCM(前身為GCM)矢沿,firefox也是使用自家的服務(wù)。那么我們是否需要寫不同的代碼來兼容不同的瀏覽器所使用的服務(wù)呢酸纲?答案是并不用捣鲸。Push Service遵循Web Push Protocol,其規(guī)定了請求及其處理的各種細(xì)節(jié)闽坡,這就保證了栽惶,不同的Push Service也會具有標(biāo)準(zhǔn)的調(diào)用方式。
這里再提一點:我們在上一節(jié)中說了Push的標(biāo)準(zhǔn)流程疾嗅,其中第一步就是瀏覽器發(fā)起訂閱外厂,生成一個PushSubscription
對。Push Service會為每個發(fā)起訂閱的瀏覽器生成一個唯一的URL代承,這樣汁蝶,我們在服務(wù)端推送消息時,向這個URL進(jìn)行推送后论悴,Push Service就會知道要通知哪個瀏覽器掖棉。而這個URL信息也在PushSubscription
對象里墓律,叫做endpoint
。
那么幔亥,如果我們知道了endpoint
的值耻讽,是否就代表我們可以向客戶端推送消息了呢?并非如此紫谷。下面會簡單介紹一下Web Push中的安全策略齐饮。
2.3. 如何保證Push的安全性
在Web Push中捐寥,為了保證客戶端只會收到其訂閱的服務(wù)端推送的消息(其他的服務(wù)端即使在拿到endpoint
也無法推送消息)笤昨,需要對推送信息進(jìn)行數(shù)字簽名。該過程大致如下:
在Web Push中會有一對公鑰與私鑰握恳÷髦希客戶端持有公鑰,而服務(wù)端持有私鑰乡洼〕绮茫客戶端在訂閱時,會將公鑰發(fā)送給Push Service束昵,而Push Service會將該公鑰與相應(yīng)的endpoint
維護(hù)起來拔稳。而當(dāng)服務(wù)端要推送消息時,會使用私鑰對發(fā)送的數(shù)據(jù)進(jìn)行數(shù)字簽名锹雏,并根據(jù)數(shù)字簽名生成一個叫】Authorization
請求頭巴比。Push Service收到請求后,根據(jù)endpoint
取到公鑰礁遵,對數(shù)字簽名解密驗證轻绞,如果信息相符則表明該請求是通過對應(yīng)的私鑰加密而成,也表明該請求來自瀏覽器所訂閱的服務(wù)端佣耐。反之亦然政勃。
而公鑰與私鑰如何生成,會在第三部分的實例中講解兼砖。
3. 如何使用Push API來推送向用戶推送信息
到這里奸远,我們已經(jīng)基本了解了Web Push的流程。光說不練假把式讽挟,下面我就通過具體代碼來說明如何使用Web Push然走。
這部分會基于sw-cache分支上的代碼,繼續(xù)增強(qiáng)我們的“圖書搜索”WebApp戏挡。
為了使文章與代碼更清晰芍瑞,將Web Push分為這幾個部分:
- 瀏覽器發(fā)起訂閱,并將訂閱信息發(fā)送至后端褐墅;
- 將訂閱信息保存在服務(wù)端拆檬,以便今后推送使用洪己;
- 服務(wù)端推送消息,向Push Service發(fā)起請求竟贯;
- 瀏覽器接收Push信息并處理答捕。
友情提醒:由于Chrome所依賴的Push Service——FCM在國內(nèi)不可訪問,所以要正常運(yùn)行demo中的代碼需要“梯子”屑那,或者可以選擇Firefox來進(jìn)行測試拱镐。
3.1. 瀏覽器(客戶端)生成subscription信息
首先,我們需要使用PushManager
的subscribe
方法來在瀏覽器中進(jìn)行訂閱持际。
在《讓你的WebApp離線可用》中我們已經(jīng)知道了如何注冊Service Worker沃琅。當(dāng)我們注冊完Service Worker后會得到一個Registration
對象,通過調(diào)用Registration
對象的registration.pushManager.subscribe()
方法可以發(fā)起訂閱蜘欲。
為了使代碼更清晰益眉,本篇demo在之前的基礎(chǔ)上,先抽離出Service Worker的注冊方法:
// index.js
function registerServiceWorker(file) {
return navigator.serviceWorker.register(file);
}
然后定義了subscribeUserToPush()
方法來發(fā)起訂閱:
// index.js
function subscribeUserToPush(registration, publicKey) {
var subscribeOptions = {
userVisibleOnly: true,
applicationServerKey: window.urlBase64ToUint8Array(publicKey)
};
return registration.pushManager.subscribe(subscribeOptions).then(function (pushSubscription) {
console.log('Received PushSubscription: ', JSON.stringify(pushSubscription));
return pushSubscription;
});
}
這里使用了registration.pushManager.subscribe()
方法中的兩個配置參數(shù):userVisibleOnly
和applicationServerKey
姥份。
-
userVisibleOnly
表明該推送是否需要顯性地展示給用戶郭脂,即推送時是否會有消息提醒。如果沒有消息提醒就表明是進(jìn)行“靜默”推送澈歉。在Chrome中展鸡,必須要將其設(shè)置為true
,否則瀏覽器就會在控制臺報錯:
-
applicationServerKey
是一個客戶端的公鑰埃难,VAPID定義了其規(guī)范莹弊,因此也可以稱為VAPID keys。如果你還記得2.3中提到的安全策略凯砍,應(yīng)該對這個公鑰不陌生箱硕。該參數(shù)需要Unit8Array類型。因此定義了一個urlBase64ToUint8Array
方法將base64的公鑰字符串轉(zhuǎn)為Unit8Array悟衩。subscribe()
也是一個Promise方法剧罩,在then中我們可以得到訂閱的相關(guān)信息——一個PushSubscription
對象。下圖展示了這個對象中的一些信息座泳。注意其中的endpoint
惠昔,Push Service會為每個客戶端隨機(jī)生成一個不同的值.
之后,我們再將PushSubscription
信息發(fā)送到后端挑势。這里定義了一個sendSubscriptionToServer()
方法镇防,該方法就是一個普通的XHR請求,會向接口post訂閱信息潮饱,為了節(jié)約篇幅就不列出具體代碼了来氧。
最后,將這一系列方法組合在一起。當(dāng)然啦扬,使用Web Push前中狂,還是需要進(jìn)行特性檢測'PushManager' in window
。
// index.js
if ('serviceWorker' in navigator && 'PushManager' in window) {
var publicKey = 'BOEQSjdhorIf8M0XFNlwohK3sTzO9iJwvbYU-fuXRF0tvRpPPMGO6d_gJC_pUQwBT7wD8rKutpNTFHOHN3VqJ0A';
// 注冊service worker
registerServiceWorker('./sw.js').then(function (registration) {
console.log('Service Worker 注冊成功');
// 開啟該客戶端的消息推送訂閱功能
return subscribeUserToPush(registration, publicKey);
}).then(function (subscription) {
var body = {subscription: subscription};
// 為了方便之后的推送扑毡,為每個客戶端簡單生成一個標(biāo)識
body.uniqueid = new Date().getTime();
console.log('uniqueid', body.uniqueid);
// 將生成的客戶端訂閱信息存儲在自己的服務(wù)器上
return sendSubscriptionToServer(JSON.stringify(body));
}).then(function (res) {
console.log(res);
}).catch(function (err) {
console.log(err);
});
}
注意胃榕,這里為了方便我們后面的推送,為每個客戶端生成了一個唯一IDuniqueid
瞄摊,這里使用了時間戳生成簡單的uniqueid
勋又。
此外,由于userVisibleOnly
為true
换帜,所以需要用戶授權(quán)開啟通知權(quán)限楔壤,因此我們會看到下面的提示框,選擇“允許”即可膜赃。你可以在設(shè)置中進(jìn)行通知的管理挺邀。
3.2. 服務(wù)端存儲客戶端subscription信息
為了存儲瀏覽器post來的訂閱信息揉忘,服務(wù)端需要增加一個接口/subscription
跳座,同時添加中間件koa-body
用于處理body
// app.js
const koaBody = require('koa-body');
/**
* 提交subscription信息,并保存
*/
router.post('/subscription', koaBody(), async ctx => {
let body = ctx.request.body;
await util.saveRecord(body);
ctx.response.body = {
status: 0
};
});
接收到subscription信息后泣矛,需要在服務(wù)端進(jìn)行保存疲眷,你可使用任何方式來保存它:mysql、redis您朽、mongodb……這里為了方便狂丝,我使用了nedb來進(jìn)行簡單的存儲。nedb不需要部署安裝哗总,可以將數(shù)據(jù)存儲在內(nèi)存中几颜,也可以持久化,nedb的api和mongodb也比較類似讯屈。
這里util.saveRecord()
做了這些工作:首先蛋哭,查詢subscription
信息是否存在,若已存在則只更新uniqueid
涮母;否則谆趾,直接進(jìn)行存儲。
至此叛本,我們就將客戶端的訂閱信息存儲完畢了』ε睿現(xiàn)在,就可以等待今后推送時使用来候。
3.3. 使用subscription信息推送信息
在實際中跷叉,我們一般會給運(yùn)營或產(chǎn)品同學(xué)提供一個推送配置后臺。可以選擇相應(yīng)的客戶端云挟,填寫推送信息峡眶,并發(fā)起推送。為了簡單起見植锉,我并沒有寫一個推送配置后臺辫樱,而只提供了一個post接口/push
來提交推送信息。后期我們完全可以開發(fā)相應(yīng)的推送后臺來調(diào)用該接口俊庇。
// app.js
/**
* 消息推送API狮暑,可以在管理后臺進(jìn)行調(diào)用
* 本例子中,可以直接post一個請求來查看效果
*/
router.post('/push', koaBody(), async ctx => {
let {uniqueid, payload} = ctx.request.body;
let list = uniqueid ? await util.find({uniqueid}) : await util.findAll();
let status = list.length > 0 ? 0 : -1;
for (let i = 0; i < list.length; i++) {
let subscription = list[i].subscription;
pushMessage(subscription, JSON.stringify(payload));
}
ctx.response.body = {
status
};
});
來看一下/push
接口辉饱。
- 首先搬男,根據(jù)post的參數(shù)不同,我們可以通過
uniqueid
來查詢某條訂閱信息:util.find({uniqueid})
彭沼;也可以從數(shù)據(jù)庫中查詢出所有訂閱信息:util.findAll()
缔逛。 - 然后通過
pushMessage()
方法向Push Service發(fā)送請求。根據(jù)第二節(jié)的介紹姓惑,我們知道褐奴,該請求需要符合Web Push協(xié)議。然而于毙,Web Push協(xié)議的請求封裝敦冬、加密處理相關(guān)操作非常繁瑣。因此唯沮,Web Push為各種語言的開發(fā)者提供了一系列對應(yīng)的庫:Web Push Libaray脖旱,目前有NodeJS、PHP介蛉、Python萌庆、Java等。把這些復(fù)雜而繁瑣的操作交給它們可以讓我們事半功倍币旧。 - 最后返回結(jié)果践险,這里只是簡單的根據(jù)是否有訂閱信息來進(jìn)行返回。
安裝node版web-push
npm install web-push --save
前面我們提到的公鑰與私鑰佳恬,也可以通過web-push來生成
使用web-push非常簡單捏境,首先設(shè)置VAPID keys:
// app.js
const webpush = require('web-push');
/**
* VAPID值
* 這里可以替換為你業(yè)務(wù)中實際的值
*/
const vapidKeys = {
publicKey: 'BOEQSjdhorIf8M0XFNlwohK3sTzO9iJwvbYU-fuXRF0tvRpPPMGO6d_gJC_pUQwBT7wD8rKutpNTFHOHN3VqJ0A',
privateKey: 'TVe_nJlciDOn130gFyFYP8UiGxxWd3QdH6C5axXpSgM'
};
// 設(shè)置web-push的VAPID值
webpush.setVapidDetails(
'mailto:alienzhou16@163.com',
vapidKeys.publicKey,
vapidKeys.privateKey
);
設(shè)置完成后即可使用webpush.sendNotification()
方法向Push Service發(fā)起請求。
最后我們來看下pushMessage()
方法的細(xì)節(jié):
// app.js
/**
* 向push service推送信息
* @param {*} subscription
* @param {*} data
*/
function pushMessage(subscription, data = {}) {
webpush.sendNotification(subscription, data, options).then(data => {
console.log('push service的相應(yīng)數(shù)據(jù):', JSON.stringify(data));
return;
}).catch(err => {
// 判斷狀態(tài)碼毁葱,440和410表示失效
if (err.statusCode === 410 || err.statusCode === 404) {
return util.remove(subscription);
}
else {
console.log(subscription);
console.log(err);
}
})
}
webpush.sendNotification
為我們封裝了請求的處理細(xì)節(jié)垫言。狀態(tài)碼401和404表示該subscription已經(jīng)無效,可以從數(shù)據(jù)庫中刪除倾剿。
3.4. Service Worker監(jiān)聽Push消息
調(diào)用webpush.sendNotification()
后筷频,我們就已經(jīng)把消息發(fā)送至Push Service了蚌成;而Push Service會將我們的消息推送至瀏覽器。
要想在瀏覽器中獲取推送信息凛捏,只需在Service Worker中監(jiān)聽push
的事件即可:
// sw.js
self.addEventListener('push', function (e) {
var data = e.data;
if (e.data) {
data = data.json();
console.log('push的數(shù)據(jù)為:', data);
self.registration.showNotification(data.text);
}
else {
console.log('push沒有任何數(shù)據(jù)');
}
});
4. 效果展示
我們同時使用firefox與chrome來訪問該WebApp担忧,并分別向這兩個客戶端推送消息。我們可以使用console中打印出來的uniqueid坯癣,在postman中發(fā)起/push
請求進(jìn)行測試瓶盛。
可以看到,我們分別向firefox與chrome中推送了“welcome to PWA”這條消息示罗。console中的輸出來自于Service Worker中對push事件的監(jiān)聽惩猫。而彈出的瀏覽器提醒則來自于之前提到的、訂閱時配置的userVisibleOnly: true
屬性蚜点。在后續(xù)的文章里轧房,我繼續(xù)帶大家了解Notification API(提醒)的使用。
正如前文所述绍绘,Push Service可以在設(shè)備離線時奶镶,幫你維護(hù)推送消息。當(dāng)瀏覽器設(shè)備重新聯(lián)網(wǎng)時陪拘,就會收到該推送厂镇。下面展示了在設(shè)備恢復(fù)聯(lián)網(wǎng)后,就會收到推送:
5. 萬惡的兼容性
又到了查看兼容性的時間了藻丢。比較重要的是剪撬,對于Push API摄乒,目前Safari團(tuán)隊并沒有明確表態(tài)計劃支持悠反。
當(dāng)然,其實比兼容性更大的一個問題是馍佑,Chrome所依賴的FCM服務(wù)在國內(nèi)是無法訪問的斋否,而Firefox的服務(wù)在國內(nèi)可以正常使用。這也是為什么在代碼中會有這一項設(shè)置:
const options = {
// proxy: 'http://localhost:1087' // 使用FCM(Chrome)需要配置代理
};
上面代碼其實是用來配置web-push代理的拭荤。這里有一點需要注意茵臭,目前從npm上安裝的web-push是不支持設(shè)置代理選項的。針對這點github上專門有issue進(jìn)行了討論舅世,并在最近(兩周前)合入了相應(yīng)的PR旦委。因此,如果需要web-push支持代理雏亚,簡單的方式就是基于master進(jìn)行web-push代碼的相應(yīng)調(diào)整缨硝。
雖然由于google服務(wù)被屏蔽,導(dǎo)致國內(nèi)Push功能無法在chrome上使用罢低,但是作為一個重要的技術(shù)點查辩,Web Push還是非常值得我們了解與學(xué)習(xí)的胖笛。
6. 寫在最后
本文中所有的代碼示例均可以在learn-pwa/push上找到。注意在git clone之后宜岛,切換到push分支长踊。切換其他分支可以看到不同的版本:
- basic分支:基礎(chǔ)項目demo,一個普通的圖書搜索應(yīng)用(網(wǎng)站)萍倡;
- manifest分支:基于basic分支身弊,添加manifest等功能;
- sw-cache分支:基于manifest分支列敲,添加緩存與離線功能佑刷;
- push分支:基于sw-cache分支,添加服務(wù)端消息推送功能酿炸;
- master分支:應(yīng)用的最新代碼瘫絮。
如果你喜歡或想要了解更多的PWA相關(guān)知識,歡迎關(guān)注我填硕,關(guān)注《PWA學(xué)習(xí)與實踐》系列文章麦萤。我會總結(jié)整理自己學(xué)習(xí)PWA過程的遇到的疑問與技術(shù)點,并通過實際代碼和大家一起實踐扁眯。
在下一篇文章里壮莹,我們先緩下腳步——工欲善其事,必先利其器姻檀。在繼續(xù)了解更多PWA相關(guān)技術(shù)之前命满,先了解一些chrome上的PWA調(diào)試技巧。之后绣版,我們會再回來繼續(xù)了解另一個經(jīng)常與Push API組合在一起的功能——消息提醒胶台,Notification API。
最后聲明一下杂抽,文中的代碼作為demo诈唬,主要是用于了解與學(xué)習(xí)PWA技術(shù)原理,可能會存在一些不完善的地方缩麸,因此铸磅,不建議直接使用到生產(chǎn)環(huán)境。
《PWA學(xué)習(xí)與實踐》系列
- 第一篇:2018杭朱,開始你的PWA學(xué)習(xí)之旅
- 第二篇:10分鐘學(xué)會使用Manifest阅仔,讓你的WebApp更“Native”
- 第三篇:從今天起,讓你的WebApp離線可用
- 第四篇:TroubleShooting: 解決FireBase login驗證失敗問題
- 第五篇:與你的用戶保持聯(lián)系: Web Push功能(本文)
- 第六篇:How to Debug? 在chrome中調(diào)試你的PWA
- 第七篇:增強(qiáng)交互:使用Notification API來進(jìn)行提醒
- 第八篇:使用Service Worker進(jìn)行后臺數(shù)據(jù)同步
- 第九篇:PWA實踐中的問題與解決方案
- 第十篇:Resource Hint - 提升頁面加載性能與體驗(寫作中…)