JavaScript技術棧應用篇之輕應用PWA實踐全過程,快速集成到應用

JavaScript技術棧應用篇之輕應用PWA實踐全過程,快速集成到應用

前言

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/設置即可,本文不做詳述,只列舉幾個常用的屬性:

JavaScript技術棧應用篇之輕應用PWA實踐全過程,快速集成到應用

手機用戶可以用瀏覽器的“添加至主屏幕”,上述配置在此處生效,並且手機默認也會提示用戶去添加。

開發者可以在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之前,我們先了解一下它的生命週期:

JavaScript技術棧應用篇之輕應用PWA實踐全過程,快速集成到應用

JavaScript技術棧應用篇之輕應用PWA實踐全過程,快速集成到應用

以上代碼寫在一個名為service_worker.js的腳本里,但它是獨立運行的,我們又需要寫引用/執行這個腳本的腳本 service_worker_before.js。

入口文件service_worker_before.js 註冊Service worker :

JavaScript技術棧應用篇之輕應用PWA實踐全過程,快速集成到應用

註冊代碼很簡單,需注意幾點:

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中可以設置不要緩存(未實踐):

JavaScript技術棧應用篇之輕應用PWA實踐全過程,快速集成到應用

外部入口註冊後,我們可以在service_worker.js中寫Worker內部事件了:

Worker 安裝

JavaScript技術棧應用篇之輕應用PWA實踐全過程,快速集成到應用

如果追求快速更新,我們可以跳過等待,直接激活,即我們打開的新頁面都是使用最新的Worker代碼。

Worker 激活

JavaScript技術棧應用篇之輕應用PWA實踐全過程,快速集成到應用

激活之後,我們做了3件事:

a. 更新所有的同源客戶端的service_worker.js,即使它沒有刷新頁面。

b.清除非當前最新版本的cache。

c. 把首頁與離線頁面(根據自己的需要)進入立即緩存,如果不這麼做的話,因為激活階段(第1次打開頁面)還沒到達,Worker還沒有開始做cache的工作,頁面已經打開了,這時是沒有離線緩存的,第2次打開頁面時沒有離線cache,但這時頁面會緩存下來,只有第3次才開始能取到離線cache,而上述這麼做,第2次進來即可以拿到離線cache的首頁。offline.html則是離線狀態下的提示頁,否則用戶不知道可以離線緩存,就直接不再使用APP了。

Cache Storage 離線緩存

JavaScript技術棧應用篇之輕應用PWA實踐全過程,快速集成到應用

注意點:

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個端:

JavaScript技術棧應用篇之輕應用PWA實踐全過程,快速集成到應用

首先先列出簡化的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 Minishlink\\WebPush\\VAPID;
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}.
";
} else {
$pushResult = $pushResult . "[failed]- {$endpoint}: {$report->getReason()}
";
$deleteFlag = DB::table(SUBSCRIPTION_TABLE_NAME)->where('endpoint', $endpoint)->delete();

echo var_dump($deleteFlag);
if ($deleteFlag) {
$pushResult = $pushResult . " delete success !
";
}
}
}
$resp = array(
'errcode' => 0,
'errmsg' => '',
'data' => $pushResult
);
return response()->json($resp);
}

提交推送的信息頁面:

<section>


/<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);
}
}
});
}

只需使用上述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


分享到:


相關文章: