本文出现的所有代码都可以从[我的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 赞赏作者 扫一扫支付 支付宝支付 微信支付
2025年10月新盘 做第一批吃螃蟹的人coinsrore.com
新车新盘 嘎嘎稳 嘎嘎靠谱coinsrore.com
新车首发,新的一年,只带想赚米的人coinsrore.com
新盘 上车集合 留下 我要发发 立马进裙coinsrore.com
做了几十年的项目 我总结了最好的一个盘(纯干货)coinsrore.com
新车上路,只带前10个人coinsrore.com
新盘首开 新盘首开 征召客户!!!coinsrore.com
新项目准备上线,寻找志同道合的合作伙伴coinsrore.com
新车即将上线 真正的项目,期待你的参与coinsrore.com
新盘新项目,不再等待,现在就是最佳上车机会!coinsrore.com
新盘新盘 这个月刚上新盘 新车第一个吃螃蟹!coinsrore.com
新盘新盘 这个月刚上新盘 新车第一个吃螃蟹!
作者的布局谋篇匠心独运,让读者在阅读中享受到了思维的乐趣。
怎么收藏这篇文章?
想想你的文章写的特别好
博主真是太厉害了!!!