本文出现的所有代码都可以从我的git地址中找到,如果对你有帮助,还请点点star
,欢迎提出您宝贵的意见。 你也可以在此狠狠地尝试Demo(记得打开谷歌开发者工具查看NetWork)
背景
先前已经在我的另一篇文章:浏览器缓存策略详解中提到了Service Worker
,其实它是一个特别大的概念,我们今天就来稍微深入地学习一下他的cacheStorage
缓存功能。相信大家都听说过PWA(Progressive Web APP)------渐进式网页;致力于实现与原生 APP 相似的交互体验。PWA总体具有以下特点:(以下特点来自知乎)
- 渐进式:适用于选用任何浏览器的所有用户,因为它是以渐进式增强作为核心宗旨来开发的。
- 自适应 :适合任何机型:桌面设备、移动设备、平板电脑或任何未来设备。
- 连接无关性 :能够借助于服务工作线程在离线或低质量网络状况下工作。
- 离线推送 :使用推送消息通知,能够让我们的应用像
Native App
一样,提升用户体验。 - 及时更新:在服务工作线程更新进程的作用下时刻保持最新状态。
- 安全性:通过 HTTPS 提供,以防止窥探和确保内容不被篡改。
总结下来,PWA的实现其实主要依赖于以下三点:
manifest
实现手机主界面的web app
图标、添加进桌面、标题、icon
等;Service Worker
实现离线缓存请求、更新缓存、删除缓存;(用插件实现文件更新即版本号更新从而缓存更新)- 前端
registration
实现用户订阅,后端web-push
实现消息推送,前端Service Woker
监听push
实现消息通知,但是Chrome需要能够连接到外网,因为push用的是谷歌的云服务。
这里就主要谈谈如何使用Service Worker
实现离线缓存:
什么是Service Worker?
众所周知, js 是被设计为单线程语言,因为主要用途是与用户互动和操作DOM
,单线程设计可以简化并发问题,避免多线程并发时的竞态条件、死锁和其他问题。但单线程存在的问题是,GUI线程 和js线程 需要抢占资源,在 js 执行比较耗时的逻辑时,容易造成页面假死,用户体验较差。后来html5
开放了Web Worker
可以在浏览器后台挂载新线程。它无法直接操作DOM
,无法访问window
、document
等对象。而Service Worker
可以说是Web Worker
进一步发展后的产物。Service Worker
也是运行在浏览器背后的独立线程 ,主要用于代理网页请求,可缓存请求结果;可实现离线缓存功能;可跨页面通信。也拥有单独的作用域范围和运行环境
Service Worker的特点
在Service Worker
诞生之前,Web Worker
就已经"服役"很久了,他们都是独立于js线程外的线程,但是Web Worker
有个特点就是:当网页关闭时,Web Worker
就失效了,而Service Worker
的诞生就是为了解决这个问题的,它具有以下特点:
- 一旦被
install
,就永远存在,除非被手动unregister
。 - 拥有自己独立的worker线程 ,独立于当前网页进程,有自己独立的worker上下文(context)。
- 用到的时候就可以直接唤醒,不用的时候自动睡眠。
- 可拦截代理
fetch
请求和响应,不支持xmlHttpRequest
请求。 - 可操作缓存文件,且缓存文件可以被网页进程取到(包括网路离线状态)。
- 能向客户推送消息。
- 不能直接操作
DOM
、window
、parent
等 。(但是它有自己的**self
**对象来代替window
) - 必须在
HTTPS
环境下才能工作。(本地调试可以用localhost
) - 异步实现,内部大都是通过
Promise
实现,以防止浏览器卡顿。所以Service Worker
的各类操作都被设计为异步 ,我们在调用的时候要使用Promise
语法。
Service Worker的生命周期
当我们注册了Service Worker
后,它会经历生命周期的各个阶段,同时会触发相应的事件。整个生命周期包括了:installing
--> installed
--> activating
--> activated
--> redundant
。当Service Worker
installed 完毕后,会触发**install
事件;而 activated完毕后,则会触发 activate
**事件。
Service Worker
同时提供了事件监听函数对这些状态进行捕获,例如:
javascript
self.addEventListener('install', function(event) { /* 安装后... */ });
self.addEventListener('activate', function(event) { /* 激活后... */ });
self.addEventListener('fetch', function(event) { /* 请求后... */ }); //用来响应和拦截各种请求。
基本上,Service Worker
的所有应用都是基于上面3个事件的,例如,我们接下来的实战内容。install
用来缓存文件,activate
用来缓存更新,fetch
用来拦截请求直接返回缓存数据。三者齐心,构成了完成的离线缓存控制结构。
实战1. 创建项目,
项目目录结构如下:
css
├── README.md
├── app.html
├── sw.js
├── src
│ └── index.js
├── assets
│ └── css
│ └── style.css
│ └── images
│ └── background1.jpg
│ └── background2.jpg
先在app.html
中引入图片资源,index.js
和style.css
并在html
中随便写点内容:
xml
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HTTPS - Learn SW OffLine</title>
<link rel="stylesheet" href="./assets/css/style.css" />
</head>
<body>
<p>
图片1:<img
src="./assets/images/background1.jpg"
alt="image1"
width="512"
height="256"
/>
</p>
<p>
图片2:<img
src="./assets/images/background2.jpg"
alt="image2"
width="512"
height="256"
/>
</p>
<p>Service Worker注册:<span class="text" id="register"></span></p>
<script src="./src/index.js" type="text/javascript"></script>
</body>
</html>
实战2. 注册Service Worker
如果注册成功,Service Worker
就会被下载到客户端并尝试安装或激活,这将作用于整个域内用户可访问的URL,或者其特定子集。这里我们将sw.js文件注册为一个Service Worker,注意文件的路径不要写错了。
ruby
navigator.serviceWorker.register(url, options);
//url:service worker文件的路径,路径是相对于 Origin ,而不是当前文件的目录的
//options: scope:表示定义service worker注册范围的URL;
//默认值是基于当前的location(./),并以此来解析传入的路径。
//假设你的sw文件放在根目录下位于/src/sw.js路径的话,那么你的sw就只能监听/src/*下面的请求。
//如果想要监听所有请求有两个办法,一个是将sw.js放在根目录下,或者是在注册是时候设置scope。
javascript
// index.js
window.addEventListener("load", () => {
if (navigator.serviceWorker) {
navigator.serviceWorker
// scope是自定义sw的作用域范围为根目录,默认作用域为当前sw.js所在目录的页面
.register("./sw.js", { scope: "./" })
.then(function (registration) {
// 注册成功后会返回registration对象,指代当前服务线程实例
document.getElementById("register").innerHTML = "成功!";
})
.catch(function (err) {
console.error(e);
document.getElementById("register").innerHTML = "失败!";
});
} else {
console.log("当前浏览器不支持service worker");
}
});
实战3. 缓存静态资源
注册完Service Worker
后,下一步就是把我们需要缓存的文件缓存下来。我们需要添加事件监听 ,来在合适的时机触发Service Worker
的相应操作。现在,要使我们的Web离线可用,就需要将所需资源缓存下来。我们需要一个资源列表,当Service Worker
被激活时,会将该列表内的资源缓存进cache
。
javascript
//sw.js
// 定义缓存空间名称
const CACHE_NAME = "tuland-1"; //修改此值可以强制更新缓存
// 定义需要缓存的文件目录
const FILE_TO_CACHE= [
"./app.html",
"./src/index.js",
"./assets/css/style.css",
"./assets/images/background1.jpg",
"./assets/images/background2.jpg",
];
// 监听install事件,回调中缓存所需文件
self.addEventListener("install", (e) => {
console.log("Service Worker 状态: instal");
e.waitUntil(
// cacheStorage API 可直接用caches来替代
// open方法创建/打开缓存空间,并会返回promise实例
// then来接收返回的cache对象索引
caches.open(CACHE_NAME).then(function (cache) {
// cache对象addAll方法解析(同fetch)并缓存所有的文件
return cache.addAll(FILE_TO_CACHE);
})
);
});
可以看到,首先在FILE_TO_CACHE
中我们列出了所有的静态资源依赖。当Service Worker install
时,我们就会通过caches.open()
与cache.addAll()
方法将资源缓存起来。open(CACHE_NAME)
这里CACHE_NAME
会成为这些缓存的key值。 上面这段代码中,caches是一个全局变量,通过它我们操作的其实是CacheStorage
相关接口。CacheStorage MDN文档
实战4:使用缓存的静态资源
到目前为止,我们仅仅是注册了一个Service Worker
,并在其install
时缓存了一些静态资源,但我们还没有使用这些缓存下来的资源。那么要如何才能使用呢?答案是拦截fetch
:
- 浏览器发起请求,请求各类静态资源(
html
/js
/css
/img
)。 Service Worker
拦截浏览器请求,并查询当前cache。- 若存在
cache
则直接返回,结束。 - 若不存在
cache
,则通过fetch
方法向服务端发起请求,并返回请求结果给浏览器。
javascript
// 拦截所有请求事件
// 如果缓存中已经有数据就直接用缓存,否则去请求数据
self.addEventListener("fetch", (e) => {
console.log("处理fetch事件:", e.request.url);
e.respondWith(
caches
.match(e.request)
.then(function (response) {
if (response) {
console.log("缓存匹配到res:", response.url);
return response;
}
console.log("缓存未匹配对应request,准备从network获取", caches);
return fetch(e.request);
})
.catch((err) => {
console.error(err);
return fetch(e.request);
})
);
});
fetch事件会监听所有浏览器的请求。e.respondWith()
方法接受Promise作为参数,通过它让Service Worker
向浏览器返回数据。caches.match(e.request)
则可以查看当前的请求是否有一份本地缓存:如果有缓存,则直接向浏览器返回cache
;否则Service Worker
会向后端服务发起一个fetch(e.request)
的请求,并将请求结果返回给浏览器。
到目前为止,运行我们的demo
:
- 当一次打开网页时,所依赖的静态资源就会被缓存在本地;
- 刷新浏览器,在
network
选项中可以看到缓存内容的请求已经被拦截了,从sw缓存中获取了。 - 在
chrome
控制台中,把network
状态改成offline
,再次刷新浏览器,虽然没网,但是还是可以从本地缓存读取内容。
第一次进入页面和第二次进入页面,如图:
普通情况离线加载和使用`Service Worker`后离线加载,如图:
实战5:更新静态缓存资源
更新sw.js
然而,一旦我们将资源缓存后,除非注销(unregister )Service Worker
或者手动清除缓存,否则新的静态资源将无法缓存。在仅有上述代码的情况下,我们修改sw.js
,我们会发现,在上个Service Worker
的有效时长内:浏览器用的永远是上一次缓存下来的sw.js 。 解决这个问题的一个简单方法就是修改CACHE_NAME
。由于浏览器判断sw.js
是否更新是通过字节方式,因此修改CACHE_NAME
会重新触发install
并缓存资源。此外,在activate
事件中,我们需要检查CACHE_NAME
是否变化,如果变化则表示有了新的缓存资源,原有缓存需要删除。
javascript
//sw.js
const CACHE_NAME = "tuland-2"; //修改此值可以强制更新缓存
...
this.addEventListener("install", (event) => {
this.skipWaiting();// 强制更新sw.js
...
})
// 监听active事件
self.addEventListener("activate", (event) => {
// 获取所有的缓存key值,将需要被缓存的路径加载放到缓存空间下
const cacheDeletePromise = caches.keys().then((keyList) => {
console.log("keyList:", keyList);
Promise.all(
keyList.map((key) => {
if (key !== CACHE_NAME) {
const deletePromise = caches.delete(key);
return deletePromise;
} else {
Promise.resolve();
}
})
);
});
// 等待所有的缓存都被清除后,直接启动新的缓存机制
event.waitUntil(
Promise.all([cacheDeletePromise]).then((res) => {
this.clients.claim();
})
);
});
我们添加skipWaiting()
,同时把缓存CACHE_NAME
改为tuland-2
,再次刷新浏览器。查看控制台log
,新的Service Worker安装完成之后立即被激活了,我们也可以看到activate
事件在更新我们的缓存文件。
更新app.html
在上面的流程中,我们使用skipWaiting
完成了sw.js
的更新,当下一次 用户访问Web时候,则直接获取并使用新的缓存。但这时还存在着一个很重要的问题:用户这一次访问的Web网页,还是上一次缓存中的Web网页!
我们此时修改app.html
less
//app.html添加
<p>我更新啦!</p>
在更改sw.js
中的CACHE_NAME
为tuland-3
objectivec
//sw.js
const CACHE_NAME = "tuland-3"; //修改此值可以强制更新缓存 可以用版本控制工具自动更新
刷新浏览器,新的Service Worker
已经激活了,可是页面上还是之前的内容,再次刷新才能出现"我更新啦!" 。
解决方案:
这边会有很多解决方案,我在demo
里使用的是让主线程
和Service Worker
互相通信,实现弹窗来通知用户刷新页面:
javascript
//app.html 添加对话框
...
<dialog class="dialog" id="myDialog">
<div class="dialogContent">
<p>检查到网页存在更新,请立即刷新</p>
<button type="submit" onclick="location.reload()">确定</button>
</div>
</dialog>
ini
//index.js 主线程监听onMessage
navigator.serviceWorker.onmessage = function (event) {
var data = event.data;
if (data.command == "reload") {
console.log(data);
const myDialog = document.querySelector("#myDialog");
myDialog.showModal();
}
};
javascript
//sw.js sw对所有客户发送消息
...
self.addEventListener("activate", (event) => {
// 获取所有的缓存key值,将需要被缓存的路径加载放到缓存空间下
const cacheDeletePromise = caches.keys().then((keyList) => {
console.log("keyList:", keyList);
Promise.all(
keyList.map((key) => {
if (key !== CACHE_NAME) {
const deletePromise = caches.delete(key);
//告诉用户需要重新刷新
console.log("need reload");
self.clients.matchAll().then(function (clients) {
clients.forEach(function (client) {
client.postMessage({
command: "reload",
message: "blablablablabla",
});
});
});
return deletePromise;
} else {
Promise.resolve();
}
})
);
});
// 等待所有的缓存都被清除后,直接启动新的缓存机制
event.waitUntil(
Promise.all([cacheDeletePromise]).then((res) => {
this.clients.claim();
})
);
});
这种方法虽然可行,但是通知用户刷新浏览器结果并不可控。而如果直接刷新页面,又显得太暴力,从而让用户体验非常差。知乎的这位老哥也遇到了诸如此类的问题,它最终是选择了拦截fetch并比较url标识的方法
但我们在经过搜查资料和组内讨论后,最终得出了一个方案:在Service Worker
控制的页面中,优先使用在线资源,Service Worker
充当面向客户端的代理服务器角色;当在线资源获取出错(服务器宕机,网络不可用等情况),则使用Service Worker
本地缓存。 私以为这是比较可靠的。
总结:
本文出现的所有代码都可以从我的git地址中找到,如果对你有帮助,还请点点star
,欢迎提出您宝贵的意见。
截止到目前,就算浅浅完成了一个可以实现离线缓存的demo
了,但要当成PWA上线,还需要非常多的工作,比如每次都修改CACHE_NAME
是不现实的,我们要结合webpack
实现自动打包生成资源号;比如我们还有CDN,还有推送消息功能,甚至包括Service Worker
本身,我们可以挖掘的东西也还有很多,这些都留着下次再细细讲吧。