什么是PWA?PWA离线方案研究分析

作者:京东云开发者-cho 张鹏程
本文并不是介绍如何将一个网页配置成离线应用并支持安装下载的。研究 pwa 的目的仅仅是为了保证用户的资源可以直接从本地加载,来忽略全国或者全球网络质量对页面加载速度造成影响。当然,如果页面上所需的资源,除了资源文件外并不需要任何的网络请求,那它除了不支持安装到桌面,已经算是一个离线应用了。
什么是 pwa
pwa(progressive web app)是一种结合了网页和原生应用程序功能的新型应用程序开发方法。pwa 通过使用现代 web 技术,例如 service worker 和 web app manifest,为用户提供了类似原生应用的体验。 从用户角度来看,pwa 具有以下特点: 1. 可离线访问:pwa 可以在离线状态下加载和使用,使用户能够在没有网络连接的情况下继续浏览应用; 2. 可安装:用户可以将 pwa 添加到主屏幕,就像安装原生应用一样,方便快捷地访问; 3. 推送通知:pwa 支持推送通知功能,可以向用户发送实时更新和提醒; 4. 响应式布局:pwa 可以适应不同设备和屏幕大小,提供一致的用户体验。
从开发者角度来看,pwa 具有以下优势: 1. 跨平台开发:pwa 可以在多个平台上运行,无需单独开发不同的应用程序; 2. 更新便捷:pwa 的更新可以通过服务器端更新 service worker 来实现,用户无需手动更新应用; 3. 可发现性:pwa 可以通过搜索引擎进行索引,增加应用的可发现性; 4. 安全性:pwa 使用 https 协议传输数据,提供更高的安全性。 总之,pwa 是一种具有离线访问、可安装、推送通知和响应式布局等特点的新型应用开发方法,为用户提供更好的体验,为开发者带来更高的效率。 我们从 pwa 的各种能力中,聚焦下其可离线访问的能力。
service worker
离线加载本质上是页面所需的各种js、css以及页面本身的html,都可以缓存到本地,不再从网络上请求。这个能力是通过service worker来实现的。 service worker 是一种在浏览器背后运行的脚本,用于处理网络请求和缓存数据。它可以拦截和处理网页请求,使得网页能够在离线状态下加载和运行。service worker 可以缓存资源,包括 html、css、javascript 和图像等,从而提供更快的加载速度和离线访问能力。它还可以实现推送通知和后台同步等功能,为 web 应用带来更强大的功能和用户体验。 某些情况下,service worker 和浏览器插件的 background 很相似,但在功能和使用方式上有一些区别:
功能差异: service worker 主要用于处理网络请求和缓存数据,可以拦截和处理网页请求,实现离线访问和资源缓存等功能。而浏览器插件的 background 主要用于扩展浏览器功能,例如修改页面、拦截请求、操作 dom 等。
运行环境: service worker 运行在浏览器的后台,独立于网页运行。它可以在网页关闭后继续运行,并且可以在多个页面之间共享状态。而浏览器插件的 background 也在后台运行,但是它的生命周期与浏览器窗口相关,关闭浏览器窗口后插件也会被终止。
权限限制: 由于安全考虑,service worker 受到一定的限制,无法直接访问 dom,只能通过 postmessage () 方法与网页进行通信。而浏览器插件的 background 可以直接操作 dom,对页面有更高的控制权。
总的来说,service worker 更适合用于处理网络请求和缓存数据,提供离线访问和推送通知等功能;而浏览器插件的 background 则更适合用于扩展浏览器功能,操作页面 dom,拦截请求等。
注册
注册一个 service worker 其实是非常简单的,下面举个简单的例子
service worker 示例 // service-worker.js// 定义需要预缓存的文件列表const filestocache =[ '/', '/index.html', '/styles.css', '/script.js', '/image.jpg'];// 安装service worker时进行预缓存self.addeventlistener('install',function(event){ event.waituntil( caches.open('my-cache') .then(function(cache){ return cache.addall(filestocache); }) );});// 激活service workerself.addeventlistener('activate',function(event){ event.waituntil( caches.keys().then(function(cachenames){ return promise.all( cachenames.filter(function(cachename){ return cachename !=='my-cache'; }).map(function(cachename){ return caches.delete(cachename); }) ); }) );});// 拦截fetch事件并从缓存中返回响应self.addeventlistener('fetch',function(event){ event.respondwith( caches.match(event.request) .then(function(response){ return response ||fetch(event.request); }) );});上述示例中,注册 service worker 的逻辑包含在 html 文件的同时调整下sw的拦截逻辑。// 新增runtime缓存const runtimecachename ='runtime-cache-'+ version;// 符合条件也是缓存优先,但是每次都重新发起网络请求更新缓存constisstalewhilerevalidate =(request)=>{ const url = request.url; const index =['http://127.0.0.1:5500/mock.js'].indexof(url); return index !==-1;};self.addeventlistener('fetch',function(event){ event.respondwith( // 尝试从缓存中获取响应 caches.match(event.request).then(function(response){ var fetchpromise =fetch(event.request).then(function(networkresponse){ // 符合匹配条件才克隆响应并将其添加到缓存中 if(isstalewhilerevalidate(event.request)){ var responsetocache = networkresponse.clone(); caches.open(runtimecachename).then(function(cache){ cache.put(event.request, responsetocache.clone()); }); } return networkresponse; }); // 返回缓存的响应,然后更新缓存中的响应 return response || fetchpromise; }) );});现在每次用户打开新的页面, 优先从缓存中获取资源,同时发起一个网络请求
有缓存则直接返回缓存,没有则返回一个fetchpromise
fetchpromise内部更新符合缓存条件的请求
用户下一次打开新页面或刷新当前页面,就会展示最新的内容
通过修改isstalewhilerevalidate中 url 的匹配条件,就能够控制是否更新缓存。在上面的示例中,我们可以将index.html从precache列表中移除,放入runtime中,或者专门处理下index.html的放置规则,去更新precache中的缓存。最好不要出现多个缓存桶中存在同一个request的缓存,那样就不知道走的到底是哪个缓存了。 一般来说,微前端的应用,资源文件都有个固定的存放位置,文件本身通过在文件名上增加hash或版本号来进行区分。我们在isstalewhilerevalidate函数中匹配存放资源位置的路径,这样用户在第二次打开页面时,就可以直接使用缓存了。如果是内嵌页面,可以与平台沟通,是否可以在应用冷起的时候,偷偷访问一个资源页面,提前进行预加载,这样就能在首次打开的时候也享受本地缓存了。
缓存过期
即使我们缓存了一些资源文件,例如 iconfont、字体库等只会更新自身内容,但不会变化名称的文件。仅使用stale-while-revalidate其实也是可以的。用户会在第二次打开页面时看到最新的内容。 但为了提高一些体验,例如,用户半年没打开页面了,突然在今天打开了一下,展示历史的内容就不太合适了,这时候可以增加一个缓存过期的策略。 如果我们使用的是workbox,通过使用expirationplugin来实现的。expirationplugin是workbox中的一个缓存插件,它允许为缓存条目设置过期时间。示例如下所示
import{ registerroute }from'workbox-routing';import{ cachefirst, stalewhilerevalidate }from'workbox-strategies';import{ expirationplugin }from'workbox-expiration';// 设置缓存的有效期为一小时const cacheexpiration ={ maxageseconds:60*60,// 一小时};// 使用cachefirst策略,并应用expirationpluginregisterroute( ({ request })=> request.destination ==='image', newcachefirst({ cachename:'image-cache', plugins:[ newexpirationplugin(cacheexpiration), ], }));// 使用stalewhilerevalidate策略,并应用expirationpluginregisterroute( ({ request })=> request.destination ==='script', newstalewhilerevalidate({ cachename:'script-cache', plugins:[ newexpirationplugin(cacheexpiration), ], }));或者我们可以实现一下自己的缓存过期策略。首先是增加缓存过期时间。在原本的更新缓存的基础上,设置自己的cache-control,然后再放入缓存中。示例中直接删除了原本的cache-control,真正使用中,需要判断下,比如no-cache类型的资源,就不要使用缓存了。 每次命中缓存时,都会判断下是否过期,如果过期,则直接返回从网络中获取的最新的请求,并更新缓存。self.addeventlistener('fetch',function(event){ event.respondwith( // 尝试从缓存中获取响应 caches.match(event.request).then(function(response){ var fetchpromise =fetch(event.request).then(function(networkresponse){ if(isstalewhilerevalidate(event.request)){ // 检查响应的状态码是否为成功 if(networkresponse.status ===200){ // 克隆响应并将其添加到缓存中 var clonedresponse = networkresponse.clone(); // 在存储到缓存之前,设置正确的缓存头部 var headers =newheaders(networkresponse.headers); headers.delete('cache-control'); headers.append('cache-control','public, max-age=3600');// 设置缓存有效期为1小时 // 创建新的响应对象并存储到缓存中 var cachedresponse =newresponse(clonedresponse.body,{ status: networkresponse.status, statustext: networkresponse.statustext, headers: headers, }); caches.open(runtimecachename).then((cache)=>{ cache.put(event.request, cachedresponse); }); } } return networkresponse; }); // 检查缓存的响应是否存在且未过期 if(response &&!isexpired(response)){ return response;// 返回缓存的响应 } return fetchpromise; }) );});functionisexpired(response){ // 从响应的headers中获取缓存的有效期信息 var cachecontrol = response.headers.get('cache-control'); if(cachecontrol){ var maxagematch = cachecontrol.match(/max-age=(d+)/); if(maxagematch){ var maxageseconds =parseint(maxagematch[1],10); var requesttime = date.parse(response.headers.get('date')); var expirationtime = requesttime + maxageseconds *1000; // 检查当前时间是否超过了缓存的有效期 if(date.now(){ func.apply(this, args); }, delay); };}const clearoutdateresources =debounce(function(){ cache .open(runtimecachename) .keys() .then(function(requests){ requests.foreach(function(request){ cache.match(request).then(function(response){ // response为匹配到的response对象 if(isexpiredwithtime(response,10)){ cache.delete(request); } }); }); });});functionisexpiredwithtime(response, time){ var requesttime = date.parse(response.headers.get('date')); if(!requesttime){ returnfalse; } var expirationtime = requesttime + time *1000; // 检查当前时间是否超过了缓存的有效期 if(date.now(){ const url = request.url; const index =[`${self.location.origin}/mock.js`,`${self.location.origin}/index.html`].indexof(url); return index !==-1;};/*********************上面是配置代码***************************** */constaddresourcestocache =async()=>{ return caches.open(precachename).then((cache)=>{ return cache.addall(filestocache); });};// 安装service worker时进行预缓存self.addeventlistener('install',function(event){ event.waituntil( addresourcestocache().then(()=>{ self.skipwaiting(); }) );});// 删除上个版本的数据asyncfunctionclearoldresources(){ return caches.keys().then(function(cachenames){ return promise.all( cachenames .filter(function(cachename){ return![precachename, runtimecachename].includes(cachename); }) .map(function(cachename){ return caches.delete(cachename); }) ); });}// 激活service workerself.addeventlistener('activate',function(event){ event.waituntil( clearoldresources().finally(()=>{ self.clients.claim(); clearoutdateresources(); }) );});// 缓存优先constiscachefirst =(request)=>{ const url = request.url; const index = filestocache.findindex((u)=> url.includes(u)); return index !==-1;};functionaddtocache(cachename, request, response){ try{ caches.open(cachename).then((cache)=>{ cache.put(request, response); }); }catch(error){ console.error('add to cache error =>', error); }}asyncfunctioncachefirst(request){ try{ return caches .match(request) .then((response)=>{ if(response){ return response; } returnfetch(request).then((response)=>{ // 检查是否成功获取到响应 if(!response || response.status !==200){ return response;// 返回原始响应 } var clonedresponse = response.clone(); addtocache(runtimecachename, request, clonedresponse); return response; }); }) .catch(()=>{ console.error('match in cachefirst error', error); returnfetch(request); }); }catch(error){ console.error(error); returnfetch(request); }}// 缓存优先,同步更新asyncfunctionhandlefetch(request){ try{ clearoutdateresources(); // 尝试从缓存中获取响应 return caches.match(request).then(function(response){ var fetchpromise =fetch(request).then(function(networkresponse){ // 检查响应的状态码是否为成功 if(!networkresponse || networkresponse.status !==200){ return networkresponse; } // 克隆响应并将其添加到缓存中 var clonedresponse = networkresponse.clone(); addtocache(runtimecachename, request, clonedresponse); return networkresponse; }); // 返回缓存的响应,然后更新缓存中的响应 return response || fetchpromise; }); }catch(error){ console.error(error); returnfetch(request); }}self.addeventlistener('fetch',function(event){ const{ request }= event; if(iscachefirst(request)){ event.respondwith(cachefirst(request)); return; } if(isstalewhilerevalidate(request)){ event.respondwith(handlefetch(request)); return; }});functiondebounce(func, delay){ let timerid; returnfunction(...args){ cleartimeout(timerid); timerid =settimeout(()=>{ func.apply(this, args); }, delay); };}const clearoutdateresources =debounce(function(){ try{ caches.open(runtimecachename).then((cache)=>{ cache.keys().then(function(requests){ requests.foreach(function(request){ cache.match(request).then(function(response){ const isexpired =isexpiredwithtime(response, maxageseconds); if(isexpired){ cache.delete(request); } }); }); }); }); }catch(error){ console.error('clearoutdateresources error => ', error); }}, debouncecleartime *1000);functionisexpiredwithtime(response, time){ var requesttime = date.parse(response.headers.get('date')); if(!requesttime){ returnfalse; } var expirationtime = requesttime + time *1000; // 检查当前时间是否超过了缓存的有效期 if(date.now()< expirationtime){ returnfalse;// 未过期 } returntrue;// 已过期}
注意
在真实的验证过程中,有部分资源获取不到date这个数据,因此为了保险,我们还是在存入缓存时,自己补充一个存入时间
// 克隆响应并将其添加到缓存中var clonedresponse = networkresponse.clone();// 在存储到缓存之前,设置正确的缓存头部var headers =newheaders(networkresponse.headers);headers.append('sw-save-date', date.now()); // 创建新的响应对象并存储到缓存中var cachedresponse =newresponse(clonedresponse.body,{ status: networkresponse.status, statustext: networkresponse.statustext, headers: headers,});在判断过期时,取我们自己写入的key即可。functionisexpiredwithtime(response, time){ var requesttime =number(response.headers.get('sw-save-date')); if(!requesttime){ returnfalse; } var expirationtime = requesttime + time *1000; // 检查当前时间是否超过了缓存的有效期 if(date.now(){ console.log('service worker 注册成功!'); }) .catch((error)=>{ console.log('service worker 注册失败:', error); }); }else{ navigator.serviceworker.getregistration('/').then((reg)=>{ reg && reg.unregister(); if(reg){ window.location.reload(); } }); }}对于没有管理后台配置html的项目,可以将上面的脚本移动到sw-register.js的脚本中,在html以script的形式加载该脚本,并将该文件缓存设置为no-cache,也不要在sw中缓存该文件。这样出问题后,覆写下该文件即可。 总结
所有要说的,在上面都说完了。pwa 的离线方案,是一种很好的解决方案,但是也有其局限性。本项目所用的 demo 已经上传到了 github,可自行查看。


美新开发出车载用2轴加速度传感器
校园气象站应用
机械领域逆向工程三维扫描解决方案
iPhone15Ultra或万元起步
罗姆计划到2050年与业务活动相关的温室气体实现净零排放
什么是PWA?PWA离线方案研究分析
iPhone7的细节创新足够跟众多友商再战两年
苹果新机iPhone8/iphone8plus/iPhoneX正式发布!三星note8国行版底气十足,华为mate10紧跟步伐,不甘示弱即将上市
集成电路,上海重磅发布政策!
LN1179系列低压差电压稳压器的详细介绍
鉴于乐视网目前面临的诸多问题,无法畅想乐视网未来
卷积码编码及译码算法的基本原理
2019春晚红包互动208亿次 百度APP日活破3亿
德国奥托博克公司依靠PADS生产能力制造产品
恒温恒湿试验箱的控温方式与特色功能
数字孪生编辑器为可视化应用开发提供哪些帮助?
单结晶体管(双基极二极管)结构原理简介
【行业洞察】UWB在冬奥大放异彩,它为何会成为定位领域风口?
Anritsu MS2724C频谱分析仪20GHz
欧盟将针对信息和通信技术产品、服务和流程建立一个欧盟范围内的认证框架