第一个PWA程序-聊天室

来源:互联网 发布:淘宝倒计时什么意思 编辑:程序博客网 时间:2024/05/16 10:24

本文已授权微信公众号:鸿洋(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 &

来运行服务器.

最后就可以通过你的地址来访问聊天室了.

15 0