本文出现的所有代码都可以从[我的git地址][1]中找到,如果对你有帮助,还请点点`star`,欢迎提出您宝贵的意见。 你也可以在此[狠狠地尝试Demo][2](记得打开谷歌开发者工具查看NetWork) ### 背景 先前已经在我的另一篇文章:[浏览器缓存策略详解][3]中提到了`Service Worker`,其实它是一个特别大的概念,我们今天就来稍微深入地学习一下他的`cacheStorage`缓存功能。相信大家都听说过PWA(Progressive Web APP)——渐进式网页;致力于实现与原生 APP 相似的交互体验。PWA总体具有以下特点:(以下特点来自[知乎][4]) - **渐进式**:适用于选用任何浏览器的所有用户,因为它是以渐进式增强作为核心宗旨来开发的。 - **自适应**:适合**任何机型**:桌面设备、移动设备、平板电脑或任何未来设备。 - **连接无关性**:能够借助于服务工作线程在**离线**或低质量网络状况下工作。 - **离线推送**:使用推送消息通知,能够让我们的应用像`Native App`一样,提升用户体验。 - **及时更新**:在服务工作线程更新进程的作用下时刻保持最新状态。 - **安全性**:通过 HTTPS 提供,以防止窥探和确保内容不被篡改。 总结下来,PWA的实现其实主要依赖于以下三点: 1. `manifest` 实现手机主界面的`web app`图标、添加进桌面、标题、`icon`等; 2. `Service Worker`实现离线缓存请求、更新缓存、删除缓存;(用插件实现文件更新即版本号更新从而缓存更新) 3. 前端`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`进一步发展后的产物。**`SW`也是运行在浏览器背后的独立线程,**主要用于代理网页请求,可缓存请求结果;可实现离线缓存功能;可跨页面通信。也拥有单独的作用域范围和运行环境 ![浏览器进程.jpg][5] ### Service Worker的特点 在`Service Worker`诞生之前,`Web Worker`就已经“服役”很久了,他们都是独立于js线程外的线程,但是`Web Worker`有个特点就是:当网页关闭时,`Web Worker`就失效了,而`Service Worker`的诞生就是为了解决这个问题的,它具有以下特点: 1. 一旦被`install`,就永远存在,除非被手动`unregister`。 2. 拥有自己独立的**worker线程**,独立于当前网页进程,有自己独立的**worker上下文**(context)。 3. 用到的时候就可以直接唤醒,不用的时候自动睡眠。 4. 可拦截代理`fetch`请求和响应,不支持`xmlHttpRequest`请求。 5. 可操作缓存文件,且缓存文件可以被网页进程取到(包括网路离线状态)。 6. 能向客户推送消息。 7. 不能直接操作`DOM`、`window`、`parent`等 。(但是它有自己的**`self`**对象来代替`window`) 8. 必须在`HTTPS`环境下才能工作。(本地调试可以用`localhost`) 9. 异步实现,内部大都是通过`Promise`实现,以防止浏览器卡顿。**所以`Service Worker`的各类操作都被设计为异步**,我们在调用的时候要**使用`Promise`语法**。 ### Service Worker的生命周期 当我们注册了`Service Worker`后,它会经历生命周期的各个阶段,同时会触发相应的事件。整个生命周期包括了:`installing` --> `installed` --> `activating` --> `activated` --> `redundant`。当`Service Worker`**installed**完毕后,会触发**`install`**事件;而**activated**完毕后,则会触发**`activate`**事件。 ![生命周期.jpg][6] `Service Worker`同时提供了事件监听函数对这些状态进行捕获,例如: ``` self.addEventListener('install', function(event) { /* 安装后... */ }); self.addEventListener('activate', function(event) { /* 激活后... */ }); self.addEventListener('fetch', function(event) { /* 请求后... */ }); //用来响应和拦截各种请求。 ``` 基本上,`Service Worker`的所有应用都是基于上面3个事件的,例如,我们接下来的实战内容。`install`用来缓存文件,`activate`用来缓存更新,`fetch`用来拦截请求直接返回缓存数据。三者齐心,构成了完成的离线缓存控制结构。 ### 实战1. 创建项目, 项目目录结构如下: ``` ├── 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`中随便写点内容: ``` HTTPS - Learn SW OffLine 图片1: 图片2: Service Worker注册: ``` ### 实战2. 注册`Service Worker` 如果注册成功,`Service Worker`就会被下载到客户端并尝试安装或激活,这将作用于整个域内用户可访问的URL,或者其特定子集。这里我们将sw.js文件注册为一个Service Worker,注意文件的路径不要写错了。 ``` navigator.serviceWorker.register(url, options); //url:service worker文件的路径,路径是相对于 Origin ,而不是当前文件的目录的 //options: scope:表示定义service worker注册范围的URL; //默认值是基于当前的location(./),并以此来解析传入的路径。 //假设你的sw文件放在根目录下位于/src/sw.js路径的话,那么你的sw就只能监听/src/*下面的请求。 //如果想要监听所有请求有两个办法,一个是将sw.js放在根目录下,或者是在注册是时候设置scope。 ``` ``` // 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`。 ``` //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文档][7] ### 实战4:使用缓存的静态资源 到目前为止,我们仅仅是注册了一个`Service Worker`,并在其`install`时缓存了一些静态资源,但我们还没有使用这些缓存下来的资源。那么要如何才能使用呢?答案是拦截`fetch`: 1. 浏览器发起请求,请求各类静态资源(`html`/`js`/`css`/`img`)。 2. `Service Worker`拦截浏览器请求,并查询当前cache。 3. 若存在`cache`则直接返回,结束。 4. 若不存在`cache`,则通过`fetch`方法向服务端发起请求,并返回请求结果给浏览器。 ``` // 拦截所有请求事件 // 如果缓存中已经有数据就直接用缓存,否则去请求数据 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`: 1. 当一次打开网页时,所依赖的静态资源就会被缓存在本地; 2. 刷新浏览器,在`network`选项中可以看到缓存内容的请求已经被拦截了,从sw缓存中获取了。 3. 在`chrome`控制台中,把`network`状态改成`offline`,再次刷新浏览器,虽然没网,但是还是可以从本地缓存读取内容。 第一次进入页面和第二次进入页面,如图: ![NetWork1.jpg][8] ![NetWork2.jpg][9] 普通情况离线加载和使用`Service Worker`后离线加载,如图: ![NetWork3.jpg][10] ![NetWork4.jpg][11] ### 实战5:更新静态缓存资源 #### 更新sw.js 然而,一旦我们将资源缓存后,除非注销(**unregister**)`Service Worker`或者手动清除缓存,否则新的静态资源将无法缓存。在仅有上述代码的情况下,我们修改`sw.js`,我们会发现,在上个`Service Worker`的有效时长内:**浏览器用的永远是上一次缓存下来的sw.js**。 解决这个问题的一个简单方法就是修改`CACHE_NAME`。由于浏览器判断`sw.js`是否更新是通过字节方式,因此修改`CACHE_NAME`会重新触发`install`并缓存资源。此外,在`activate`事件中,我们需要检查`CACHE_NAME`是否变化,如果变化则表示有了新的缓存资源,原有缓存需要删除。 ``` //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`事件在更新我们的缓存文件。 ![NetWork8.jpg][12] #### 更新app.html 在上面的流程中,我们使用`skipWaiting`完成了`sw.js`的更新,当**下一次**用户访问Web时候,则直接获取并使用新的缓存。但这时还存在着一个很重要的问题:用户**这一次**访问的Web网页,还是上一次缓存中的Web网页! 我们此时修改`app.html` ``` //app.html添加 我更新啦! ``` 在更改`sw.js`中的`CACHE_NAME`为`tuland-3` ``` //sw.js const CACHE_NAME = "tuland-3"; //修改此值可以强制更新缓存 可以用版本控制工具自动更新 ``` 刷新浏览器,新的`Service Worker`已经激活了,可是页面上还是之前的内容,**再次刷新才能出现“我更新啦!”**。 ![NetWork9.jpg][13] ![NetWork10.jpg][14] #### 解决方案: 这边会有很多解决方案,我在`demo`里使用的是让`主线程`和`Service Worker`互相通信,实现弹窗来通知用户刷新页面: ``` //app.html ... 检查到网页存在更新,请立即刷新 确定 ``` ``` //index.js navigator.serviceWorker.onmessage = function (event) { var data = event.data; if (data.command == "reload") { console.log(data); const myDialog = document.querySelector("#myDialog"); myDialog.showModal(); } }; ``` ``` //sw.js 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); //TODO:告诉用户需要重新刷新 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(); }) ); }); ``` ![NetWork11.jpg][15] 这种方法虽然可行,但是通知用户刷新浏览器结果并不可控。而如果直接刷新页面,又显得太暴力,从而让用户体验非常差。知乎的这位老哥也遇到了诸如此类的问题,它最终是选择了[拦截fetch并比较url标识的方法][16] 但我们在经过搜查资料和组内讨论后,最终得出了一个方案:**在`Service Worker`控制的页面中,优先使用在线资源,`Service Worker`充当面向客户端的代理服务器角色;当在线资源获取出错(服务器宕机,网络不可用等情况),则使用``Service Worker``本地缓存。** 私以为这是比较可靠的。 ### 总结: 本文出现的所有代码都可以从[我的git地址][17]中找到,如果对你有帮助,还请点点`star`,欢迎提出您宝贵的意见。 截止到目前,就算浅浅完成了一个可以实现离线缓存的`demo`了,但要当成PWA上线,还需要非常多的工作,比如每次都修改`CACHE_NAME`是不现实的,我们要结合`webpack`实现自动打包生成资源号;比如我们还有CDN,还有推送消息功能,甚至包括`Service Worker`本身,我们可以挖掘的东西也还有很多,这些都留着下次再细细讲吧。 [1]: https://github.com/232295311/learn-sw [2]: https://232295311.github.io/learn-sw/app.html [3]: http://120.25.166.245/index.php/archives/6/ [4]: https://zhuanlan.zhihu.com/p/144512343 [5]: http://120.25.166.245/usr/uploads/2022/05/1898033692.jpg [6]: http://120.25.166.245/usr/uploads/2022/05/1891590781.jpg [7]: https://developer.mozilla.org/zh-CN/docs/Web/API/CacheStorage/keys [8]: http://120.25.166.245/usr/uploads/2022/05/2755679102.jpg [9]: http://120.25.166.245/usr/uploads/2022/05/2657161385.jpg [10]: http://120.25.166.245/usr/uploads/2022/05/412486908.jpg [11]: http://120.25.166.245/usr/uploads/2022/05/169326627.jpg [12]: http://120.25.166.245/usr/uploads/2024/03/1641758470.jpg [13]: http://120.25.166.245/usr/uploads/2024/03/3939313176.jpg [14]: http://120.25.166.245/usr/uploads/2024/03/3200581011.jpg [15]: http://120.25.166.245/usr/uploads/2024/03/4270045727.jpg [16]: https://zhuanlan.zhihu.com/p/680932147 [17]: https://github.com/232295311/learn-sw 最后修改:2024 年 03 月 05 日 01 : 19 PM © 著作权归作者所有 赞赏 如果觉得我的文章对你有用,请随意赞赏 ×Close 赞赏作者 扫一扫支付 支付宝支付 微信支付
作者的布局谋篇匠心独运,让读者在阅读中享受到了思维的乐趣。
怎么收藏这篇文章?
想想你的文章写的特别好
博主真是太厉害了!!!