《PWA学习与实践》系列文章已整理至 gitbook - PWA学习手册,文字内容已同步至 learning-pwa-ebook。转载请注明做者与出处。
本文是《PWA学习与实践》系列的第三篇文章。文中的代码均可以在learning-pwa的sw-cache分支上找到(git clone
后注意切换到sw-cache分支)。javascript
PWA做为时下最火热的技术概念之一,对提高Web应用的安全、性能和体验有着很大的意义,很是值得咱们去了解与学习。对PWA感兴趣的朋友欢迎关注《PWA学习与实践》系列文章。css
PWA其中一个使人着迷的能力就是离线(offline)可用。html
离线只是它的一种功能表现而已,具体说来,它能够:前端
而这一切,其实都要归功于PWA背后的英雄 —— Service Worker。java
那么,Service Worker是什么呢?你能够把Service Worker简单理解为一个独立于前端页面,在后台运行的进程。所以,它不会阻塞浏览器脚本的运行,同时也没法直接访问浏览器相关的API(例如:DOM、localStorage等)。此外,即便在离开你的Web App,甚至是关闭浏览器后,它仍然能够运行。它就像是一个在Web应用背后默默工做的勤劳小蜜蜂,处理着缓存、推送、通知与同步等工做。因此,要学习PWA,绕不开的就是Service Worker。git
在接下来的几篇文章里,我会从如何使用Service Worker来实现资源的缓存、消息的推送、消息的通知以及后台同步这几个角度,来介绍相关原理与技术实现。这些部分会是PWA技术的重点。须要特别注意的是,因为Service Worker所具备的强大能力,所以规范规定,Service Worker只能运行在HTTPS域下。然而咱们开发时候没有HTTPS怎么办?别着急,还有一个贴心的地方——为方便本地开发,Service Worker也能够运行在localhost(127.0.0.1)域下。github
好了,简单了解了Service Worker与它能实现的功能后,咱们仍是要回到这一篇的主题,也就是Service Worker的第一部分——如何利用Service Worker来实现前端资源的缓存,从而提高产品的访问速度,作到离线可用。web
这一小节会告诉你们,Service Worker是如何让咱们在离线的状况下也能访问Web App的。固然,离线访问只是其中一种表现。chrome
首先,咱们想一下,当访问一个web网站时,咱们实际上作了什么呢?整体上来讲,咱们经过与与服务器创建链接,获取资源,而后获取到的部分资源还会去请求新的资源(例如html中使用的css、js等)。因此,粗粒度来讲,咱们访问一个网站,就是在获取/访问这些资源。json
可想而知,当处于离线或弱网环境时,咱们没法有效访问这些资源,这就是制约咱们的关键因素。所以,一个最直观的思路就是:若是咱们把这些资源缓存起来,在某些状况下,将网络请求变为本地访问,这样是否能解决这一问题?是的。但这就须要咱们有一个本地的cache,能够灵活地将各种资源进行本地存取。
有了本地的cache还不够,咱们还须要可以有效地使用缓存、更新缓存与清除缓存,进一步应用各类个性化的缓存策略。而这就须要咱们有个可以控制缓存的“worker”——这也就是Service Worker的部分工做之一。顺便多说一句,可能有人还记得 ApplicationCache 这个API。当初它的设计一样也是为了实现Web资源的缓存,然而就是由于不够灵活等各类缺陷,现在已被Service Worker与cache API所取代了。
Service Worker有一个很是重要的特性:你能够在Service Worker中监听全部客户端(Web)发出的请求,而后经过Service Worker来代理,向后端服务发起请求。经过监听用户请求信息,Service Worker能够决定是否使用缓存来做为Web请求的返回。
下图展现普通Web App与添加了Service Worker的Web App在网络请求上的差别:
这里须要强调一下,虽然图中好像将浏览器、SW(Service Worker)与后端服务三者并列放置了,但实际上浏览器(你的Web应用)和SW都是运行在你的本机上的,因此这个场景下的SW相似一个“客户端代理”。
了解了基本概念以后,就能够具体来看下,咱们如何应用这个技术来实现一个离线可用的Web应用。
还记得咱们以前的那个图书搜索的demo Web App么?不了解的朋友能够看下本系列的第一篇文章,固然你能够忽略细节,继续往下了解技术原理。
没错,此次我仍然会基于它进行改造。在上一篇添加了manifest后,它已经拥有了本身的桌面图标,并有一个很像Native App的外壳;而今天,我会让它变得更酷。
若是想要跟着文章内容一块儿实践,能够在 这里下载到所需的所有代码。
记得切换到manifest
分支,由于本篇内容,是基于上一篇的最终代码进行相应的开发与升级。毕竟咱们的最终目标是将这个普通的“图书搜索”demo升级为PWA。
注意,咱们的应用始终应该是渐进可用的,在不支持Service Worker的环境下,也须要保证其可用性。要实现这点,能够经过特性检测,在index.js中来注册咱们的Service Worker(sw.js):
// index.js // 注册service worker,service worker脚本文件为sw.js if ('serviceWorker' in navigator) { navigator.serviceWorker.register('./sw.js').then(function () { console.log('Service Worker 注册成功'); }); }
这里咱们将sw.js文件注册为一个Service Worker,注意文件的路径不要写错了。
值得一提的是,Service Worker的各种操做都被设计为异步,用以免一些长时间的阻塞操做。这些API都是以Promise的形式来调用的。因此你会在接下来的各段代码中不断看到Promise的使用。若是你彻底不了解Promise,能够先在这里了解基本的Promise概念:Promise(MDN)和JavaScript Promise:简介。
当咱们注册了Service Worker后,它会经历生命周期的各个阶段,同时会触发相应的事件。整个生命周期包括了:installing --> installed --> activating --> activated --> redundant。当Service Worker安装(installed)完毕后,会触发install事件;而激活(activated)后,则会触发activate事件。
下面的例子监听了install事件:
// 监听install事件 self.addEventListener('install', function (e) { console.log('Service Worker 状态: install'); });
self
是Service Worker中一个特殊的全局变量,相似于咱们最多见的window
对象。self
引用了当前这个Service Worker。
经过上一节,咱们已经学会了如何添加事件监听,来在合适的时机触发Service Worker的相应操做。如今,要使咱们的Web App离线可用,就须要将所需资源缓存下来。咱们须要一个资源列表,当Service Worker被激活时,会将该列表内的资源缓存进cache。
// sw.js var cacheName = 'bs-0-2-0'; var cacheFiles = [ '/', './index.html', './index.js', './style.css', './img/book.png', './img/loading.svg' ]; // 监听install事件,安装完成后,进行文件缓存 self.addEventListener('install', function (e) { console.log('Service Worker 状态: install'); var cacheOpenPromise = caches.open(cacheName).then(function (cache) { return cache.addAll(cacheFiles); }); e.waitUntil(cacheOpenPromise); });
能够看到,首先在cacheFiles
中咱们列出了全部的静态资源依赖。注意其中的'/'
,因为根路径也能够访问咱们的应用,所以不要忘了将其也缓存下来。当Service Worker install时,咱们就会经过caches.open()
与cache.addAll()
方法将资源缓存起来。这里咱们给缓存起了一个cacheName
,这个值会成为这些缓存的key。
上面这段代码中,caches
是一个全局变量,经过它咱们能够操做Cache相关接口。
Cache 接口提供缓存的 Request / Response 对象对的存储机制。Cache 接口像 workers 同样, 是暴露在 window 做用域下的。尽管它被定义在 service worker 的标准中, 可是它没必要必定要配合 service worker 使用。——MDN
到目前为止,咱们仅仅是注册了一个Service Worker,并在其install时缓存了一些静态资源。然而,若是这时运行这个demo你会发现——“图书搜索”这个Web App依然没法离线使用。
为何呢?由于咱们仅仅缓存了这些资源,然而浏览器并不知道须要如何使用它们;换言之,浏览器仍然会经过向服务器发送请求来等待并使用这些资源。那怎么办?
聪明的你应该想起来了,咱们在文章前半部分介绍Service Worker时提到了“客户端代理”——用Service Worker来帮咱们决定如何使用缓存。
下图是一个简单的策略:
fetch
方法向服务端发起请求,并返回请求结果给浏览器// sw.js self.addEventListener('fetch', function (e) { // 若是有cache则直接返回,不然经过fetch请求 e.respondWith( caches.match(e.request).then(function (cache) { return cache || fetch(e.request); }).catch(function (err) { console.log(err); return fetch(e.request); }) ); });
fetch
事件会监听全部浏览器的请求。e.respondWith()
方法接受Promise做为参数,经过它让Service Worker向浏览器返回数据。caches.match(e.request)
则能够查看当前的请求是否有一份本地缓存:若是有缓存,则直接向浏览器返回cache
;不然Service Worker会向后端服务发起一个fetch(e.request)
的请求,并将请求结果返回给浏览器。
到目前为止,运行咱们的demo:当第一联网打开“图书搜索”Web App后,所依赖的静态资源就会被缓存在本地;之后再访问时,就会使用这些缓存而不发起网络请求。所以,即便在无网状况下,咱们彷佛依旧能“访问”该应用。
然而,若是你细心的话,会发现一个小问题:当咱们将资源缓存后,除非注销(unregister)sw.js、手动清除缓存,不然新的静态资源将没法缓存。
解决这个问题的一个简单方法就是修改cacheName
。因为浏览器判断sw.js是否更新是经过字节方式,所以修改cacheName
会从新触发install并缓存资源。此外,在activate事件中,咱们须要检查cacheName
是否变化,若是变化则表示有了新的缓存资源,原有缓存须要删除。
// sw.js // 监听activate事件,激活后经过cache的key来判断是否更新cache中的静态资源 self.addEventListener('activate', function (e) { console.log('Service Worker 状态: activate'); var cachePromise = caches.keys().then(function (keys) { return Promise.all(keys.map(function (key) { if (key !== cacheName) { return caches.delete(key); } })); }) e.waitUntil(cachePromise); return self.clients.claim(); });
到这里,咱们的应用基本已经完成了离线访问的改造。可是,若是你注意到文章开头的图片就会发现,离线时咱们不只能够访问,还可使用搜索功能。
这是怎么回事呢?其实这背后的秘密就在于,这个Web App也会把XHR请求的数据缓存一份。而再次请求时,咱们会优先使用本地缓存(若是有缓存的话);而后向服务端请求数据,服务端返回数据后,基于该数据替换展现。大体过程以下:
首先咱们改造一下前一节的代码在sw.js的fetch
事件里进行API数据的缓存
// sw.js var apiCacheName = 'api-0-1-1'; self.addEventListener('fetch', function (e) { // 须要缓存的xhr请求 var cacheRequestUrls = [ '/book?' ]; console.log('如今正在请求:' + e.request.url); // 判断当前请求是否须要缓存 var needCache = cacheRequestUrls.some(function (url) { return e.request.url.indexOf(url) > -1; }); /**** 这里是对XHR数据缓存的相关操做 ****/ if (needCache) { // 须要缓存 // 使用fetch请求数据,并将请求结果clone一份缓存到cache // 此部分缓存后在browser中使用全局变量caches获取 caches.open(apiCacheName).then(function (cache) { return fetch(e.request).then(function (response) { cache.put(e.request.url, response.clone()); return response; }); }); } /* ******************************* */ else { // 非api请求,直接查询cache // 若是有cache则直接返回,不然经过fetch请求 e.respondWith( caches.match(e.request).then(function (cache) { return cache || fetch(e.request); }).catch(function (err) { console.log(err); return fetch(e.request); }) ); } });
这里,咱们也为API缓存的数据建立一个专门的缓存位置,key值为变量apiCacheName
。在fetch
事件中,咱们首先经过对比当前请求与cacheRequestUrls
来判断是不是须要缓存的XHR请求数据,若是是的话,就会使用fetch
方法向后端发起请求。
在fetch.then
中咱们以请求的URL为key,向cache中更新了一份当前请求所返回数据的缓存:cache.put(e.request.url, response.clone())
。这里使用.clone()
方法拷贝一份响应数据,这样咱们就能够对响应缓存进行各种操做而不用担忧原响应信息被修改了。
若是你跟着作到了这一步,那么恭喜你,距离咱们酷酷的离线应用还差最后一步了!
目前为止,咱们对Service Worker(sw.js)的改造已经完毕了。最后只剩下如何在XHR请求时有策略的使用缓存了,这一部分的改造所有集中于index.js,也就是咱们的前端脚本。
仍是回到上一节的这张图:
和普通状况不一样,这里咱们的前端浏览器会首先去尝试获取缓存数据并使用其来渲染界面;同时,浏览器也会发起一个XHR请求,Service Worker经过将请求返回的数据更新到存储中的同时向前端Web应用返回数据(这一步分就是上一节提到的缓存策略);最终,若是判断返回的数据与最开始取到的cache不一致,则从新渲染界面,不然忽略。
为了是代码更清晰,咱们将本来的XHR请求部分单独剥离出来,做为一个方法getApiDataRemote()
以供调用,同时将其改造为了Promise。为了节省篇幅,我部分的代码比较简单,就不单独贴出了。
这一节最重要的部分实际上是读取缓存。咱们知道,在Service Worker中是能够经过caches
变量来访问到缓存对象的。使人高兴的是,在咱们的前端应用中,也仍然能够经过caches
来访问缓存。固然,为了保证渐进可用,咱们须要先进行判断'caches' in window
。为了代码的统一,我将获取该请求的缓存数据也封装成了一个Promise方法:
function getApiDataFromCache(url) { if ('caches' in window) { return caches.match(url).then(function (cache) { if (!cache) { return; } return cache.json(); }); } else { return Promise.resolve(); } }
而本来咱们在queryBook()
方法中,咱们会请求后端数据,而后渲染页面;而如今,咱们加上基于缓存的渲染:
function queryBook() { // …… // 远程请求 var remotePromise = getApiDataRemote(url); var cacheData; // 首先使用缓存数据渲染 getApiDataFromCache(url).then(function (data) { if (data) { loading(false); input.blur(); fillList(data.books); document.querySelector('#js-thanks').style = 'display: block'; } cacheData = data || {}; return remotePromise; }).then(function (data) { if (JSON.stringify(data) !== JSON.stringify(cacheData)) { loading(false); input.blur(); fillList(data.books); document.querySelector('#js-thanks').style = 'display: block'; } }); // …… }
若是getApiDataFromCache(url).then
返回缓存数据,则使用它先进行渲染。而当remotePromise
的数据返回时,与cacheData
进行比对,只有在数据不一致时须要从新渲染页面(注意这里为了简便,粗略地使用了JSON.stringify()
方法进行对象间的比较)。这么作有两个优点:
至此,咱们完成了PWA的两大基本功能:Web App Manifest和Service Worker的离线缓存。这两大功能能够很好地提高用户体验与应用性能。咱们用Chrome中的Lighthouse来检测一下目前的应用:
能够看到,在PWA评分上,咱们的这个Web App已经很是不错了。其中惟一个扣分项是在HTTPS协议上:因为是本地调试,因此使用了http://127.0.0.1:8085,在生产确定会替换为HTTPS。
随着今年(2018年)年初,Apple在iOS 11.3中开始支持Service Worker,加上Apple一直以来较为良好的系统升级率,整个PWA在兼容性问题上有了重大的突破。
虽然Service Worker中的一些其余功能(例如推送、后台同步)Apple并未表态,可是Web App Manifest和Service Worker的离线缓存是iOS 11.3所支持的。这两大核心功能不只效果拔群,并且目前看来具备还不错的兼容性,很是适合投入生产。
更况且,做为渐进式网页应用,其最重要的一个特色就是在兼容性支持时自动升级功能与体验;而在不支持时,会静默回退部分新功能。在保证咱们的正常服务状况下,尽量利用浏览器特性,提供更优质的服务。
本文中全部的代码示例都可以在learn-pwa/sw-cache上找到。注意在git clone以后,切换到sw-cache分支,本文全部的代码均存在于该分支上。切换其余分值能够看到不一样的版本:
若是你喜欢或想要了解更多的PWA相关知识,欢迎关注我,关注《PWA学习与实践》系列文章。我会总结整理本身学习PWA过程的遇到的疑问与技术点,并经过实际代码和你们一块儿实践。
最后声明一下,文中的代码做为demo,主要是用于了解与学习PWA技术原理,可能会存在一些不完善的地方,所以,不建议直接使用到生产环境。