转《service worker在移动端H5项目的应用》
1. PWA和Service Worker的关系
PWA (Progressive Web Apps) 不是一项技术,也不是一个框架,我们可以把她理解为一种模式,一种通过应用一些技术将 Web App 在安全、性能和体验等方面带来渐进式的提升的一种 Web App的模式。对于 webview 来说,Service Worker 是一个独立于js主线程的一种 Web Worker 线程, 一个独立于主线程的 Context,但是面向开发者来说 Service Worker 的形态其实就是一个需要开发者自己维护的文件,我们假设这个文件叫做 sw.js。通过 service worker 我们可以代理 webview 的请求相当于是一个正向代理的线程,fiddler也是干这些事情),在特定路径注册 service worker 后,可以拦截并处理该路径下所有的网络请求,进而实现页面资源的可编程式缓存,在弱网和无网情况下带来流畅的产品体验,所以 service worker 可以看做是实现pwa模式的一项技术实现。
2. service worker简介
-
注意事项
- service worker 是一种JS工作线程,无法直接访问DOM, 该线程通过postMessage接口消息形式来与其控制的页面进行通信;
- service worker 广泛使用了Promise,这些在接下来代码示例中将会看到;
- 目前并不是所有主流浏览器支持 service worker, 可以通过 navigator && navigator.serviceWorker 来进行特性探测;
- 在开发过程中,可以通过 localhost 使用服务工作线程,如若上线部署,必须要通过https来访问注册服务工作线程的页面,但有种场景是我们的测试环境可能并不支持https,这时就要通过更改host文件将localhost指向测试环境ip来巧妙绕过该问题(例如:192.168.22.144 localhost);
-
生命周期
- service worker的生命周期完全独立于网页,要为网站安装服务工作线程,我们需要在页面业务js代码中注册,浏览器从指定路径下载并解析服务工作线程脚本进而浏览器将会在后台启动安装步骤,在安装过程中,我们通常会缓存静态资源,如果所有文件都成功缓存,那么服务工程线程就安装完毕,如果任何文件下载失败或缓存失败,那么安装步骤将会失败,当然也不会被激活。安装后就进入激活步骤,这里是管理旧缓存的绝佳机会(后面代码示例中将会介绍原因),激活后service worker将开始对其作用域内的所有页面实施控制。这里需要注意的是,首次注册 service worker 线程的页面需要再次加载才会受其控制。在成功安装完成并处于激活状态之前,服务工程线程不会收到fetch和push事件;
-
工作流程
-
注册
- 这里需要注意的是register方法注册服务工作线程文件的位置,该path就是默认的 serviceworker 的作用域,例如注册path为/a/b/service-worker.js,则默认scope为/a/b/,当然也可以通过传入{scope: '/a/b/c/'}来指定自己的scope,但这里要特别注意的是,传入的scope参数一定是在默认作用域范围内再自定义(例如/a/b/c/),反之自定义为/d/e/就不行;
- 通俗来讲,上面提到的scope就是 service worker 能够控制和发挥作用的范围;
- 注意注册是在自己的业务代码中进行,后面会有具体通过插件来实现注册的代码示例;
if(navigator && navigator.serviceWorker) { navigator.serviceWorker.register('/service-worker.js').then(function (registration) { console.log(registration) }).catch(function (err) { console.log(err) }) }
-
安装
- 下面代码就是前面注册的service-worker.js文件内容;
- 我们通过install事件来定义安装步骤,通过缓存名称调用caches.open(), 之后再调用cache.addAll()并传入具体缓存文件清单数组,这是一个Promise链式event.waitUntil()方法带有Promise参数并使用它来判断花费耗时以及安装是否成功;
- 正如前面提到,安装过程中如果所有清单中文件成功缓存,则安装结束,否则安装过程视为失败,所以在实践中我们尽可能缓存核心资源以避免服务工作线程未能安装;
var cacheVersion = 'test_2017122608'; // 安装服务工作线程 self.addEventListener('install', function(event){ // 需要缓存的资源 var cacheFiles = [ '/dist/index.html', '/dist/js/index_async_bundle.js' ]; console.log('service worker: run into install'); event.waitUntil(caches.open(cacheVersion).then(function(cache) { return cache.addAll(cacheFiles); })); });
-
激活
- 在某个时间点服务工程线程需要更新(例如:service-worker.js文件发生更改并上线),用户访问页面时浏览器会尝试在后台重新下载service-worker.js,如果服务工程线程文件与当前所用文件存在字节差异,则将其视为“新服务工作线程”;
- 新服务工作线程将会启动,且将会触发 install 事件;
- 此时旧的服务工作线程仍将控制着当前页面,因此新服务工作线程将会进入waiting状态;
- 当网站当前页面关闭时,旧服务工作线程将会终止,新服务工作线程将会取得控权;
- 新服务工作线程取得控制权后,将会触发 activate 事件;
- 监听 activate 事件的回调函数中常见的任务是管理缓存,前面我也提到过这是管理旧缓存的绝佳时机,因为如果在安装步骤中清理了旧缓存,由于旧的服务工作线程仍旧控制着页面,将无法从缓存中提取文件,但是在 activate 时旧服务工作线程已经终止了页面控制权,所在在这里清理旧缓存再合适不过;
// 新的service worker线程被激活(其实和离线包一样存在"二次生效"的机理) self.addEventListener('activate', function (event) { console.log('service worker: run into activate'); event.waitUntil(caches.keys().then(function (cacheNames) { return Promise.all(cacheNames.map(function (cacheName) { // 注意这里cacheVersion也可以是一个数组 if(cacheName !== cacheVersion){ console.log('service worker: clear cache' + cacheName); return caches.delete(cacheName); } })); })); });
-
监听
- 这里通过监听fetch事件来代理响应,进而实现自定义前端资源缓存;
- 在event.respondWith()中我们传入来自caches.match()的一个promise,此方法拦截请求并从服务工作线程所创建的任何缓存中查找缓存结果,如若发现匹配的响应则返回缓存的值,否则,将会调用fetch以代理发出网络请求,并将从网络中检索的数据作为结果返回;
- 如果希望连续性缓存新的请求,则注意注释的代码部分,其通过cache.put来将请求的响应添加到缓存来实现;
- 在fetch请求中添加对then()的回调,获得响应后执行检查,并clone响应,注意这样处理的原因是该响应是stream,主体只能使用一次,我们需要返回能被浏览器使用的响应,还要传递到缓存以供使用,因此需要克隆一份副本;
// 拦截请求并响应 self.addEventListener('fetch', function (event) { console.log('service worker: run into fetch'); event.respondWith(caches.match(event.request).then(function (response) { // 发现匹配的响应缓存 if(response){ console.log('service worker 匹配并读取缓存:' + event.request.url); return response; } console.log('没有匹配上:' + event.request.url); return fetch(event.request); /*var fetchRequest = event.request.clone(); return fetch(fetchRequest).then(function(response){ if(!response || response.status !== 200 || response.type !== 'basic'){ return response; } var responseToCache = response.clone(); caches.open(cacheVersion).then(function (cache) { console.log(cache); cache.put(fetchRequest, responseToCache); }); return response; });*/ })); });
-
3. 前端资源缓存演进
- 利用webview自身的http缓存机制。这里往往需要服务器运维同事配合,对于前端来讲不够灵活且缓存粒度太粗,而且在http协议在不同版本下缓存机制有一定的差异(例如1.0版本中If-Modified-Since、Last-Modified、expires, 1.1版本中对缓存进行了优化,添加If-None-Match、Etag、cache-control等;
- 离线包策略,其大致原理是通过将静态资源打包至离线管理平台(自行开发),在app启动时从离线管理平台拉取资源包并存放于本地,后续终端将会拦截url请求并基于约定规则将请求代理到本地文件系统,进而加快静态资源的访问以及为cdn减压,该方案的缺陷在于需要离线资源管理平台和终端的配合,牵扯资源过多,但其优点是不存在兼容性问题;
- h5离线缓存manifest,其实质就是一个缓存清单文件(xx.manifest),然后在html标签设置manifest属性为xx.manifest,该缓存方案也存在“二次更新”的问题,该方案需要注意的问题是xx.manifest文件自身不要被webview缓存,且manifest文件cache部分不能使用通配符,必须手动指定,不过好在可以通过构建工具来解决,主流浏览器对该方案支持度也不错。与service worker相对,其业务JS代码无法感知缓存更新的时机,所以service worker方案更具有想象空间;
- service worker 通过一个独立JS线程来实现资源的可编程式缓存;
4. 项目如何快速接入service worker
- 在接入前有两个问题摆在我们面前,service worker可以帮助我们解决资源缓存问题,有缓存就必须要有更新的机制,service-worker.js本身也会被浏览器缓存,后续产品迭代过程中如何解决该文件自身的更新问题,否则其他资源的缓存更新也就无从谈起(旧的服务工作线程将一直控制页面),无可厚非每次构建部署时service-worker.js需要携带版本号(例如?v=201801021721),当然也可以在服务器运维层控制该文件的cache-control: no-cache从而规避浏览器缓存问题,但这样太麻烦;
-
我们是在业务代码中通过register的方式引入service-worker.js, 那问题就变为如何在注册服务工作线程的位置引入版本号呢,我们可以通过sw-register-webpack-plugin来解决该问题,其思路是将服务工作线程的注册放在一个单独的文件中(sw-register.js),然后自动在页面入口(例如index.html)写入一段JS脚本来动态加载sw-register.js文件,这里sw-register.js的加载路径是带有实时时间戳的,而生成的sw-register.js文件内容中注册service-worker.js的位置自动携带构建版本号参数(默认是当前构建时间),该插件配置如下(基于webpack构建的项目):
let SwRegisterWebpackPlugin = require('sw-register-webpack-plugin') ... plugins: [ new SwRegisterWebpackPlugin({ filePath: path.resolve(__dirname, '../src/sw-register.js') }) ]
- 构建后html新增部分如图:
- 构建后生成的sw-register.js文件变化如图:
- 这样处理后,sw-register.js文件就不会被浏览器缓存,也即每次刷新会多一次sw-register.js的文件请求,由于它只是用来做注册的工作,体量不会太大,可以接受,关键是前端可以自行控制
-
已缓存资源文件如何更新呢?上述插件只是解决了service-worker.js文件本身的更新的问题(保证每次构建部署后会新启一个服务工作线程),但对于service-worker.js文件中定义的cacheFiles而言,当我们修改了已缓存文件后如何来更新缓存呢,我的项目是基于vue.js + webpack,打包后的JS文件是[name].[hash].[ext]格式,从前面的介绍可知资源的缓存也是基于url(作为key)来的,不可能每次构建后都手动去调整service-worker.js文件内容中cacheFiles的路径值吧,应该是将构建后的文件名(包括路径)直接放到service-worker.js内容中,看到这里你应该想到了有webpack插件已经帮我们做好了,那就是sw-precache-webpack-plugin,该插件会自动在dist目录下生成service-worker.js文件,供给service worker运行,也就是说service-worker.js文件本身不需要我们手动添加了,但问题是我们如何自定义需要缓存的文件呢,该插件的配置参数会告诉你,我的项目该插件配置如下:
// 生成service-worker.js和配置缓存清单 new SwPrecacheWebpackPlugin({ cacheId: 'attendance-mobile-cache', filename: 'service-worker.js', minify: true, dontCacheBustUrlsMatching: false, staticFileGlobs: [ 'dist/static/js/manifest.**.*', 'dist/static/js/vendor.**.*', 'dist/static/js/app.**.*' ], stripPrefix: 'dist/' })
- 由上可知,我们能够通过正则来匹配需要缓存的文件,这里特别要注意的是stripPrefix参数的使用,我们配置的缓存文件路径是项目中的路径,但对于部署线上而言,我们可能需要过滤前缀的部分路径(我的项目线上部署文件根目录下就是static等,所以需要过滤dist路径),最终该插件生成的service-worker.js文件如图所示(仅截取缓存文件清单部分代码)
4. 调试service worker
- 通过上述两个插件,我们的service-worker接入工作基本完成,那接下来就是验证服务工作线程运行是否ok,通过chrome devTools(Application项)我们可以很方面的查看当前服务工作线程的运行情况和已缓存了哪些文件,具体如何查看这里不再介绍;
- 当首次运行 service worker 时我们会发现要缓存的文件还是走正常的网络请求,cache storage 下也看不到我们的缓存项,因为服务工程线程也存在“二次生效”的机制(即使需要缓存的资源延迟加载),具体如下图所示:
- 通过刷新访问我们可以看到,service worker 缓存文件已经生效,在network面板下自定义的缓存文件size项都显示为“from ServiceWorker”, 耗时也明显很低。在cache storage下面也可以看到已经缓存的文件列表,具体如下图所示:
- 接下来我们更新service-worker.js文件来看下新服务工作线程如何工作,正如前面所讲新服务工作线程将会启动安装,但由于旧服务工作线程控制着页面,所以新服务工作线程将进入waiting状态,当当前打开的页面关闭时,旧服务工作线程将会被终止,新服务工作线程会得的控制权并触发activate事件,在开发过程中我们需要通过Chrome Devtools的skipWaiting或者勾选Updated on reload来强制激活新服务工作线程,具体如下图所示:
- 在开发过程中我们可以通过上述来了解新服务工作线程的更新流程,但在实际项目中我们可以通过self.skipWaiting()跳过等待过程安装后直接激活,一般我们在install事件中调用,具体可参见sw-precache-webpack-plugin生成的service-worker源代码。这会导致新服务工作线程将当前活动的工作线程逐出,skipWaiting()意味着新服务工作线程可能会控制使用较旧工作线程加载的页面,也就是页面获取的部分数据由旧工作线程处理,而新服务工作线程处理后来获取的数据,如果有问题就不要使用skipWaiting();
- 手动清理service worker缓存后刷新页面,在 Network 面板中,我们会看到本应缓存文件的一组初始请求。之后是前面带有齿轮图标的第二轮请求,这些请求似乎要获取相同的资源,“齿轮”图标代表这些请求来自服务工作线程,如果不unregsiter该服务工作线程,我们会发现即使多次刷新页面,Network 面板依然如此,其实也就是说资源没有再次缓存(因为服务工作线程已经安装且控制当前页面,刷新操作不会重新触发install事件,也就不会再次添加资源到缓存,除非unregister或者更新service-worker.js文件),具体如下图所示:
5. 异常回滚(注销)
-
某些场景下如果service worker使用出现异常,比如不同页面间 service worker 控制的scope存在“重叠污染”的问题,那么我们就需要紧急回滚(撤销)当前 service worker,在开发环境很好解决,我们依然可以通过Chrome Devtools来进行unregister, 那么在线上环境已经有服务工作线程在运行的情况下呢,我们需要在新上线版本的service worker注册前将被污染或者异常的service worker注销掉,具体代码如下:
if (navigator.serviceWorker) { navigator.serviceWorker.getRegistrations().then(function (registrations) { for (var item of registrations) { if (item.scope === 'http://localhost/attendance-mobile/dist/') { item.unregister(); } } // 注销掉污染 Service Worker 之后再重新注册... }); }
备注:文中部分内容摘选自Google开发者文档
原文直通车