前言
PWA (Progressive web Apps),渐进式Web 应用,又称轻应用,是一种纯html5网站却可实现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。比如在Nginx中可以设置不要缓存(未实践):
外部入口注册后,我们可以在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后端实现
本博客后端使用的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