本文已授权微信公众号:鸿洋(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是浏览器在后台独立于网页运行的脚本, 也就是说它是运行在单独的线程的, 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一份代码, 然后修改一下配置, 就可以跑到自己的服务器上了. 以下是需要大家自己动手修改的配置.
- 打开/sw.js文件和/script/main.js, 将baseUrl修改成为你的服务器地址.
- 打开/server.cfg文件, 将listen_addr修改成你的地址
- 打开/server.cfg文件, 将cert_file修改成你的证书文件绝对路径
- 打开/server.cfg文件, 将cert_key_file修改成你的证书密钥文件绝对路径
- 打开/server.cfg文件, 将token修改成你的服务器密钥
完成配置后, 可以使用
nohup ./server &
来运行服务器.
最后就可以通过你的地址来访问聊天室了.