前言
PWA (Progressive web Apps),漸進式Web 應用,又稱輕應用,是一種純html5網站卻可實現Native App的屏幕入口、離線緩存、消息推送等功能的W3C標準的技術組合。
PWA的完整教程網上比較少(中文版寫的比較好的:https://lavas.baidu.com/pwa,不過里面實踐比較少,很多坑沒踩出來),故寫下這篇文章幫助需要的人。PWA按照以上三個主要功能,分別用到三種技術:
manifest.json 實現APP入口
Service Worker 離線緩存
Web Push 消息推送
它們都需要在https基礎上才能使用。
PWA并不是新技術,早在2014年即有人提出草案并做出了demo,比微信小程序還早。隨著標準被新版本瀏覽器支持,17年國內也有很多團隊開始實踐,而18年前端Chrome力推的兩大前端技術就是PWA與Flutter。不同的是,PWA是力求不改變原站代碼的基礎上,逐步的實現輕應用的功能;而Flutter是用Dart重寫跨平臺的APP,一套代碼,多端使用。
理想很美好,現實很骨感。PWA在國內實踐并不算多,由兩個重要原因:1. 國內瀏覽器對之支持不太好。2. web push功能在國內遇阻,因為web push由瀏覽器自己的消息推送服務器實現的,比如Chrome的消息推送國內常常block。所以,為了更好的體驗,中國局域網用戶推薦使用Firefox, 其他互聯網用戶推薦使用Chrome(測試后發現,國內局域網也是部分能收到Chrome的推送)。
manifest.json 實現APP入口
manifest.json是一個位于網站對外根目錄的配置文件(一般與index.html在同級目錄),開發者只需按照 W3C定義好的屬性https://www.w3.org/TR/appmanifest/設置即可,本文不做詳述,只列舉幾個常用的屬性:
手機用戶可以用瀏覽器的“添加至主屏幕”,上述配置在此處生效,并且手機默認也會提示用戶去添加。
開發者可以在Chrome devTools 的Application的Manifest中查看當前網站的匹配,它還可以提示配置錯誤。
Service Worker 離線緩存
Service Worker 是運行于瀏覽器后臺的獨立線程,它注冊在指定源的路徑下,不僅不同網站都有獨立的Worker,同一個網站不同的路徑下也可以注冊不同的Worker,一旦注冊則是永久的,除非手動卸載,在Chrome devTools 的Application的Service Worker中可以查看/卸載。
可以發現Service Worker與Web Worker非常類似,都是獨立于主線程之外的獨立線程,都不能使用Window之類的瀏覽器內置對象,都不能操作DOM,都是異步的等。不僅如此,Service Worker還被增強了,它可以攔截/代理瀏覽器的請求,可以使用Cache Storage緩存頁面,可以監聽服務器推送的消息并且向在瀏覽器給用戶推送消息等。
使用Service Worker之前,我們先了解一下它的生命周期:
以上代碼寫在一個名為service_worker.js的腳本里,但它是獨立運行的,我們又需要寫引用/執行這個腳本的腳本 service_worker_before.js。
入口文件service_worker_before.js 注冊Service worker :
注冊代碼很簡單,需注意幾點:
a. scope是Worker的源的范圍,默認值為service_worker.js所在目錄。
b. 這里命名了swVersion 即Service Worker version,用它記錄與升級我們的Worker, 并把這個值傳入Worker中,控制著緩存的版本,我們讓緩存與Worker一起升級。但有一個問題,我們的頁面是會被緩存的,這時無論我們的版本號是多少,都無法讓其升級,所以對于升級代碼文件,我們不應該使用離線緩存,而應該使用瀏覽器默認的緩存,也可以直接設置不緩存。
c. 升級文件指 manifest.json, service_worker.js,service_worker_before.js。比如在Nginx中可以設置不要緩存(未實踐):
外部入口注冊后,我們可以在service_worker.js中寫Worker內部事件了:
Worker 安裝
如果追求快速更新,我們可以跳過等待,直接激活,即我們打開的新頁面都是使用最新的Worker代碼。
Worker 激活
激活之后,我們做了3件事:
a. 更新所有的同源客戶端的service_worker.js,即使它沒有刷新頁面。
b.清除非當前最新版本的cache。
c. 把首頁與離線頁面(根據自己的需要)進入立即緩存,如果不這么做的話,因為激活階段(第1次打開頁面)還沒到達,Worker還沒有開始做cache的工作,頁面已經打開了,這時是沒有離線緩存的,第2次打開頁面時沒有離線cache,但這時頁面會緩存下來,只有第3次才開始能取到離線cache,而上述這么做,第2次進來即可以拿到離線cache的首頁。offline.html則是離線狀態下的提示頁,否則用戶不知道可以離線緩存,就直接不再使用APP了。
Cache Storage 離線緩存
注意點:
a. Cache Storage與我們常說的瀏覽器緩存(Http Cache)有相似之處,即對整個請求/文件緩存。又有不同之處,它可永久保存,可離線使用。在在Chrome devTools -> Application -> Cache -> Cache Storage中可以查看。
b. fech事件可以攔截HTTPS的請求,進行緩存,但下次請求時如果發現已經緩存過,則直接返回緩存中的HTTPS Response,不過上述代碼沒有這么做,因為博客頁面非常小,為了追求頁面最新,只有當離線時才使用緩存,這種做法其實是偏離了離線緩存減小服務器壓力的的初衷。不過離線緩存與時時更新是矛盾的,取決于業務怎么權衡了。
c. 請求都是clone之后才緩存,因為請求的狀態是變化的,如果直接保存,可能不是當時的結果。
d. 只有Get請求才緩存,否則會報錯,畢竟像Post/Put/Delete之類的離線緩存也沒有意義。這里開發者可以自己定義規則。
e. 離線提示頁是在這里攔截而實現的。
f. 為了保證順利升級,我在緩存中設置的升文件“manifest.json”、“service_worker.js”,“service_worker_before.js”是不做離線緩存的。
Web Push 消息推送
Web Push的過程比較復雜,因為它涉及到4個端:
首先先列出簡化的9個步驟:
a. 業務服務端生成公鑰與私鑰,并把公鑰給網頁客戶端
b. 網頁客戶端需要支持PushManager前提下,然后請求用戶授權通知
c. b的基礎上,網頁客戶端把公鑰轉成Uint8Array
d. 網頁客戶端向推送服務端發起訂閱,如果成功,會得到推送服務器返回的訂閱信息
e. 網頁客戶端把訂閱信息發給業務服務端
f. 業務服務端保留該訂閱信息
g. 業務服務端拿著訂閱列表、公鑰私鑰、把想要推送的信息發送給推送服務端
h. 推送服務端拿到推送信息,解析后發送給Service Worker端
i. Service Worker監聽到信息,使用Notification推送給用戶
除了四個端之間有各種交互,還有各種加密比較麻煩外,關于推送服務器文檔少、不便于調試、兼容性不好也是個問題。
關于Web Push的php后端實現
本博客后端使用的PHP,相關教程較少,所幸已經開源的組件可用https://github.com/web-push-libs/web-push-php。
安裝minishlink/web-push
yum install php-gmp composer require minishlink/web-push
可是安裝報錯:
The following exception is caused by a lack of memory or swap, or not having swap configured
Check https:// getcomposer。org/doc/articles/troubleshooting.md#proc-open-fork-failed-errors for details
PHP Warning: proc_open(): fork failed - Cannot allocate memory in phar:// /usr/local/bin/composer/vendor/symfony/console/Application.php on line 952
Warning: proc_open(): fork failed - Cannot allocate memory in phar:// /usr/local/bin/composer/vendor/symfony/console/Application.php on line 952
[ErrorException]
proc_open(): fork failed - Cannot allocate memory
內存問題,修改后OK
/bin/dd if=/dev/zero of=/var/swap.1 bs=1M count=256 /sbin/mkswap /var/swap.1 /sbin/swapon /var/swap.1
a.生成公鑰私鑰
use MinishlinkWebPushVAPID; echo var_dump(VAPID::createVapidKeys());
f. 業務服務端保留該訂閱信息
略
g. 業務服務端拿著訂閱列表、公鑰私鑰、把想要推送的信息發送給推送服務端
public function push_mess(Request $request) { $title = $request->input('title'); $body = $request->input('body'); $href = $request->input('href'); $noticeObj = new stdClass(); $noticeObj->title = $title; $noticeObj->body = $body; $noticeObj->href = $href; $noticeObj->icon = "/static/dist/image/common/favicon.ico"; $noticeObj->badge = "/static/dist/image/common/favicon.ico"; $auth = array( 'VAPID' => array( 'subject' => 'https://www.boatsky.com/', 'publicKey' => 'BGMKbiifiHo5zKaK+gQ=', 'privateKey' => 'FjGJbNeg=', ), ); $webPush = new WebPush($auth); $subList = DB::table(SUBSCRIPTION_TABLE_NAME) ->get(); foreach($subList as $sub){ $subscription = Subscription::create(array( 'endpoint'=> $sub->endpoint, 'publicKey'=> $sub->public_key, 'authToken'=> $sub->auth_token, 'contentEncoding'=> $sub->content_encoding ), true); $res = $webPush->sendNotification( $subscription, json_encode($noticeObj) ); } // handle eventual errors here, and remove the subscription from your server if it is expired $pushResult = ''; foreach ($webPush->flush() as $report) { $endpoint = $report->getRequest()->getUri()->__toString(); if ($report->isSuccess()) { $pushResult = $pushResult . "[successfully] -- {$endpoint}.<br>"; } else { $pushResult = $pushResult . "[failed]- {$endpoint}: {$report->getReason()}<br>"; $deleteFlag = DB::table(SUBSCRIPTION_TABLE_NAME)->where('endpoint', $endpoint)->delete(); echo var_dump($deleteFlag); if ($deleteFlag) { $pushResult = $pushResult . " delete success !<br>"; } } } $resp = array( 'errcode' => 0, 'errmsg' => '', 'data' => $pushResult ); return response()->json($resp); }
提交推送的信息頁面:
<section class="mod-inner"> <form class="bsf-form" id="pushForm"> <h2>推送消息</h2> <div class="bsf-unit"> <label class="bsf-label" for="title">標題:</label> <input type="text" name="title" class="bsf-item" value="輕應用PWA實踐過程"/> </div> <div class="bsf-unit"> <label class="bsf-label" for="body">內容:</label> <input type="text" name="body" class="bsf-item" value="技術·JS"/> </div> <div class="bsf-unit"> <label class="bsf-label" for="href">鏈接:</label> <input type="text" name="href" class="bsf-item" value="https://www.boatsky.com/blog/66.html?cf=push"/> </div> <div class="bsf-unit"> <label class="bsf-label"> </label> <button type="button" class="bsf-btn bsf-btn-primary bsf-btn-md" onclick="pushSubmit()">提交</button> </div> </form> <div id="pushResultMsg"></div> </section> function pushSubmit() { $.ajax({ url : '/admin/push/push_mess', method : 'POST', headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }, data : $('#pushForm').serialize(), dataType : 'JSON', error : function(e){ alert('error'); }, success : function(resp){ if(resp.errcode === 0){ $('#pushResultMsg').html(resp.data); } else { alert(resp.errmsg); } } }); } </script>
只需使用上述HTML,即可以推送相關信息,并且加上其他配置,還可以設置有效時間,推送時間等。
Web Push 授權、發起訂閱、提交訂閱
if ('PushManager' in window) { if (Notification.permission !== 'granted') { // 請求授權 askPermission(); } // 發起訂閱 navigator.serviceWorker.ready.then(function(reg) {subscribe(reg)}); } // 授權消息推送 function askPermission() { return new Promise(function (resolve, reject) { var permissionResult = Notification.requestPermission(function (result) { resolve(result); // 舊版本 }); if (permissionResult) { permissionResult.then(resolve, reject); // 新版本 } }).then(function (permissionResult) { if (permissionResult !== 'granted') { alert('只有允許顯示通知,您才能收到更新提醒,提醒一個月只會出現兩三次,您可以在設置處修改。'); } }).catch(e => console.log(e)); } // 將base64的applicationServerKey轉換成UInt8Array function urlBase64ToUint8Array(base64String) { var padding = '='.repeat((4 - base64String.length % 4) % 4); var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); var rawData = window.atob(base64); var outputArray = new Uint8Array(rawData.length); for (var i = 0, max = rawData.length; i < max; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } function subscribe(serviceWorkerReg) { serviceWorkerReg.pushManager.subscribe({ // 2. 訂閱 userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array('BGMKbiifiMDHo5ZiXxziLuOC7GZaPGdDBfwZp4eYGUxUKvY1VMjNff814+Oi4jAQXnY1LMNgYahiV8gAzKaK+gQ=') }).then(function (subscription) { // 3. 發送推送訂閱對象到服務器,具體實現中發送請求到后端api sendEndpointInSubscription(subscription); console.log('subscribe success'); }).catch(function (e) { console.log(e); // 訂閱請求失敗 if (Notification.permission === 'denied') { } }); } function sendEndpointInSubscription(subscription) { let endpoint = subscription.endpoint; let publicKey = subscription.getKey('p256dh'); publicKey = publicKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(publicKey))) : null; let authToken = subscription.getKey('auth'); authToken = authToken ? btoa(String.fromCharCode.apply(null, new Uint8Array(authToken))) : null; const contentEncoding = (PushManager.supportedContentEncodings || ['aesgcm'])[0]; const reqData = { endpoint, publicKey, authToken, contentEncoding, } console.log(reqData); $.ajax({ url : '/admin/push/save_subscription', method : 'POST', headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }, data : reqData, dataType : 'JSON', error : function(e){ }, success : function(resp){ console.log('send success'); } }); }
endpoint: 為客戶端推薦的地址,推送服務端便是用這個找到客戶端的。
publicKey: 公鑰
authToken: 加密方式,好處是推送服務器也無法解密這個信息
contentEncoding: 編碼方式
Service Worker 監聽push,發出通知
// 監聽server有push的消息,通知用戶 self.addEventListener('push', function (event) { console.log('push', event); if (!(self.Notification && self.Notification.permission === 'granted')) { return; } if (event.data) { var promiseChain = Promise.resolve(event.data.json()).then(data => { console.log(data); // 使用setTimeout之后,可以實現點擊跳轉,否則chrome不行 setTimeout(function(){ self.registration.showNotification(data.title, { body: data.body, icon: data.icon, badge: data.badge, data: { href: data.href, } }); }, 10); }); event.waitUntil(promiseChain); } });
self.registration.showNotification 中data是可以傳額外的參數。
有個細節,官方沒有提到的,需要用setTimeout包著showNotification,Chrome推送出的消息才不會出現鏈接無法點擊的問題。
監聽推送消息的點擊事件
// 推送消息點擊事件 self.addEventListener('notificationclick', event => { console.log('notificationclick'); const clickedNotification = event.notification; const urlToOpen = new URL(clickedNotification.data.href, self.location.origin).href; let promiseChain = clients.matchAll({ type: 'window', includeUncontrolled: true }).then(windowClients => { let matchingClient = null; for (let i = 0, max = windowClients.length; i < max; i++) { let windowClient = windowClients[i]; if (windowClient.url.split('?')[0] === urlToOpen.split('?')[0]) { matchingClient = windowClient; break; } } return matchingClient ? matchingClient.focus() : clients.openWindow(urlToOpen); }); event.waitUntil(promiseChain); clickedNotification.close(); });
監聽 notificationclick 點擊事件,除了需要打開彈窗,還要判斷該彈窗是否曾經打開過,如果是則只需active tab即可。
參考鏈接
https://www.boatsky.com/blog/66