第一個PWA程式-聊天室
本文已授權微信公眾號:鴻洋(hongyangAndroid)在微信公眾號平臺原創首發。
好久沒寫部落格了, 為了治療懶癌, 今天我們來學習一下Google的Progressive Web App, 什麼是Progressive Web App(簡稱PWA)? 文件上有這麼一句話:
Progressive Web Apps 是結合了 web 和 原生應用中最好功能的一種體驗
一個網頁能做到媲美原生APP, 需要具備一下幾個條件:
- 網頁框架的快取
- 資料的快取
- 桌面啟動
- 可能還需要推送通知的功能
當然, 以上4個條件還需要有一個大環境, 那就是瀏覽器支援, 當然我們大多數人使用的Chrome已經具備了這個大環境~~
演示
為了覆蓋以上4個條件, 今天我們就用一個簡單的聊天室程式來做一下演示, 大家可以先到https://codercard.net:8890來體驗一下, 這裡的聊天室功能我們主要使用了Google Firebase的推送功能, 所以在使用的過程中還需要你全程準備梯子~~ 對於暫時還沒有梯子的朋友, 一下準備了兩張截圖, 先來大致瞭解一下.
在電腦上的執行效果:
在手機上的執行效果:
專案結構解析
接下來, 我們就來看看這個小專案的專案結構.
專案結構也不復雜, 我們一點點的說一下, 首先一個css目錄, 當然是存放我們專案中的樣式檔案的, 這裡我們僅有一個main.css檔案; images目錄存放了聊天室的icon; mdl存放的是Google的Material Design Lite 開發包; script目錄存放的是我們專案中使用的js檔案, 這裡我們僅有一個main.js檔案; index.html是我們聊天室的主頁; 三個server相關的檔案, 這裡先不用瞭解; 最後一個sw.js檔案, 這個是我們實現PWA的關鍵-serviceWorker, 什麼離線快取, 推送通知全靠它了.
快取網頁框架
好了, 下面我們就開始進入開發階段了, 首先我們要做的就是有一個介面, 然後還能讓它有離線快取的功力~ 說到這裡就不得不提我們今天的主角serviceWorker
首先是註冊serviceWorker, 開啟我們的main.js檔案, 加入一下程式碼:
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js")
.then(function() {
console.log("serviceWorker register success");
}).catch(function(err) {
console.log(err.message);
});
}
如果瀏覽器支援serviceWorker, 那麼我們就把sw.js檔案註冊進去, 這裡必須注意一下的是sw.js檔案必須存在於專案的根目錄下.
註冊完畢後, 我們就需要開啟sw.js檔案, 來監聽它的生命週期了, 首先是install的監聽, 在install過程中, 我們就來快取應用的框架.
var cacheName = "chat-cache-name";
var cacheFiles = [
"/", "/index.html", "/css/main.css",
"/mdl/bower.json","/mdl/bower.json",
"/mdl/material.min.css", "/mdl/material.min.js",
"/script/main.js", "/images/icon.png"
];
self.addEventListener("install", function(e) {
e.waitUntil(caches.open(cacheName).then(function(cache) {
return cache.addAll(cacheFiles);
}));
});
cacheName是我們應用框架的快取名稱, cacheFiles是我們需要快取哪些檔案. 然後我們監聽install事件, 並且開啟快取, 將cacheFile新增到快取中.
e.waitUntil()是等待一個Promise物件執行完畢.
接下來我們來看下一個生命週期, activate, 在activate階段我們同樣要做的就是清理過期的快取檔案.
self.addEventListener("activate", function(e) {
e.waitUntil(caches.keys().then(function(keyList) {
return Promise.all(keyList.map(function(key) {
if (key !== cacheName) {
return caches.delete(key);
}
}));
}));
});
這裡我們遍歷快取的鍵, 然後將不是cacheName的快取刪除掉~~
當我們發起一個請求的時候, 還需要監聽一個fetch事件來做一些工作.
self.addEventListener("fetch", function(e) {
e.respondWith(caches.match(e.request).then(function(response) {
return response || fetch(e.request);
}));
});
這裡的作用是如果快取中能匹配到我們的請求, 那麼就返回快取中的response, 否則使用fetch()函式發起一個請求.
好了, 到現在為止, 我們的應用框架就可以快取到本地了, 用瀏覽器開啟應用, 然後按F12鍵, 選擇Application標籤, 下面選擇Service Workers選項, 將offline選中來模擬一下無網環境, 然後重新整理介面, 你會發現網頁依然可以正常顯示.
資料快取
上面我們將應用的框架給快取下來了, 不過有些時候我們還需要快取一些資料, 必須一個新聞列表, 在使用者無網的環境下, 我們不希望使用者看到的是一個大白介面, 而是上次瀏覽的新聞列表. 在咱們這個聊天室應用裡, 我們的資料快取只快取了使用者資訊. 下面我們就來完成這項工作.
首先我們需要再定義一個cacheName來區分應用框架的快取名稱.
var dataCacheName = "chat-data-cache-name";
還需要定義一個我們需要快取的資料介面地址.
var baseUrl = "https://codercard.net:8890/";
var dataUrl = baseUrl + "user";
在activate事件的監聽裡我們需要將資料快取的條件判斷加上.
self.addEventListener("activate", function(e) {
e.waitUntil(caches.keys().then(function(keyList) {
return Promise.all(keyList.map(function(key) {
if (key !== cacheName && key !== dataCacheName) {
return caches.delete(key);
}
}));
}));
});
在fetch事件裡, 我們還得判斷該請求是不是我們關心的資料請求, 如果是, 則將請求結果快取起來.
self.addEventListener("fetch", function(e) {
if (e.request.url.indexOf(dataUrl) === 0) {
return e.respondWith(caches.open(dataCacheName).then(function(cache) {
return fetch(e.request).then(function(response) {
cache.put(e.request.url, response.clone());
return response;
});
}));
} else {
e.respondWith(caches.match(e.request).then(function(response) {
return response || fetch(e.request);
}));
}
});
現在資料請求快取的準備工作就完成了, 下面我們就來發起一個使用者資訊獲取的函式, 在這個函式裡我們需要先判斷快取中是否有, 如果有則從快取返回, 最後再發起真正的網路請求. 開啟上面的main.js檔案.
function userInfo(subscription, f) {
if ("caches" in window) {
caches.match(dataUrl).then(function(response) {
if (response) {
response.json().then(function(json) {
f(json);
}).catch(function(err) {
console.log(err.message);
});
}
});
}
var request = new XMLHttpRequest();
request.onreadystatechange = function() {
if (request.readyState == XMLHttpRequest.DONE) {
if (request.status == 200) {
var resp = request.response;
if (resp) {
f(JSON.parse(request.response));
return;
}
f(null);
}
}
};
request.open("POST", "/user", true);
request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
request.send("sub=" + JSON.stringify(subscription));
}
這個函式的引數先不用理會, 我們首先判斷是否支援caches, 如果支援, 則從caches裡匹配我們的連結, 資料存在, 則返回資料. 接下來我們利用XMLHttpRequest發起了一次請求.
到現在為止, 我們的專案就有了資料快取的能力.
支援桌面launcher
這一部分相對比較簡單, 要想讓我們的應用和原生應用一樣在桌面可以有一個應用圖示, 我們需要配置一個manifest.json檔案, 來看看咱們聊天室的manifest.json檔案.
{
"name": "ChatRoom",
"short_name": "ChatRoom",
"icons": [{
"src": "/images/icon.png",
"sizes": "128x128",
"type": "image/png"
}, {
"src": "/images/icon.png",
"sizes": "144x144",
"type": "image/png"
}, {
"src": "/images/icon.png",
"sizes": "152x152",
"type": "image/png"
}, {
"src": "/images/icon.png",
"sizes": "192x192",
"type": "image/png"
}, {
"src": "/images/icon.png",
"sizes": "256x256",
"type": "image/png"
}],
"start_url": "/",
"display": "standalone",
"background_color": "#3E4EB8",
"theme_color": "#2F3BA2"
}
然後我們需要在網頁中引用這個manifest檔案.
<link rel="manifest" href="/manifest.json">
這樣, 我們就可以把網頁放置到桌面上了, 在Android手機上, 首次進入, Chrome會提醒傳送到桌面, 然後你從桌面啟動的時候就看不到Chrome的影子了, 更像是一個原生應用.
實現聊天功能
在咱們這個應用裡, 聊天功能是最核心的功能, 這裡我們利用Google Firebase的訊息推送來實現聊天功能, 這裡不得不讚一下Firebase, 訊息推送的實時性不是國內推送平臺能比的.
再開始之前, 我們需要去firebase上開通一個專案, 然後在console裡點選的你專案, 然後在左上角點選專案設定, 接著選擇雲訊息傳遞, 將你的伺服器金鑰和**傳送者 ID**copy下來, 下面我們會用到這兩個. 如果console面板你打不開, 可以將一下內容新增到你的hosts檔案中.
- 61.91.161.217 firebase.google.com
- 61.91.161.217 console.firebase.google.com
- 61.91.161.217 mobilesdk-pa.clients6.google.com
- 61.91.161.217 cloudusersettings-pa.clients6.google.com
- 61.91.161.217 firebasestorage.clients6.google.com
- 61.91.161.217 firebaserules.clients6.google.com
- 61.91.161.217 firebasedurablelinks-pa.clients6.google.com
- 61.91.161.217 cloudconfig.clients6.google.com
- 61.91.161.217 gcmcontextualcampaign-pa.clients6.google.com
- 61.91.161.217 mobilecrashreporting.clients6.google.com
好, 萬事俱備, 我們就來完善聊天室專案. 首先開啟main.js檔案. 在register sw.js的程式碼後面加入以下程式碼.
if ("PushManager" in window) {
navigator.serviceWorker.ready.then(function(swReg) {
console.log("PushManager registration success");
swRegistration = swReg;
initPush();
}).catch(function(err) {
console.log(err.message);
});
}
如果瀏覽器支援推送功能, 我們就在serviceWorker的狀態變為ready的時候拿到registration然後去初始化推送功能. 這個程式碼我們放在initPush函式中完成.
function initPush() {
swRegistration.pushManager.getSubscription()
.then(function(subscription) {
if (subscription) {
isSubscibed = true;
updateSubscriptionOnServer(subscription);
} else {
subscribe();
}
}).catch(function(err) {
console.log(err.message);
});
}
這裡我們先來拿一個subscription, 如果能拿到, 那說明之前我們一應訂閱過了, 接下來我們只需要將這個subscription告訴伺服器即可, 如果沒拿到, 我們就呼叫subscribe函式來訂閱.
function subscribe() {
swRegistration.pushManager.subscribe({
userVisibleOnly: true
}).then(function(subscription) {
isSubscibed = true;
updateSubscriptionOnServer(subscription);
}).catch(function(err) {
console.log(err.message);
});
}
我們可以呼叫pushManager.subscribe()函式來註冊訂閱, 這裡面的userVisibleOnly必須是true, 當然這個引數也是可以在manifest.json中配置的, 這個引數選項裡還有一個applicationServerKey的引數代表我們客戶端的唯一表示, 因為這裡我們使用的Google的服務, 所以沒有在程式碼裡顯式宣告, 而是在manifest.json中配置了一個gcm_sender_id欄位, 瀏覽器就拿這個欄位去google伺服器換一個唯一標識, 這個gcm_sender_id就是上面從firebase中儲存下來的傳送者 ID. 接下來, 在拿到subscription後, 我們將這個subscription告訴伺服器.
function updateSubscriptionOnServer(subscription) {
if (subscription) {
getUserInfo(subscription);
}
}
這我們直接呼叫了getUserInfo函式, 思路是在拿到subscription後, 我們用來和伺服器來換取一個使用者資訊. 來看看getUserInfo函式.
function getUserInfo(subscription) {
userInfo(subscription, function(resp) {
if (resp == null) {
showRegister(subscription);
return;
}
document.getElementById('pop').style.display = "none";
userName = resp.name;
startPing(subscription);
});
}
這裡面直接呼叫了上面我們提到的userInfo函式, 當伺服器沒有給我們返回任何使用者資訊的時候, 我們就認為這是一個新使用者, 這時候就顯示一個註冊對話方塊提醒使用者註冊. 否則就將使用者名稱儲存起來. 最後一個startPing函式是一個簡單的心跳檢測, 每隔5分鐘向伺服器傳送一次請求表明自己還活著~~
跟著流程中, 我們繼續看showRegister方法裡.
function showRegister(subscription) {
var pop = document.getElementById('pop');
var confirm = document.getElementById("login-confirm");
var loading = document.getElementById("login-loading");
pop.style.display='block';
confirm.addEventListener("click", function(e) {
var name = document.getElementById("user-name").value;
if (name == null || name == "") { return}
confirm.style.display = "none";
loading.style.display = "block";
register(subscription, name, function(resp) {
if (resp == null) {
confirm.style.display = "block";
loading.style.display = "none";
alert("註冊失敗,請重試");
return;
}
pop.style.display='none';
userName = resp.name;
startPing(subscription);
});
});
}
這裡面的邏輯很簡單, 就是顯示一個對話方塊讓使用者去輸入暱稱, 然後註冊, 真正的註冊邏輯是在register函式中完成的.
function register(subscription, name, f) {
var request = new XMLHttpRequest();
request.onreadystatechange = function() {
if (request.readyState == XMLHttpRequest.DONE) {
if (request.status == 200) {
var resp = request.response;
if (resp) {
f(JSON.parse(request.response));
return;
}
f(null);
}
}
};
request.open("POST", "/reg", true);
request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
request.send("sub=" + JSON.stringify(subscription) + "&name=" + name);
}
這裡向伺服器傳送了一個請求, 將使用者的subscription和使用者輸入的暱稱傳送給伺服器, 如果註冊成功, 伺服器將會返回該使用者資訊, 之後的邏輯和獲取使用者資訊的邏輯一致了.
走到這裡, 我們的使用者資訊邏輯才剛完成, 下面我們就來處理髮送和接收資訊的功能. 首先是傳送資訊功能, 傳送資訊是在一個sendMessage裡.
function sendMessage(message) {
if (userName == null) { return;}
var request = new XMLHttpRequest();
request.onreadystatechange = function() {
if (request.readyState == XMLHttpRequest.DONE) {
if (request.status == 200) {
document.getElementById("chat-message-input").value = "";
}
}
};
request.open("POST", "/send", true);
request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
request.send("name=" + userName + "&msg=" + message);
}
其實所謂的傳送訊息就是向伺服器傳送一個請求, 然後將使用者名稱和訊息的內容告訴伺服器. 這裡再說一點我們看不到的邏輯, 伺服器是怎麼處理的? 伺服器接受到訊息請求後, 會遍歷使用者列表, 將該訊息推送出去. 那我們客戶端怎麼接收推送訊息呢? 開啟sw.js檔案, 註冊一個push事件的監聽.
self.addEventListener("push", function(e) {
var message = JSON.parse(e.data.text());
self.clients.matchAll().then(function(clientList) {
clientList.forEach(function(client) {
client.postMessage(message);
});
});
const title = message.name;
const options = {
body: message.body,
icon: "/images/icon.png",
badge: "/images/icon.png"
};
if (!isCurrentWindowFocus) {
e.waitUntil(self.registration.showNotification(title, options));
}
});
當伺服器push一段訊息的時候, push事件就會觸發, 在這裡, 我們遍歷所有註冊的clients(其實通常情況下只有一個client), 然後呼叫client.postMessage來將訊息傳送給客戶端. 為什麼不直接給而是要通過client**post出去呢? 別忘了, 咱們的**serviceWorker是執行在獨立的執行緒中, client要和serviceWorker通訊就必須要通過postMessage的方式. 最後我們還通過self.registration.showNotification來顯示一個通知, 但這個通知顯示是有一個前提, 那就是聊天視窗沒有在聚焦的狀態.
當我們點選一個通知的時候, 我們希望開啟一個聊天對話.
self.addEventListener("notificationclick", function(e) {
e.notification.close();
e.waitUntil(clients.openWindow(baseUrl));
});
這裡點選通知的點選事件, 然後開啟一個視窗. 其實這塊在咱們的聊天室專案裡是有問題的, 因為, 假如聊天室視窗在另外一個標籤的話, 這裡會開啟一個新的標籤, 但是serviceWorker不會重新執行在新的對話中.
serviceWorker通知客戶端後, 客戶端如何接受訊息呢? 我們需要在客戶端監聽一個message事件.
if ("PushManager" in window) {
...
navigator.serviceWorker.addEventListener("message", function(e) {
showMessage(e.data);
});
}
然後呼叫showMessage函式來顯示到介面上,
function showMessage(message) {
var messageContainer = document.getElementById("message-list-container");
var messageList = document.getElementById("message-list");
messageList.innerHTML += "<li class=\"mdl-list__item mdl-list__item--three-line\"><span class=\"mdl-list__item-primary-content\"><i class=\"material-icons mdl-list__item-avatar\">person</i><span>"+message.name+"</span><span class=\"mdl-list__item-text-body\">"+message.body+"</span></span></li>";
messageContainer.scrollTop = messageContainer.scrollHeight;
}
最後再來看一個問題, 那就是isCurrentWindowFocus這個狀態如何從client傳遞給serviceWorker, 其實上面已經提到過了, client和serviceWorker之前通訊只有postMessage一種方式. 所以當我們客戶端監聽到視窗狀態變化時需要通過postMessage通知到serviceWorker.
document.addEventListener(visibilityChange, function() {
navigator.serviceWorker.controller.postMessage(document[state]);
}, false);
客戶端將當前狀態傳遞給serviceWorker後, serviceWorker也需要監聽一個message事件來處理響應.
self.addEventListener("message", function(e) {
isCurrentWindowFocus = e.data == "visible";
});
到現在為止, 我們的聊天室專案就算完成了, 如果你想要將它放置到伺服器上, 還需要一個https伺服器, 有很多免費證書申請的地方, 大家可以google一下, 這裡我選擇的是騰訊雲的1年免費證書.
自己搭建聊天室
大家在看完之後, 肯定很想自己動手搭建一個聊天室玩玩. 最簡單的方式就是去我的github: https://github.com/qibin0506/ChatRoom-PWA, 上clone一份程式碼, 然後修改一下配置, 就可以跑到自己的伺服器上了. 以下是需要大家自己動手修改的配置.
- 開啟/sw.js檔案和/script/main.js, 將baseUrl修改成為你的伺服器地址.
- 開啟/server.cfg檔案, 將listen_addr修改成你的地址
- 開啟/server.cfg檔案, 將cert_file修改成你的證書檔案絕對路徑
- 開啟/server.cfg檔案, 將cert_key_file修改成你的證書金鑰檔案絕對路徑
- 開啟/server.cfg檔案, 將token修改成你的伺服器金鑰
完成配置後, 可以使用
nohup ./server &
來執行伺服器.
最後就可以通過你的地址來訪問聊天室了.