离线可用:
- 在无网的情况下可以访问, 甚至使用部分功能, 而不是展示"无网络连接"的错误页面。
- 在弱网的情况下, 能使用缓存快速访问我们的页面,提升体验。
- 在正常网络的情况下, 可以通过自发各种控制的缓存来节省请求宽带。
- ......
而这一切,其实都要归功于PWA背后的英雄 —— Service Worker。
Service Worker可以简单的理解为一个独立于前端页面,在后台运行的进程(Worker)。因此, 它不会阻塞浏览器脚本运行, 同事也无法直接访问浏览器相关的API(例如DOM、localStorage等)。此外,即便是离开页面, 甚至是关闭浏览器后, 它仍然可以运行。 它就是web应用后默默工作的小蜜蜂, 处理着缓存、推送、通知与同步等工作,如果学习PWA,也绕不开Service Worker。
Service Worker是如何实现离线可用的?
当访问一个web网站时, 我们实际上做了些什么? 总体上来说, 我们通过与服务器建立连接, 获取资源, 然后获取到的部分资源还会去请求新的资源(例如html中使用的css,js等)。粗粒度来说, 我们访问一个网站,就是在获取/访问这些资源。
可想而知, 当处于离线或弱网环境时, 我们无法有效访问这些资源, 这就是制约我们的关键因素。因此一个直观的思路就是: 如果我们把这些资源缓存起来,在某些情况下,将网络请求变为本地访问,是否能解决这一问题?这时就需要有个本地cache, 可以灵活的将各类资源进行本地存取。
有了本地缓存的cache还不够, 还需要有效的使用缓存, 更新缓存与清除缓存, 进一步应用各种个性化的缓存策略。 而这就需要有个能够控制缓存的woker,也就是 Service Worker的部分工作之一。
Service Worker有一个非常重要的特性: 你可以在Service Worker中监听所有的客户端(Web)发出的请求, 然后通过Service Worker来代理, 向服务器发起请求。 通过监听用户请求信息, Service Worker可以决定是都使用缓存来作为Web请求的返回。
普通 网页与 添加了Service Worker的网页对比图如下:
-
普通网页
-
添加Service Worker
注意: 图中虽然将浏览器、SW(Service Worker)与后端服务三者并列放置了, 但实际上浏览器和SW都是运行在本机上的, 所以这个场景下的SW类似一个"客户端代理"。
如何使用Service Worker实现离线可用
1. 注册Service Worker
在index.js来注册Service Worker(sw.js)
//注册Service Worker,脚本为sw.js
if('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js').then(function () {
console.log("service Worker 注册成功");
});
}
Service Worker的各类操作都被设计为异步, 用以避免一些长时间的阻塞操作。 这些API都是以Promise 的形式来调用的。 所以在接下来的各段代码中不断会看到Promise的使用。
2. Service Worker的声明周期
当我们注册Service Worker后, 它会经历生命周期的各个阶段, 同时会触发响应的事件。 整个生命周期包括了: installing -> installed -> activated -> redundant。 当Service Worker安装(installed)完毕后, 会触发install事件;而激活(activated)后, 则会触发activate事件。
写个例子监听install事件:
//监听install
self.addEventListener('install', function () {
console.log("Service Worker当前状态:install");
});
self
是Service Worker中一个特的全局变量,类似于我们常见的Window对象。 self引用了当前这个Service Worker。
3. 缓存静态资源
要使网页离线可用, 就需要将所需资源缓存下来。我们需要一个资源列表, 当Service Worker被激活时, 会将该列表内的资源存进cache。在sw.js中:
//创建一个cacheName
const cacheName = 'cache-0-1-2';
//需要缓存的资源列表
const cacheFiles = [
'/',
'./index.html',
'./index.js',
'./style.css',
'./img/wang.jpeg',
'./img/loading.svg'
];
//监听install事件, 完成安装时, 进行文件缓存
self.addEventListener('install', function (e) {
console.log("Service Worker当前状态:install");
const cacheOpenPromise = caches.open(cacheName).then(function (cache) {
return cache.addAll(cacheFiles);
});
e.waitUntil(cacheOpenPromise);
});
可以看到, 首先cacheFiles
中我们列出了所有的静态资源依赖。注意 '/'
,由于根路径也可以访问页面, 因此不要忘记将其也缓存下来。 当Service Worker install时, 我们就会通过caches.open()
与caches.addAll()
方法将资源缓存起来。这里给缓存起了个名字cacheName
,这个值成为这些缓存的key。
caches
是一个全局变量, 通过它我们可以操作Cache相关接口。
Cache接口提供缓存的Request/Response对象的存储机制。Cache接口向wokers一样, 是保留在window作用域下的。尽管被定义在Service Worker中, 但是它不必一定要配合Service Worker使用。 ——MDN。
4. 使用缓存的静态资源
到目前为止, 我们仅仅注册了一个Service Worker, 并在其install时缓存了一些静态资源。 然而, 如果这是运行网页时会发现无法使用我们的缓存。因为我们仅仅缓存了这些资源, 然而浏览器并不知道需要如何使用它们,换句话说,浏览器仍然会通过向服务器发送请求来等待并使用这些资源。
在文章的前半部分提到了Service Worker 可以做 ”客户端代理“ ——用Service Worker 来帮助如何使用缓存。
下图是一个简单的策略:
-
浏览器有cache时:
-
浏览器无cache时:
- 浏览器发起请求, 请求各类静态资源(html,css,img
- Service Worker拦截浏览器请求, 并查询当前cache
- 若存在cache则直接返回,结束
- 若不存在cache,则通过
fetch
方法向服务器端发起请求, 并返回请求结果给浏览器
//cache存在则使用cache,无cache则fetch服务器端请求资源
self.addEventListener('fetch', function (e) {
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.responedWith()
方法接受Promise作为参数,通过它让Service Worker向浏览器返回数据。caches.match(e.request)
则可以查看当前的请求是否有一份本地缓存, 如果有缓存,则直接向浏览器返回cache
。如果没有,则Service Worker 会向后端服务发起一个fetch(e.request)
的请求, 并将请求返回给浏览器。
到目前为止, 我们的网页静态资源将会被缓存到本地;以后再访问时, 就会使用这些缓存而不发起网络请求,因此无网的情况下,依旧能访问该网页。
5. 更新静态缓存资源
当缓存可以使用时,细心的话会发现一个小问题, 当我们将资源缓存后,除非注销(unregister)sw.js、手动清除缓存、否则新的静态资源将无法缓存。
解决这个问题的一个简单方法就是修改cacheName
。由于浏览器判断sw.js是否更新是通过字节方式, 因此修改cacheName
会重新出发install并缓存资源。 此外,在activate事件中, 我们需要检查cacheName
是否变化,如果变化则表示有了新的缓存,原有缓存需要删除。
//监听activite事件, 激活后通过cache的key来判断是否需要更新cache中的静态资源
self.addEventListener('activate',function (e) {
console.log('Service Worker 当前状态: activate');
const 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(); //激活触发Service Worker
});
6.缓存离线接口##
离线时,我们发现只展示了静态资源,而接口数据渲染的地方还是空白一片。
所以我们把XHR也缓存一份, 然后再请求时, 会优先使用本地缓存, 然后再向服务器端请求数据。大致过程如下:
首先需要改造一下sw.js的fetch事件进行API数据的缓存:
//定义api缓存name
const apiCacheName = 'api-0-1-1';
// cache存在则使用cache,无cache则fetch服务器端请求资源
self.addEventListener('fetch', function (e) {
// 需要缓存的XHR请求
const cacheRequestUrls = [
'/book?'
];
console.log('当前请求接口:' + e.request.url);
//判断当前请求是否需要缓存
const needCacheXhr = cacheRequestUrls.some(function (url) {
return e.request.url.indexOf(url) > -1;
});
if(needCacheXhr) {
// 使用fetch请求数据, 并将请求结果clone一份缓存到cache
// 缓存后在browser中使用全局变量caches获取
caches.open(apiCacheName).then(function (cache) {
return fetch(e.request).then(function (res) {
cache.put(e.request.url, res.clone());
return res;
})
})
}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, res.clone())
。这里使用.clone()
方法拷贝一份响应数据, 这样就可以对响应缓存进行各类操作而不用担心响应原数据被修改了。
7.使用离线XHR数据, 完成离线渲染,提升响应速度
目前为止, 我们已经对Service Worker(sw.js) XHR进行了缓存改造, 最后只剩如何在XHR请求时有策略的使用缓存了, 这一部分的改造几种在index.js, 也就是我们的前端脚本。
先看看XHR缓存的这张图:
和普通情况不同, 前端浏览器会首先尝试获取缓存数据并使用其渲染页面。同时, 浏览器也会发起一个XHR请求,Service Worker 通过将请求返回的数据更新到缓存中的同事向前端页面返回数据(这一部分主要就是缓存策略);最终,如果判断返回的数据与最开始取到的cache不一致,则重新渲染界面, 否则忽略。
为了使代码更加清晰,我们将原来XHR请求部分单独剥离出来,作为一个方法 getApiDataRemote()
使用, 同事将其改造为Promise。
我们知道,在Service Worker 中是可以通过caches
变量来访问到缓存对象的。 而在前端应用中,也仍然可以通过caches
来访问缓存。 为了统一代码, 将获取该请求的缓存数据也封装为Promise方法getApiDataFromCache()
。
// 前端页面中
function getApiDataRemote(url) {
if('caches' in window) {
return caches.match(url).then(function (cache) {
if(!cache) {
return;
}
return cache.json();
})
}else {
return Promise.resolve();
}
}
而原本在请求接口queryData()
方法中, 我们会请求后端数据, 然后再渲染页面;而现在则加上缓存的渲染:
···
function queryBook(value) {
var input = document.querySelector('#js-search-input');
var query = value || input.value;
var url = '/book?q=' + query;
//请求缓存
var remotePromise = getApiDataRemote(url);
var cacheData;
//先使用缓存数据渲染
getApiDataFromCache(url).then(data => {
if(data) {
loading(false);
input.blur();
fillList(data.data.songs);
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.data.songs);
document.querySelector('#js-thanks').style = 'display: block';
}
})
}
如果getApiDataFromCache(url).then
返回缓存数据, 则使用它进行先渲染。 当getApiDataRemote()
返回的数据时, 与cacheData
进行比对, 只有数据不一致的时候重新渲染页面即可。 这里使用 JSON.stringify()
进行粗略的比较,这么做有两个优势:
- 离线可用。 如果我们之前访问过某些URL,那么及时在离线的情况下, 重复响应的操作依然可以正常显示页面。
- 优化体验,提高访问速度读取本地cache耗时相对对网络请求时间很低,因此就会有一种 "秒开", "秒响应"的感觉。