<返回更多

JavaScript轻应用PWA实践全过程

2019-08-08    
加入收藏



前言

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/设置即可,本文不做详述,只列举几个常用的属性:

JavaScript技术栈应用篇之轻应用PWA实践全过程,快速集成到应用

 

手机用户可以用浏览器的“添加至主屏幕”,上述配置在此处生效,并且手机默认也会提示用户去添加。

开发者可以在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之前,我们先了解一下它的生命周期:

JavaScript技术栈应用篇之轻应用PWA实践全过程,快速集成到应用

 


JavaScript技术栈应用篇之轻应用PWA实践全过程,快速集成到应用

 

以上代码写在一个名为service_worker.js的脚本里,但它是独立运行的,我们又需要写引用/执行这个脚本的脚本 service_worker_before.js。

入口文件service_worker_before.js 注册Service worker :

JavaScript技术栈应用篇之轻应用PWA实践全过程,快速集成到应用

 

注册代码很简单,需注意几点:

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中可以设置不要缓存(未实践):

JavaScript技术栈应用篇之轻应用PWA实践全过程,快速集成到应用

 

外部入口注册后,我们可以在service_worker.js中写Worker内部事件了:

Worker 安装

JavaScript技术栈应用篇之轻应用PWA实践全过程,快速集成到应用

 

如果追求快速更新,我们可以跳过等待,直接激活,即我们打开的新页面都是使用最新的Worker代码。

Worker 激活

JavaScript技术栈应用篇之轻应用PWA实践全过程,快速集成到应用

 

激活之后,我们做了3件事:

a. 更新所有的同源客户端的service_worker.js,即使它没有刷新页面。

b.清除非当前最新版本的cache。

c. 把首页与离线页面(根据自己的需要)进入立即缓存,如果不这么做的话,因为激活阶段(第1次打开页面)还没到达,Worker还没有开始做cache的工作,页面已经打开了,这时是没有离线缓存的,第2次打开页面时没有离线cache,但这时页面会缓存下来,只有第3次才开始能取到离线cache,而上述这么做,第2次进来即可以拿到离线cache的首页。offline.html则是离线状态下的提示页,否则用户不知道可以离线缓存,就直接不再使用APP了。

Cache Storage 离线缓存

JavaScript技术栈应用篇之轻应用PWA实践全过程,快速集成到应用

 

注意点:

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个端:

JavaScript技术栈应用篇之轻应用PWA实践全过程,快速集成到应用

 

首先先列出简化的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

声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多资讯 >>>