作者:京东云开发者-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 Worker self.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策略,并应用ExpirationPlugin registerRoute( ({ request })=> request.destination ==='image', newCacheFirst({ cacheName:'image-cache', plugins:[ newExpirationPlugin(cacheExpiration), ], }) ); // 使用StaleWhileRevalidate策略,并应用ExpirationPlugin registerRoute( ({ 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()< expirationTime){ returnfalse;// 未过期 } } } returntrue;// 已过期 }从 Service Worker 发起的请求,可能会被浏览器自身的内存缓存或硬盘缓存捕获,然后直接返回。
精确清理缓存
下面的内容,默认为微前端应用。 随着微前端应用的更新,会逐渐出现失效的资源文件一直出现在缓存中,时间长了可能会导致缓存溢出。
定时更新
例如以半年为期限,定期更新sw文件的版本号,每次更新都会一刀切的将上一个版本中的动态缓存干掉,此操作会导致下次加载变慢,因为会重新通过网络请求的方式加载来创建缓存。但如果更新频率控制得当,并且资源拆分合理,用户感知不会很大。
处理不常用缓存
上文中的缓存过期策略,并不适用于此处。因为微服务中资源文件中,只要文件名不变,内容就应该不变。我们只是期望删除超过一定时间没有使用的条目,防止缓存溢出。这里也使用Stale-While-Revalidate的原因是为了帮助我们识别长期不使用的js文件,方便删除。 本来可以使用self.registration.periodicSync.register来创建一个周期性任务,但是由于兼容性问题,放弃了。需要的可自行研究,附上网址。 这里我们换一个条件。每当有网络请求被触发时,启动一个延迟 20s 的debounce函数,来处理缓存问题。先把之前的清除旧版本缓存的函数改名成clearOldResources。然后设定缓存过期时间为 10s,刷新两次页面来触发网路请求,20s 之后,runtime缓存中的mock.js就会被删除了。真实场景下,延迟函数和缓存过期都不会这么短,可以设置成 5min 和 3 个月。
functiondebounce(func, delay){ let timerId; returnfunction(...args){ clearTimeout(timerId); timerId =setTimeout(()=>{ 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()< expirationTime){ returnfalse;// 未过期 } returntrue;// 已过期 }重新总结下微前端应用下的缓存配置: 1. 使用版本号,并初始化preCache和runtimeCache 2. preCache中预缓存基座数据,使用Cache First策略,sw不更新则基座数据不更新 3. runtimeCache使用Stale-While-Revalidate策略负责动态缓存业务资源的数据,每次访问页面都动态更新一次 4. 使用debounce函数,每次访问页面都会延迟清除过期的缓存 5. 如果需要更新preCache中的基座数据,则需要升级版本号并重新安装sw文件。新服务激活后会删除上一个版本的数据 6. runtimeCache和preCache不能同时存储一个资源,否则可能导致混乱。
最终示例
下面是最终的sw.js,我删除掉了缓存过期的逻辑,如有需要请自行从上文代码中获取。顺便我增加了一点点丧心病狂的错误处理逻辑。 理论上,index.html应该放入预缓存的列表里,但我懒得写在Stale-While-Revalidate里分别更新preCache和runtimeCache了,相信看完上面内容的你,一定可以自己实现对应逻辑。 如果你用了下面的文件,每次刷新完页面的 20s 后,runtime 的缓存就会被清空,因为我们过期时间只设置了 10s。而每次发起请求后的 20s 后就会进行过期判断。 在真实的验证过程中,有部分
const version ='v1'; const preCacheName ='pre-cache-'+ version; const runtimeCacheName ='runtime-cache';// runtime不进行整体清除 const filesToCache =[];// 这里将index.html放到动态缓存里了,为了搭自动更新的便车。这个小项目也没别的需要预缓存的了 const maxAgeSeconds =10;// 缓存过期时间,单位s const debounceClearTime =20;// 延迟清理缓存时间,单位s // 符合条件也是缓存优先,但是每次都重新发起网络请求更新缓存 constisStaleWhileRevalidate =(request)=>{ 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 Worker self.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()< expirationTime){ returnfalse;// 未过期 } returntrue;// 已过期 }
不可见响应
还记得上面为了安全考虑,在存入缓存时,对响应的状态做了判断,非 200 的都不缓存。然后就又发现异常场景了。
// 检查是否成功获取到响应 if(!response || response.status !==200){ return response;// 返回原始响应 }opaque 响应通常指的是跨源请求(CORS)中的一种情况,在该情况下,浏览器出于安全考虑,不允许访问服务端返回的响应内容。opaque 响应通常发生在服务工作者(Service Workers)进行的跨源请求中,且没有 CORS 头部的情况下。 opaque 响应的特征是:
响应的内容无法被 JavaScript 访问。
响应的大小无法确定,因此 Chrome 开发者工具中会显示为 (opaque)。
响应的状态码通常是 0,即使实际上服务器可能返回了不同的状态码。
因此我们需要做一些补充动作。不单是补充cors模式,还得同步设置下credentials。
const newRequest = request.url ==='index.html' ? request :newRequest(request,{ mode:'cors', credentials:'omit'});在 Service Workers 发起网络请求时,如果页面本身需要认证,那就像上面代码那样,对页面请求做个判断。request.url === 'index.html'是我写的示例,真实请求中,需要拼出完整的 url 路径。而对于资源文件,走非认证的cors请求即可。将请求的request改为我们变更后的newRequest,请求资源就可以正常的被缓存了。
var fetchPromise =fetch(newRequest).then(function(networkResponse)
销毁
离线缓存用得好升职加薪,用不好就删库跑路。除了上面的一点点的防错逻辑,整体的降级方案一定要有。 看到这里,应该已经忘了 Service Worker 是如何被注册上的吧。没事,我们看个新的脚本。在原本的基础上,我们加了个变量SW_FALLBACK,如果离线缓存出问题了,赶紧到管理后台,把对应的值改成true。让用户多刷新两次就好了。只要不是彻底的崩溃导致html无法更新,这个方案就没问题。
// 如果有问题,将此值改成true SW_FALLBACK=false; if('serviceWorker'in navigator){ if(!SW_FALLBACK){ navigator.serviceWorker .register('/eemf-service-worker.js') .then((registration)=>{ 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,可自行查看。
审核编辑:黄飞
全部0条评论
快来发表一下你的评论吧 !