序言
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-gmpcomposer 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 Minishlink\WebPush\VAPID;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转换成UInt8Arrayfunction 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