前言

PWA (Progressive web s),渐进式Web 应用,又称轻应用,是一种纯5却可实现Native App的屏幕入口、离线缓存、消息推送等功能的W3C标准的技术组合。
PWA的完整教程网上比较少(中文版写的比较好的:https://lavas.baidu.com/pwa,不过里面实践比较少,很多坑没踩出来),故写下这篇文章帮助需要的人。PWA按照以上三个主要功能,分别用到三种技术:
manifest.json 实现APP入口
Service Worker 离线缓存
Web Push 消息推送
它们都需要在https基础上才能使用。
PWA并不是新技术,早在2014年即有人提出草案并做出了demo,比还早。随着标准被新版本浏览器支持,17年国内也有很多团队开始实践,而18年前端Chrome力推的两大前端技术就是PWA与Flutter。不同的是,PWA是力求不改变原站代码的基础上,逐步的实现轻应用的功能;而Flutter是用Dart重写跨平台的APP,一套代码,多端使用。
理想很美好,现实很骨感。PWA在国内实践并不算多,由两个重要原因:1. 国内浏览器对之支持不太好。2. web push功能在国内遇阻,因为web push由浏览器自己的消息推送服务器实现的,比如Chrome的消息推送国内常常block。所以,为了更好的体验,中国局域网用户推荐使用Firefox, 其他互联网用户推荐使用Chrome(测试后发现,国内局域网也是部分能收到Chrome的推送)。
manifest.json 实现APP入口
manifest.json是一个位于网站对外根目录的配置文件(一般与index.html在同级目录),开发者只需按照 W3C定义好的属性https://www.w3.org/TR/appmanifest/设置即可,本文不做详述,只列举几个常用的属性:
用户可以用浏览器的“添加至主屏幕”,上述配置在此处生效,并且手机默认也会提示用户去添加。
开发者可以在Chrome devTools 的Application的Manifest中查看当前网站的匹配,它还可以提示配置错误。
Service Worker 离线缓存
Service Worker 是运行于浏览器后台的独立线程,它注册在指定源的路径下,不仅不同网站都有独立的Worker,同一个网站不同的路径下也可以注册不同的Worker,一旦注册则是永久的,除非手动卸载,在Chrome devTools 的Application的Service Worker中可以查看/卸载。
可以发现Service Worker与Web Worker非常类似,都是独立于主线程之外的独立线程,都不能使用Window之类的浏览器内置对象,都不能操作DOM,都是异步的等。不仅如此,Service Worker还被增强了,它可以拦截/代理浏览器的请求,可以使用Cache Storage缓存页面,可以监听服务器推送的消息并且向在浏览器给用户推送消息等。
使用Service Worker之前,我们先了解一下它的生命周期:
以上代码写在一个名为service_worker.js的脚本里,但它是独立运行的,我们又需要写引用/执行这个脚本的脚本 service_worker_before.js。
入口文件service_worker_before.js 注册Service worker :
注册代码很简单,需注意几点:
a. scope是Worker的源的范围,默认值为service_worker.js所在目录。
b. 这里命名了swVersion 即Service Worker version,用它记录与升级我们的Worker, 并把这个值传入Worker中,控制着缓存的版本,我们让缓存与Worker一起升级。但有一个问题,我们的页面是会被缓存的,这时无论我们的版本号是多少,都无法让其升级,所以对于升级代码文件,我们不应该使用离线缓存,而应该使用浏览器默认的缓存,也可以直接设置不缓存。
c. 升级文件指 manifest.json, service_worker.js,service_worker_before.js。比如在中可以设置不要缓存(未实践):
外部入口注册后,我们可以在service_worker.js中写Worker内部事件了:
Worker 安装
如果追求快速更新,我们可以跳过等待,直接激活,即我们打开的新页面都是使用最新的Worker代码。
Worker 激活
激活之后,我们做了3件事:
a. 更新所有的同源客户端的service_worker.js,即使它没有刷新页面。
b.清除非当前最新版本的cache。
c. 把首页与离线页面(根据自己的需要)进入立即缓存,如果不这么做的话,因为激活阶段(第1次打开页面)还没到达,Worker还没有开始做cache的工作,页面已经打开了,这时是没有离线缓存的,第2次打开页面时没有离线cache,但这时页面会缓存下来,只有第3次才开始能取到离线cache,而上述这么做,第2次进来即可以拿到离线cache的首页。offline.html则是离线状态下的提示页,否则用户不知道可以离线缓存,就直接不再使用APP了。
Cache Storage 离线缓存
注意点:
a. Cache Storage与我们常说的浏览器缓存(Http Cache)有相似之处,即对整个请求/文件缓存。又有不同之处,它可永久保存,可离线使用。在在Chrome devTools -> Application -> Cache -> Cache Storage中可以查看。
b. fech事件可以拦截HTTPS的请求,进行缓存,但下次请求时如果发现已经缓存过,则直接返回缓存中的HTTPS Response,不过上述代码没有这么做,因为博客页面非常小,为了追求页面最新,只有当离线时才使用缓存,这种做法其实是偏离了离线缓存减小服务器压力的的初衷。不过离线缓存与时时更新是矛盾的,取决于业务怎么权衡了。
c. 请求都是clone之后才缓存,因为请求的状态是变化的,如果直接保存,可能不是当时的结果。
d. 只有Get请求才缓存,否则会报错,毕竟像Post/Put/Delete之类的离线缓存也没有意义。这里开发者可以自己定义规则。
e. 离线提示页是在这里拦截而实现的。
f. 为了保证顺利升级,我在缓存中设置的升文件“manifest.json”、“service_worker.js”,“service_worker_before.js”是不做离线缓存的。
Web Push 消息推送
Web Push的过程比较复杂,因为它涉及到4个端:
首先先列出简化的9个步骤:
a. 业务服务端生成公钥与私钥,并把公钥给网页客户端
b. 网页客户端需要支持PushManager前提下,然后请求用户授权通知
c. b的基础上,网页客户端把公钥转成Uint8Array
d. 网页客户端向推送服务端发起订阅,如果成功,会得到推送服务器返回的订阅信息
e. 网页客户端把订阅信息发给业务服务端
f. 业务服务端保留该订阅信息
g. 业务服务端拿着订阅列表、公钥私钥、把想要推送的信息发送给推送服务端
h. 推送服务端拿到推送信息,解析后发送给Service Worker端
i. Service Worker监听到信息,使用Notification推送给用户
除了四个端之间有各种交互,还有各种加密比较麻烦外,关于推送服务器文档少、不便于调试、兼容性不好也是个问题。
关于Web Push的后端实现
本博客后端使用的PHP,相关教程较少,所幸已经开源的组件可用https://github.com/web-push-libs/web-push-php。
安装minishlink/web-push
yum install php-gmp composer require minishlink/web-push
可是安装报错:
The following exception is caused by a lack of memory or swap, or not having swap configured
Check https:// getcomposer。org/doc/articles/troubleshooting.md#proc-open-fork-failed-errors for details
PHP Warning: proc_open(): fork failed - Cannot allocate memory in phar:// /usr/local/bin/composer/vendor/symfony/console/Application.php on line 952
Warning: proc_open(): fork failed - Cannot allocate memory in phar:// /usr/local/bin/composer/vendor/symfony/console/Application.php on line 952
[ErrorException]
proc_open(): fork failed - Cannot allocate memory
问题,修改后OK
/bin/dd if=/dev/zero of=/var/swap.1 bs=1M count=256 /sbin/mkswap /var/swap.1 /sbin/swapon /var/swap.1
a.生成公钥私钥
use MinishlinkWebPushVAPID; echo var_dump(VAPID::createVapidKeys());
f. 业务服务端保留该订阅信息
略
g. 业务服务端拿着订阅列表、公钥私钥、把想要推送的信息发送给推送服务端
public function push_mess(Request $request)
{
$title = $request->input('title');
$body = $request->input('body');
$href = $request->input('href');
$noticeObj = new stdClass();
$noticeObj->title = $title;
$noticeObj->body = $body;
$noticeObj->href = $href;
$noticeObj->icon = "/static/dist/image/common/favicon.ico";
$noticeObj->badge = "/static/dist/image/common/favicon.ico";
$auth = array(
'VAPID' => array(
'subject' => 'https://www.boatsky.com/',
'publicKey' => 'BGMKbiifiHo5zKaK+gQ=',
'privateKey' => 'FjGJbNeg=',
),
);
$webPush = new WebPush($auth);
$subList = DB::table(SUBSCRIPTION_TABLE_NAME)
->get();
foreach($subList as $sub){
$subscription = Subscription::create(array(
'endpoint'=> $sub->endpoint,
'publicKey'=> $sub->public_key,
'authToken'=> $sub->auth_token,
'contentEncoding'=> $sub->content_encoding
), true);
$res = $webPush->sendNotification(
$subscription,
json_encode($noticeObj)
);
}
// handle eventual errors here, and remove the subscription from your server if it is expired
$pushResult = '';
foreach ($webPush->flush() as $report) {
$endpoint = $report->getRequest()->getUri()->__toString();
if ($report->isSuccess()) {
$pushResult = $pushResult . "[successfully] -- {$endpoint}.<br>";
} else {
$pushResult = $pushResult . "[failed]- {$endpoint}: {$report->getReason()}<br>";
$deleteFlag = DB::table(SUBSCRIPTION_TABLE_NAME)->where('endpoint', $endpoint)->delete();
echo var_dump($deleteFlag);
if ($deleteFlag) {
$pushResult = $pushResult . " delete success !<br>";
}
}
}
$resp = array(
'errcode' => 0,
'errmsg' => '',
'data' => $pushResult
);
return response()->json($resp);
}
提交推送的信息页面:
<section class="mod-inner">
<form class="bsf-form" id="pushForm">
<h2>推送消息</h2>
<div class="bsf-unit">
<label class="bsf-label" for="title">标题:</label>
<input type="text" name="title" class="bsf-item" value="轻应用PWA实践过程"/>
</div>
<div class="bsf-unit">
<label class="bsf-label" for="body">内容:</label>
<input type="text" name="body" class="bsf-item" value="技术·JS"/>
</div>
<div class="bsf-unit">
<label class="bsf-label" for="href">链接:</label>
<input type="text" name="href" class="bsf-item" value="https://www.boatsky.com/blog/66.html?cf=push"/>
</div>
<div class="bsf-unit">
<label class="bsf-label"> </label>
<button type="button" class="bsf-btn bsf-btn-primary bsf-btn-md" onclick="pushSubmit()">提交</button>
</div>
</form>
<div id="pushResultMsg"></div>
</section>
function pushSubmit() {
$.ajax({
url : '/admin/push/push_mess',
method : 'POST',
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
},
data : $('#pushForm').serialize(),
dataType : 'JSON',
error : function(e){
alert('error');
},
success : function(resp){
if(resp.errcode === 0){
$('#pushResultMsg').html(resp.data);
}
else {
alert(resp.errmsg);
}
}
});
}
</script>
只需使用上述HTML,即可以推送相关信息,并且加上其他配置,还可以设置有效时间,推送时间等。
Web Push 授权、发起订阅、提交订阅
if ('PushManager' in window) {
if (Notification.permission !== 'granted') {
// 请求授权
askPermission();
}
// 发起订阅
navigator.serviceWorker.ready.then(function(reg) {subscribe(reg)});
}
// 授权消息推送
function askPermission() {
return new Promise(function (resolve, reject) {
var permissionResult = Notification.requestPermission(function (result) {
resolve(result); // 旧版本
});
if (permissionResult) {
permissionResult.then(resolve, reject); // 新版本
}
}).then(function (permissionResult) {
if (permissionResult !== 'granted') {
alert('只有允许显示通知,您才能收到更新提醒,提醒一个月只会出现两三次,您可以在设置处修改。');
}
}).catch(e => console.log(e));
}
// 将base64的applicationServerKey转换成UInt8Array
function urlBase64ToUint8Array(base64String) {
var padding = '='.repeat((4 - base64String.length % 4) % 4);
var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
var rawData = window.atob(base64);
var outputArray = new Uint8Array(rawData.length);
for (var i = 0, max = rawData.length; i < max; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function subscribe(serviceWorkerReg) {
serviceWorkerReg.pushManager.subscribe({ // 2. 订阅
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array('BGMKbiifiMDHo5ZiXxziLuOC7GZaPGdDBfwZp4eYGUxUKvY1VMjNff814+Oi4jAQXnY1LMNgYahiV8gAzKaK+gQ=')
}).then(function (subscription) {
// 3. 发送推送订阅对象到服务器,具体实现中发送请求到后端api
sendEndpointInSubscription(subscription);
console.log('subscribe success');
}).catch(function (e) {
console.log(e);
// 订阅请求失败
if (Notification.permission === 'denied') {
}
});
}
function sendEndpointInSubscription(subscription) {
let endpoint = subscription.endpoint;
let publicKey = subscription.getKey('p256dh');
publicKey = publicKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(publicKey))) : null;
let authToken = subscription.getKey('auth');
authToken = authToken ? btoa(String.fromCharCode.apply(null, new Uint8Array(authToken))) : null;
const contentEncoding = (PushManager.supportedContentEncodings || ['aesgcm'])[0];
const reqData = {
endpoint,
publicKey,
authToken,
contentEncoding,
}
console.log(reqData);
$.ajax({
url : '/admin/push/save_subscription',
method : 'POST',
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
},
data : reqData,
dataType : 'JSON',
error : function(e){
},
success : function(resp){
console.log('send success');
}
});
}
endpoint: 为客户端推荐的地址,推送服务端便是用这个找到客户端的。
publicKey: 公钥
authToken: 加密方式,好处是推送服务器也无法解密这个信息
contentEncoding: 编码方式
Service Worker 监听push,发出通知
// 监听server有push的消息,通知用户
self.addEventListener('push', function (event) {
console.log('push', event);
if (!(self.Notification && self.Notification.permission === 'granted')) {
return;
}
if (event.data) {
var promiseChain = Promise.resolve(event.data.json()).then(data => {
console.log(data);
// 使用setTimeout之后,可以实现点击跳转,否则chrome不行
setTimeout(function(){
self.registration.showNotification(data.title, {
body: data.body,
icon: data.icon,
badge: data.badge,
data: {
href: data.href,
}
});
}, 10);
});
event.waitUntil(promiseChain);
}
});
self.registration.showNotification 中data是可以传额外的参数。
有个细节,官方没有提到的,需要用setTimeout包着showNotification,Chrome推送出的消息才不会出现链接无法点击的问题。
监听推送消息的点击事件
// 推送消息点击事件
self.addEventListener('notificationclick', event => {
console.log('notificationclick');
const clickedNotification = event.notification;
const urlToOpen = new URL(clickedNotification.data.href, self.location.origin).href;
let promiseChain = clients.matchAll({
type: 'window',
includeUncontrolled: true
}).then(windowClients => {
let matchingClient = null;
for (let i = 0, max = windowClients.length; i < max; i++) {
let windowClient = windowClients[i];
if (windowClient.url.split('?')[0] === urlToOpen.split('?')[0]) {
matchingClient = windowClient;
break;
}
}
return matchingClient ? matchingClient.focus() : clients.openWindow(urlToOpen);
});
event.waitUntil(promiseChain);
clickedNotification.close();
});
监听 notificationclick 点击事件,除了需要打开弹窗,还要判断该弹窗是否曾经打开过,如果是则只需active tab即可。
参考链接
https://www.boatsky.com/blog/66
胜象大百科







