1. 程式人生 > >第一個PWA程式-聊天室

第一個PWA程式-聊天室

本文已授權微信公眾號:鴻洋(hongyangAndroid)在微信公眾號平臺原創首發。

好久沒寫部落格了, 為了治療懶癌, 今天我們來學習一下Google的Progressive Web App, 什麼是Progressive Web App(簡稱PWA)? 文件上有這麼一句話:

Progressive Web Apps 是結合了 web 和 原生應用中最好功能的一種體驗

一個網頁能做到媲美原生APP, 需要具備一下幾個條件:

  1. 網頁框架的快取
  2. 資料的快取
  3. 桌面啟動
  4. 可能還需要推送通知的功能

當然, 以上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是瀏覽器在後臺獨立於網頁執行的指令碼, 也就是說它是執行在單獨的執行緒的, serviceWorker支援離線快取和推送通知功能, 關於serviceWorker的詳細介紹, 大家可以Google上了解一下, 這裡我們僅僅做一個簡單的解釋, 首先serviceWorker需要我們手動註冊, 然後我們需要監聽它的各種生命週期, 在不同的生命週期裡做不同的工作(聽起來是不是有點像Android的Activity開發?). 下面我們一步步的來實現一下.

首先是註冊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一份程式碼, 然後修改一下配置, 就可以跑到自己的伺服器上了. 以下是需要大家自己動手修改的配置.

  1. 開啟/sw.js檔案和/script/main.js, 將baseUrl修改成為你的伺服器地址.
  2. 開啟/server.cfg檔案, 將listen_addr修改成你的地址
  3. 開啟/server.cfg檔案, 將cert_file修改成你的證書檔案絕對路徑
  4. 開啟/server.cfg檔案, 將cert_key_file修改成你的證書金鑰檔案絕對路徑
  5. 開啟/server.cfg檔案, 將token修改成你的伺服器金鑰

完成配置後, 可以使用

nohup ./server &

來執行伺服器.

最後就可以通過你的地址來訪問聊天室了.