第一个PWA程序-聊天室

Posted 亓斌

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了第一个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 &

来运行服务器.

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

以上是关于第一个PWA程序-聊天室的主要内容,如果未能解决你的问题,请参考以下文章

第一个PWA程序-聊天室

打开 PWA 时收到推送事件时不显示通知

C++程序设计原理与实践第2版基础篇.pdf高清完整资源

说说 PWA 和微信小程序--Progressive Web App

第一个progressive web application,发车!

2020年第2周,17.5h,实验室小项目的第二部分