博客
电影
宝箱
友链
关于
<
JS代理Proxy与反射Reflect场景
升级到php7.3与laravel5.8的问题记录
>
轻应用PWA实践全过程
作者:
Cifer
类别: 技术·JS
时间:2019-03-11 19:44:50
字数:16925
版权所有,未经允许,请勿转载,谢谢合作~
###前言 <a href="https://www.boatsky.com/blog/66.html">PWA (Progressive web apps)</a>,渐进式Web 应用,又称轻应用,是一种纯HTML5网站却可实现Native App的屏幕入口、离线缓存、消息推送等功能的W3C标准的技术组合。 PWA的完整教程网上比较少(中文版写的比较好的:<a href="https://lavas.baidu.com/pwa" target="_blank">https://lavas.baidu.com/pwa</a>,不过里面实践比较少,很多坑没踩出来),故写下这篇文章帮助需要的人。PWA按照以上三个主要功能,分别用到三种技术: <a href="#link_manifest">manifest.json 实现APP入口</a> <a href="#link_service_worker">Service Worker 离线缓存</a> <a href="#link_web_push">Web Push 消息推送</a> 它们都需要在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的推送)。 <i id="link_manifest"></i> ### manifest.json 实现APP入口 manifest.json是一个位于网站对外根目录的配置文件(一般与index.html在同级目录),开发者只需按照 W3C定义好的属性<a href="https://www.w3.org/TR/appmanifest/" target="_blank">https://www.w3.org/TR/appmanifest/</a>设置即可,本文不做详述,只列举几个常用的属性: ```json { "name": "太空船博客-Cifer", "short_name": "太空船博客", "icons": [ { "src": "static/dist/image/common/boat.png", "type": "image/png", "sizes": "128x128" }, { "src": "static/dist/image/common/boat_192x192.png", "type": "image/png", "sizes": "192x192" } ], "start_url": "/?cf=homescreen", "background_color": "#00d586", "display": "standalone", "theme_color": "#00d586" } ``` | key | value | | ------------ | ------------ | | name | 应用名字,APP启动画面的图片及PC图标名字 | | short_name | 手机主屏幕的简称 | | icons | 应用logo,可设置多个,浏览会自动取出最佳大小的icon,最好有一个144x144大小以上的,否则在PC端部分浏览器会报错 | | start_url | 启动页面url | | background_color | 启动画面的背景色 | | display | 应用在浏览器的显示类型: browser为默认的浏览器打开应用,standalone 隐藏工具条/地址栏等,fullscreen 为全屏模式 | | theme_color | 浏览器主题色 | 手机用户可以用浏览器的“添加至主屏幕”,上述配置在此处生效,并且手机默认也会提示用户去添加。 开发者可以在Chrome devTools 的Application的Manifest中查看当前网站的匹配,它还可以提示配置错误。 <i id="link_service_worker"></i> ### 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 life cycle.](https://mdn.mozillademos.org/files/12636/sw-lifecycle.png "Service Worker life cycle.") | 状态 | 说明 | | ------------ | ------------ | | Installing | 安装注册,可以监听install事件 | | Installed | 安装之后等待同源的其他所有Worker关闭,才能进入初始化阶段,如果等不到,那么无法更新Worker,Worker会保持旧的代码 | | Activating | 初始化阶段,这时可以清除旧的Worker而升级Worker,并缓存的旧资源进行清除。可以监听activate事件 | | Activated | 激活阶段,像请求的拦截/代理,服务端推送的消息监听都在这个阶段工作。可以监听fetch,push,notificationclick,sync等事件 | | Redundant | Worker更新进行中 | 以上代码写在一个名为service_worker.js的脚本里,但它是独立运行的,我们又需要写引用/执行这个脚本的脚本 service_worker_before.js。 入口文件service_worker_before.js 注册Service worker : ```javascript const swVersion = '0.3.20'; if ('serviceWorker' in navigator) { window.addEventListener('load', function () { registerServiceWorker(); }); } // 注册 service worker function registerServiceWorker() { return navigator.serviceWorker.register(`/service_worker.js?v=${swVersion}`, { scope: '/' }).then(function (registration) { if (localStorage.getItem('sw_version') !== swVersion) { registration.update().then(function () { localStorage.setItem('sw_version', swVersion); console.log(`serviceWorker ${swVersion} registration update successful with scope: `, registration.scope); }); } else { console.log(`serviceWorker ${swVersion} registration successful with scope: `, registration.scope); } }).catch(function (err) { console.log(`serviceWorker ${swVersion} registration failed: `, err); }); } ``` 注册代码很简单,需注意几点: 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中可以设置不要缓存(未实践): ``` location ~.* (manifest\.json|service_worker\.js|service_worker_before\.js)$ { add_header Cache-Control no-cache; } ``` 外部入口注册后,我们可以在service_worker.js中写Worker内部事件了: ##### Worker 安装 ```javascript let swVersion = ''; self.addEventListener('install', function () { swVersion = new URL(location).searchParams.get('v'); self.skipWaiting(); }); ``` 如果追求快速更新,我们可以跳过等待,直接激活,即我们打开的新页面都是使用最新的Worker代码。 ##### Worker 激活 ```javascript self.addEventListener('activate', function (event) { event.waitUntil( Promise.all([ // 更新客户端 self.clients.claim(), // 清理旧版本cache caches.keys().then(function (cacheList) { return Promise.all( cacheList.map(function (cacheName) { if (cacheName !== swVersion) { return caches.delete(cacheName); } }) ); }), // 清空上一次之后,在 swVersion 版本中添加首页缓存,否则首页需要刷新两次才能缓存。 caches.open(swVersion).then(function (cache) { // 通过 cache 缓存对象的 addAll 方法添加 precache 缓存 return cache.addAll([ '/', '/offline.html', '/static/dist/image/common/favicon.ico', '/static/dist/css/common/global.css', '/static/dist/css/blog/blog_list.css', '/static/external/mixed/static.js', '/static/dist/js/common/common.js', '/static/dist/js/blog/blog_list.js', ]); }) ]) ); }); ``` 激活之后,我们做了3件事: a. 更新所有的同源客户端的service_worker.js,即使它没有刷新页面。 b.清除非当前最新版本的cache。 c. 把首页与离线页面(根据自己的需要)进入立即缓存,如果不这么做的话,因为激活阶段(第1次打开页面)还没到达,Worker还没有开始做cache的工作,页面已经打开了,这时是没有离线缓存的,第2次打开页面时没有离线cache,但这时页面会缓存下来,只有第3次才开始能取到离线cache,而上述这么做,第2次进来即可以拿到离线cache的首页。offline.html则是离线状态下的提示页,否则用户不知道可以离线缓存,就直接不再使用APP了。 ##### Cache Storage 离线缓存 ```javascript // 截取请求,返回cache storage self.addEventListener('fetch', function (event) { event.respondWith( caches.match(event.request).then(function (response) { // 如果 service worker 没有返回,那就得直接请求真实远程服务 var request = event.request.clone(); // 把原始请求拷过来 return fetch(request).then(function (httpRes) { // 请求失败了,直接返回失败的结果 if (!httpRes || httpRes.status !== 200) { return httpRes; } // 请求成功的话,将请求缓存起来。 var responseClone = httpRes.clone(); // 只有GET 请求可以被缓存 if (request.method === 'GET' && !/.+((manifest\.json)|(service_worker\.js)|(service_worker_before\.js))($|(\?\S*))/.test(request.url)) { caches.open(swVersion).then(function (cache) { cache.put(event.request, responseClone); }); } return httpRes; }).catch(() => { if (response) { return response; } else { if (request && request.url && /.+\.html($|(\?\S*))/.test(request.url)) { return caches.open(swVersion).then((cache) => { return cache.match('/offline.html'); }); } } }); }) ); }); ``` 注意点: 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"是不做离线缓存的。 <i id="link_web_push"></i> ### Web Push 消息推送 Web Push的过程比较复杂,因为它涉及到4个端: | 端点 | 作用 | | ------------ | ------------ | | 网页客户端 service_worker_before.js | 消息推荐授权、向推送服务端发起订阅、把订阅信息提到至业务服务端 | | Service Worker 客户端 service_worker.js | 监听推送服务端的推送信息、向网页客户端发起弹窗提示(推送的落地) | | 业务服务端 | 生成公钥私钥、保存网页客户端的订阅信息、基于订单信息向推送服务端发起消息推送 | | 推送服务端 | 接受网页客户端订阅、接受业务服务端的推送消息、向Service Worker端发送推送信息 | 首先先列出简化的9个步骤: a. 业务服务端生成公钥与私钥,并把公钥给网页客户端 b. 网页客户端需要支持PushManager前提下,然后请求用户授权通知 c. b的基础上,网页客户端把公钥转成Uint8Array d. 网页客户端向推送服务端发起订阅,如果成功,会得到推送服务器返回的订阅信息 e. 网页客户端把订阅信息发给业务服务端 f. 业务服务端保留该订阅信息 g. 业务服务端拿着订阅列表、公钥私钥、把想要推送的信息发送给推送服务端 h. 推送服务端拿到推送信息,解析后发送给Service Worker端 i. Service Worker监听到信息,使用Notification推送给用户 除了四个端之间有各种交互,还有各种加密比较麻烦外,关于推送服务器文档少、不便于调试、兼容性不好也是个问题。 ##### 关于Web Push的PHP后端实现 本博客后端使用的PHP,相关教程较少,所幸已经开源的组件可用<a href="https://github.com/web-push-libs/web-push-php" target="_blank">https://github.com/web-push-libs/web-push-php</a>。 安装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.生成公钥私钥 ```php use Minishlink\WebPush\VAPID; echo var_dump(VAPID::createVapidKeys()); ``` f. 业务服务端保留该订阅信息 略 g. 业务服务端拿着订阅列表、公钥私钥、把想要推送的信息发送给推送服务端 ```php 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); } ``` 提交推送的信息页面: ```html <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 授权、发起订阅、提交订阅 ```javascript 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,发出通知 ```javascript // 监听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推送出的消息才不会出现链接无法点击的问题。 ##### 监听推送消息的点击事件 ```javascript // 推送消息点击事件 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即可。 最后“太空船博客”已经实现了上述的功能,“内网”用户使用Firefox,“外网”用户使用Chrome可体验。在Chrome中, 可以查看已经订阅了哪些网站 <a href="chrome://gcm-internals/" target="_blank">chrome://gcm-internals/</a>。
如果觉得有帮忙,您可以在本页底部留言。
相关推荐:
NPM发包流程与技巧
Puppeteer爬取豆瓣电影TOP250评分
基于vue实现腾讯云点播的上传与播放
JS代理Proxy与反射Reflect场景
ES6迭代器Iterator原理与性能
JavaScript之Set与Map
ES6设计模式之观察者模式
解决toFixed四舍五入陷阱
深入理解IEEE754的64位双精度
ES6设计模式之单例模式
ES6设计模式之工厂模式
ES6二叉树的实现
JavaScript链表实例
JavaScript排序算法及性能比较
原生ajax及jQuery封装ajax实例
JavaScript类的语法糖
……
更多
<
JS代理Proxy与反射Reflect场景
升级到php7.3与laravel5.8的问题记录
>
全部留言
我要留言
内容:
网名:
邮箱:
个人网站:
发表
全部留言