Service Worker缓存策略实战:从零打造离线可用的网页

发布时间:2026/10/10 3:49:31
Service Worker缓存策略实战:从零打造离线可用的网页
不知道你有没有遇到过这种场景地铁里信号不好网页转了半天最后只看到一个“无法连接到互联网”偏偏你想看的内容是之前已经打开过的。以前我也觉得离线访问是大型应用才敢想的能力直到自己动手试了一次才发现普通的 HTML 页面完全可以通过 Service Worker 缓存策略实现“断网也能打开”。核心思路并不难在浏览器和网络之间塞一个本地代理程序把页面依赖的静态资源提前“藏”一份在本地等网络断开时从本地直接取出来响应。这篇文章写给已经写过几行 HTML/CSS/JS、想给自己的静态页面加上离线能力但一直没太搞懂 Service Worker 怎么用的朋友。我会从生命周期讲到四种常见缓存策略再完整手搓一个离线 Demo最后把那些文档里不会写清楚、实测很容易翻车的细节一并拆开说透。不依赖任何框架和构建工具纯文本编辑器加上一个简单的本地服务器就能跑起来。1. 为什么网页一离线就“死”HTTP 缓存管不住这件事1.1 浏览器自带的 HTTP 缓存性能优化能手但不是离线救世主做前端的人大概没有人不知道 HTTP 缓存。服务器吐出资源时带上Cache-Control、Expires之类的响应头浏览器觉得资源新鲜就直接用本地副本不发请求一旦资源过期浏览器就要发请求去服务器确认资源有没有变化。这套机制对性能优化非常重要但它的决策逻辑从根上就不是为离线设计的。离线意味着什么意味着连请求都发不出去。HTTP 缓存算法里那些“过期后重新验证”“需要协商缓存”的路径全部失效。还有一个更关键的痛点HTTP 缓存放权在浏览器手里开发者只能通过响应头间接影响它的行为根本没有程序化的接口让你写一段逻辑说“这个请求走缓存、那个请求优先网络、那个请求永不缓存”。它不是一个可供编程控制的存储系统更像一个黑盒里面装着一堆浏览器自己的判断规则。我见过很多人以为“离线应用就是把静态资源的 Cache-Control 调大一点”实际操作后会发现断网时页面依然打不开。原因很简单资源即使命中了磁盘缓存页面导航请求在离线的状态下也可能直接失败而且浏览器对 HTML 入口的缓存态度非常谨慎很多情况下它宁可展示错误页也不愿意拿缓存里可能过期的 HTML 来渲染。1.2 Service Worker本质上是给浏览器装了一个“本地代理”Service Worker 的出现把权力的天平往开发者这边拉了一大截。它是一个独立于页面存在的 JavaScript 工作线程页面关了它还能继续活着下次打开页面时它已经在后台待命。它能够拦截受控页面发出的网络请求并且配合 Cache Storage 这个可编程的存储空间实现真正由开发者掌控的缓存读写。打个比方以前网络请求的路线是“页面 - 服务器”中间浏览器自己决定要不要用缓存有了 Service Worker 之后路线变成了“页面 - Service Worker - 服务器”这个本地代理收到请求后是直接回缓存还是去网络上拉取、拉完再更新缓存全由你脚本里写的逻辑说了算。这就是“缓存策略”四个字背后的核心价值。需要提前确认两个硬性条件不然后面所有代码都是白写Service Worker 只能在安全上下文里运行生产环境必须是 HTTPS不过本地的localhost和127.0.0.1属于例外本地开发可以直接跑。Service Worker 有一个“作用范围”scope的概念它只能控制自己所在目录下的请求。这就是为什么几乎所有实践教程都建议你把sw.js放在站点根目录而不是扔进某个子目录里。理解这几点之后可以进入下一步搞懂一个 Service Worker 从注册到接管请求中间到底经历了什么。2. 动手写缓存之前先搞懂 Service Worker 的生命周期2.1 注册、安装、激活三个阶段各干了什么Service Worker 有一套非常明确的生命周期你会接触到三个核心事件install、activate、fetch。很多人第一次写的时候就犯了一个错误以为注册完函数就能接管页面了实际完全不是这样。一个 Service Worker 从出生到掌权典型路径是这样的你在页面里调用navigator.serviceWorker.register()浏览器开始下载sw.js文件。脚本下载完成并且是首次安装时触发install事件。这个事件里通常会做预缓存把页面依赖的静态资源一次性放进 Cache Storage。install事件里的event.waitUntil()返回的 Promise 完成表示安装成功如果 Promise 被拒绝比如某个资源下载失败这个 Service Worker 会直接进入redundant状态整个注册作废。安装成功后进入activate事件。这里是清理旧版本缓存的地方。激活完成后理论上 Service Worker 才算“上岗”可以开始拦截请求。这里有一个最反直觉的地方新注册的 Service Worker 不会控制当前这个页面即使它已经激活成功也不行。因为当前页面早在注册发生之前就已经开始加载了服务工作者只能接管“激活之后”新建的页面请求。所以你在本地第一次测试时经常需要刷新页面两次才会看到效果——第一次刷新时新 SW 才拿到控制权第二次刷新发出的请求才会被它拦截。2.2 网页一次没生效skipWaiting 和 clients.claim 怎么用当页面已经打开着而你修改了sw.js里的逻辑浏览器会发现脚本的字节内容发生了变化于是开始安装一个新版本。新版本装好后按照默认规则并不会立刻替代旧版本而是进入“等待”状态直到所有被旧版本控制的页面都关闭为止。这在多标签页场景下是合理的因为页面可能会在运行中途被换成另一个版本的 Service Worker导致不可预期的状态。但对于我们日常开发调试来说这种等待实在浪费时间。两个 API 可以解决这个问题self.skipWaiting()让新安装的 Service Worker 跳过等待状态安装完成后直接进入激活流程。self.clients.claim()让刚激活的 Service Worker 立即接管当前已经打开的页面。我在小型项目中干脆两个都调用。代码风格如下self.addEventListener(install, (event) { event.waitUntil( caches.open(CACHE_NAME) .then((cache) cache.addAll(STATIC_ASSETS)) .then(() self.skipWaiting()) ); }); self.addEventListener(activate, (event) { event.waitUntil( caches.keys() .then((keys) Promise.all( keys.filter((key) key ! CACHE_NAME) .map((key) caches.delete(key)) )) .then(() self.clients.claim()) ); });这样一个新版本从安装到接管页面几乎是无缝的省去反复刷新页面的测试成本。生产环境中很多人会保守一点不主动 claim以避开半路切换带来的问题但如果你做的不是一个有实时运行状态的中大型应用这两个 API 完全可以放心使用。2.3 为什么 install 和 activate 里要包一层 waitUntilinstall和activate事件本身并不会阻止 Service Worker 进入下一个状态。如果你在事件回调里启动一个异步任务比如打开缓存、添加资源然后事件回调同步执行完了浏览器就会认为当前阶段已经完成可能在你缓存还没写完的时候就让 Service Worker 进入激活。event.waitUntil()接收一个 Promise并且明确告诉浏览器这个 Promise 结束之前别切换状态。这个机制保证了预缓存操作的原子性。尤其要注意cache.addAll()的行为只要列表里有一个资源下载失败整个 Promise 都会失败进而导致安装失败。所以预缓存列表里的 URL 一定要确认能正常访问不要随手把一个会返回 404 的路径塞进去。我最初就因为在列表里写了一个路径大小写错误结果本地调试时 Service Worker 一直注册失败控制台报错看半天才找到原因。3. 缓存策略到底在“策略”什么四种常见套路对比说完了生命周期终于到了标题里最核心的部分Service Worker 缓存策略。缓存策略看起来是写代码本质上是取舍快不快、新不新、离线兜不兜底这三件事在实际需求里优先级完全不同。我分别列出来讲清楚。3.1 Cache First命中缓存就直接返回不回服务器这是最容易理解、也是最激进的一种策略。收到请求后先到 Cache Storage 里找找到了立刻返回找不到才发网络请求拿到响应后顺手放进缓存下一次就不用再请求了。适用场景非常明确带哈希或者版本号命名的静态资源比如app.a1b2c3.js、style.xyz.css、图片、字体这类内容基本不会变的文件。因为 URL 一旦变化等于一个全新的缓存键自然不会拿到旧内容。async function cacheFirst(request) { const cachedResponse await caches.match(request); if (cachedResponse) { return cachedResponse; } const response await fetch(request); if (response.ok) { const cache await caches.open(CACHE_NAME); cache.put(request, response.clone()); } return response; }这里值得说两个细节。第一caches.match(request)是全局匹配它会在所有 Cache Storage 里找 key 匹配的响应如果你在多个版本号下保留了多个缓存它不关心哪个是最新的只关心是否能命中。所以版本清理逻辑一定要在activate里按时执行。第二fetch返回的response只能被消费一次要把它写入缓存前必须先response.clone()克隆一份否则后面页面消费响应时会直接报错。这个坑我在第一次写缓存时踩过一次当时页面一刷新就空白因为响应已经在写入缓存时被消耗掉了。3.2 Network First先找网络失败之后用缓存兜底很多内容是需要一定新鲜度的但又不希望彻底把离线用户挡在门外。典型例子是 HTML 首页、文章列表、用户资料这类接口。策略名字叫 Network First代码逻辑也很好理解先尝试请求网络请求成功就用最新的响应返回顺便写进缓存请求失败离线、超时、服务器崩溃再从缓存里拿旧数据出来。async function networkFirst(request) { const cache await caches.open(CACHE_NAME); try { const response await fetch(request); if (response.ok) { cache.put(request, response.clone()); } return response; } catch (error) { const cached await cache.match(request); if (cached) { return cached; } return caches.match(./offline.html); } }缓存都没有的情况下我会让页面重定向到一个自己写的离线提示页offline.html总比浏览器原生的那个难看的错误页友好得多。如果你担心弱网环境下网络请求迟迟不返回用户可以坐下来喝杯咖啡这里可以再加一层超时控制用Promise.race来比赛谁先完成就听谁的——通常是设置一个 3 秒的时间限超时直接放弃网络走缓存。async function networkFirstWithTimeout(request, timeout 3000) { const cache await caches.open(CACHE_NAME); const timeoutPromise new Promise((_, reject) setTimeout(() reject(new Error(timeout)), timeout) ); try { const response await Promise.race([fetch(request), timeoutPromise]); cache.put(request, response.clone()); return response; } catch (error) { const cached await cache.match(request); if (cached) { return cached; } return caches.match(./offline.html); } }对首屏来说这种“网络超时回退缓存”的方式比傻等要好得多用户网络状况越差感受越明显。3.3 Stale While Revalidate先给旧数据然后在后台偷偷更新这个名字翻译过来叫“过期的数据陪你到新数据就位”。策略分两步命中缓存的时候直接把缓存这次返回给页面响应不让页面等网络但同时后台发起一个真实的网络请求等响应回来后把最新的数据写进缓存里。下一次请求拿到的就是新版本了。这套策略特别适合对实时性不敏感的内容比如文章摘要、用户头像、商品标签这类数据。缓存里的数据就算旧了十几分钟用户根本感知不到而你享受到的是“秒开”的体验。async function staleWhileRevalidate(request) { const cache await caches.open(CACHE_NAME); const cachedResponse await cache.match(request); const networkPromise fetch(request).then((response) { if (response.ok) { cache.put(request, response.clone()); } return response; }); return cachedResponse || networkPromise; }注意这个实现里有一个隐藏的技巧如果缓存未命中直接返回networkPromise页面会等待网络响应如果缓存命中直接返回cachedResponse而networkPromise还在后台继续跑完成后写入缓存。这段代码写起来非常简短但包含的取舍很有意思相当于拿“这次展示的不一定最准”换“这次展示的一定很快”。3.4 混合路由一次 fetch 里按请求类型分流实战里几乎不会全局只用一种策略更多时候是在fetch事件里根据请求的特征分流。Service Worker 的Request对象有一个destination属性能识别出请求的用途document导航请求也就是用户打开页面本身适合 Network First。style样式表script脚本image图片这些资源适合 Cache First。font字体文件同样适合 Cache First。其他 API 请求可以根据业务需求选择 Stale While Revalidate 或 Network First。我常用的一套分流逻辑如下self.addEventListener(fetch, (event) { const { request } event; if (request.method ! GET) { return; } const url new URL(request.url); if (url.origin ! self.location.origin) { return; } if (request.destination document) { event.respondWith(networkFirst(request)); } else if ( request.destination style || request.destination script || request.destination image || request.destination font ) { event.respondWith(cacheFirst(request)); } else { event.respondWith(staleWhileRevalidate(request)); } });写到这里顺手总结一个表格方便你对照选型策略命中时行为离线时适合资源新鲜度Cache First直接返回缓存不发请求可用带哈希的 JS/CSS、图片、字体依赖 URL 变化Network First请求网络成功后更新缓存返回缓存或离线页HTML 页面、核心接口高Stale While Revalidate先返回缓存后台更新缓存可用文章列表、头像、非关键接口中等Network Only只走网络不碰缓存不可用实时性最强的接口登录态相关最高4. 完整手搓一个离线 Demo从页面到缓存全流程说了这么多原理终于到了手搓环节。我准备了五个文件组成的迷你站点index.html、style.css、app.js、sw.js、offline.html全部放在同一个目录下。这样目录结构最简单也不需要任何构建工具。4.1 工程结构和最简页面我先建一个offline-demo目录目录结构是这样的offline-demo/ ├── index.html ├── style.css ├── app.js ├── sw.js └── offline.htmlindex.html就放一个标题、一段文字、一张图片和一个按钮按钮点击后动态渲染一句“当前请求的数据”。这个页面本身不需要多复杂关键是让所有文件都能被 Service Worker 拦截并缓存。style.css给页面配上简单的深色背景和白色文字形成离线页与在线页的视觉区分。app.js里可以放一段模拟请求的代码比如点击按钮后用fetch(./data.json)拉数据——为了不让 Demo 变复杂我直接用本地文件模拟接口。offline.html是一个单独的降级页面里面只写一句“你目前处于离线状态请检查网络连接”样式尽量简洁再配一个“尝试重新加载”按钮点击后执行location.reload()。这个页面不会依赖任何对外资源保证离线时也能自己渲染出来。4.2 注册脚本的正确打开方式在app.js里或者直接在index.html底部写这一段注册逻辑。注意register()的路径决定了 Service Worker 的作用范围if (serviceWorker in navigator) { window.addEventListener(load, () { navigator.serviceWorker.register(./sw.js) .then((registration) { console.log(注册成功作用范围是, registration.scope); }) .catch((error) { console.error(注册失败, error); }); }); }为什么要在load事件之后注册我的理解是注册过程会下载并解析sw.js这会和页面自身的资源加载竞争带宽放在load之后可以让页面优先完成首屏渲染注册逻辑悄悄在后台进行不影响用户第一眼看到的体验。这里还有一个细节register(./sw.js)和register(/sw.js)看起来差别不大但作用范围完全不同。./sw.js是相对页面路径如果页面在/sub/目录下sw.js 就必须也在这个子目录里/sw.js表示从站点根目录定位。为了让作用范围覆盖整个站点把sw.js放在根目录并且始终用相对路径./sw.js或直接sw.js注册是最不容易出错的方案。4.3 install 预缓存与 activate 清理版本号的艺术打开sw.js先定义一个版本常量。版本号会出现在缓存 key 里它是整个缓存管理策略的地基const CACHE_NAME handmade-demo-v1; const STATIC_ASSETS [ ./, ./index.html, ./style.css, ./app.js, ./offline.html, ]; self.addEventListener(install, (event) { event.waitUntil( caches.open(CACHE_NAME) .then((cache) cache.addAll(STATIC_ASSETS)) .then(() self.skipWaiting()) ); });这里有一个容易被人忽略的细节./这个路径其实是请求站点根目录实际的 HTML 是它返回的内容。如果你的本地服务器没有配置默认首页addAll请求./会返回 404安装阶段直接失败。所以务必确认本地服务器能正常访问根目录。activate里的清理逻辑要配合版本号使用。每次发布新版本把CACHE_NAME从v1改成v2activate中会把所有名字不等于当前版本的缓存全部删除避免旧资源永远占据存储空间self.addEventListener(activate, (event) { event.waitUntil( caches.keys() .then((keys) Promise.all( keys .filter((key) key ! CACHE_NAME) .map((key) caches.delete(key)) )) .then(() self.clients.claim()) ); });这套“旧版本缓存清理”的逻辑看起来简单但它是缓存更新链条上至关重要的一环。如果只更新了资源 URL 而不清理旧缓存浏览器会同时保留好几个版本的缓存存储空间被浪费而且用户打开页面时可能匹配到旧版本的缓存响应。4.4 fetch 事件里串起三种策略respondWith 的同步陷阱然后在sw.js里加上fetch事件。前面已经见过分流逻辑这里我把三种策略函数也一并放入sw.js中。完整文件大致如下const CACHE_NAME handmade-demo-v1; const STATIC_ASSETS [ ./, ./index.html, ./style.css, ./app.js, ./offline.html, ]; self.addEventListener(install, (event) { event.waitUntil( caches.open(CACHE_NAME) .then((cache) cache.addAll(STATIC_ASSETS)) .then(() self.skipWaiting()) ); }); self.addEventListener(activate, (event) { event.waitUntil( caches.keys() .then((keys) Promise.all( keys.filter((key) key ! CACHE_NAME) .map((key) caches.delete(key)) )) .then(() self.clients.claim()) ); }); self.addEventListener(fetch, (event) { const { request } event; if (request.method ! GET) return; const url new URL(request.url); if (url.origin ! self.location.origin) return; if (request.destination document) { event.respondWith(networkFirst(request)); } else if ( request.destination style || request.destination script || request.destination image || request.destination font ) { event.respondWith(cacheFirst(request)); } else { event.respondWith(staleWhileRevalidate(request)); } }); async function cacheFirst(request) { /* 前面已实现 */ } async function networkFirst(request) { /* 前面已实现 */ } async function staleWhileRevalidate(request) { /* 前面已实现 */ }这段代码在本地是可以跑通的。但有一个坑我必须单独拿出来提醒event.respondWith()必须同步调用。也就是说你在 fetch 事件处理函数里不能先await一个异步操作再调用respondWith正确的做法是让respondWith接收一个 Promise而异步逻辑放在这个 Promise 内部。我上面代码里event.respondWith(cacheFirst(request))就是正确示范因为cacheFirst(request)只返回一个 Promise调用时respondWith已经被同步执行了。如果你写成这样// 错误示范 self.addEventListener(fetch, async (event) { const response await handleRequest(event.request); event.respondWith(response); });在某些浏览器里会直接抛出 “The event handler is already finished” 的报错因为事件处理器已经异步返回了你再调用respondWith就已经太晚。这个错误的隐蔽性很强因为它不是百分百触发而是取决于await的耗时越慢越容易复现。4.5 离线兜底不能让用户看到一片空白当networkFirst里网络请求失败而且缓存里也没有页面请求对应的响应时我选择返回offline.htmlasync function networkFirst(request) { const cache await caches.open(CACHE_NAME); try { const response await fetch(request); if (response.ok) { cache.put(request, response.clone()); } return response; } catch (error) { const cached await cache.match(request); if (cached) { return cached; } return caches.match(./offline.html); } }因为在预缓存阶段已经把offline.html放进了缓存所以断网时它能被caches.match(./offline.html)找到。这就是为什么install预缓存列表中一定要包含离线页的原因——它本身就是一个兜底的盾牌。本地调试时执行python -m http.server 8000起一个简单的静态服务器然后打开http://localhost:8000在浏览器开发者工具里观察 Service Worker 的注册状态。首次访问时资源会正常加载同时被写入缓存刷新一次后所有资源开始由 Service Worker 接管再手动设置浏览器网络为离线页面依然能够正常打开。5. 实测最容易翻车的五个细节如果只看前面的代码你可能觉得 Service Worker 缓存相当顺滑但实际跑起来总会遇到几个很折磨人的细节。我把踩过的坑按出现频率排序一个个说清楚。5.1 改了代码为什么用户刷新后还是旧内容这是最常见的问题。当你改了页面资源比如style.css的内容但它依然没有被缓存策略重新拉取用户看到的是旧样式。原因通常是资源路径没有变而fetch事件里命中了一条既有的 Cache First 缓存它根本没发网络请求。解决办法是在生产构建中给静态资源文件名加上哈希style.8f0d3a.css。文件内容变了URL 变了缓存自然失效。如果你不想引入构建工具至少可以升级CACHE_NAME版本号让activate清空旧缓存同时保证预缓存列表使用的是最新资源路径。这里还有一个常见的误解sw.js本身是有更新的。浏览器在页面刷新时会问服务器“你这里的 sw.js 有没有变化”如果字节数有差异就安装新版本。但如果你只是改了style.csssw.js文件本身没有变化浏览器不会重新安装任何东西也就不会清理缓存。所以在发布静态页面更新时记得要么改资源的哈希 URL要么顺手在sw.js里改一个版本号甚至加个注释让文件内容发生变化触发新 Service Worker 的安装流程。5.2 为什么有的请求根本就没被拦截Service Worker 不是“全站代理”它只对处于自己作用范围内的页面和请求生效。如果你把sw.js放到了js/子目录下那么它的默认作用范围就是/js/页面根路径下的请求它都管不到。看起来 Service Worker 明明注册成功了但是fetch事件一个都没触发。最稳妥的方案是把sw.js放到站点根目录。如果实在想放在子目录但又希望控制整个站可以在注册时指定{ scope: / }但这个写法对服务器的要求比较高需要一个Service-Worker-Allowed响应头配合否则浏览器会拒绝。本地静态服务器不一定支持加响应头所以我个人建议直接用根目录方案。5.3 调试时的“老 Service Worker”阴魂不散在浏览器开发者工具里勾选“Disable cache”只会禁用 HTTP 层面的缓存对 Service Worker 的 Cache Storage 完全无效。这是新手最容易混淆的地方。如果你改了sw.js里的缓存逻辑但旧版本还在控制页面你会发现不管怎么刷新页面走的都是旧代码。推荐的做法是在开发者工具的 Application应用面板里找到 Service Workers 一栏勾选 “Update on reload” 选项。这样每次刷新页面都会强制更新 Service Worker调试时非常方便。如果你想让 Service Worker 彻底消失可以在同一面板里点击 “Unregister”或者用 “Clear storage” 清掉站点的所有本地数据包括 Cache Storage。还有一个细节如果页面上同时打开了两个标签页旧 Service Worker 在另一标签页关闭之前可能一直不被卸载。我调试时经常把无关标签页先关掉只保留一个焦点页面这样更新流程更可控。5.4 跨域资源与不安全响应opaque response的隐患Service Worker 可以拦截页面里发送的跨域图片、字体、CDN 脚本请求。如果这些跨域服务器没有开启 CORSfetch返回的响应是opaque类型status为 0你无法判断这次请求到底成功没有。把 opaque 响应放进缓存是允许的但它带来两个问题第一你无法校验它是不是真的可用一个 404 的响应也可能以 opaque 的形式被写入缓存第二opaque 响应占用的存储空间没有大小统计可能推高总占用空间。我的建议是对跨域资源尽量采用缓存友好策略但不要过度依赖它或者在自己的服务器上做一层代理把所有跨域请求转成同源请求再交给 Service Worker 控制。5.5 方法不是 GET 的请求别乱拦截Service Worker 的caches.match()默认只匹配 GET 请求。POST 请求即使你把它拦截下来也基本没有办法通过 Cache Storage 做离线响应。所以无论你的缓存策略设计得多复杂第一道判断就是request.method ! GET直接放行。这不仅是为了兼容性也是为了避免无意中把 POST 请求的错误响应写进缓存污染后面相同的 GET 请求。6. 让离线小站进入“可安装”状态补上 manifestService Worker 缓存策略让页面离线可用这是 PWA 能力的一半另一半是 Web App Manifest。它是一个 JSON 文件描述应用名称、图标、启动方式。加上它之后浏览器会把你的站点识别成一个“可安装”的应用用户可以在地址栏点击下载按钮把站点像 app 一样放到手机桌面或电脑桌面。在项目根目录新建一个manifest.json{ name: 我的离线小站, short_name: 离线站, start_url: ./index.html, display: standalone, background_color: #1e1e1e, theme_color: #333333, icons: [] }然后在index.html里加一行link relmanifest href./manifest.json有了 manifest 加上已完成的 Service Worker站点就同时具备了“可离线打开”和“可安装到桌面”两个特征。如果再往后扩展还可以在push事件里做消息推送、在sync事件里做后台同步——这些都是 Service Worker 提供的其他能力但离线缓存本身就已经能解决很大一部分用户痛点。最后分享一下我自己的实操体会。如果你第一次动手试这个功能我的建议是先不要急着给整个线上站点写满缓存策略而是用一个三五文件的小 Demo 把生命周期整个跑通每一步都在开发者工具里观察状态变化注册成功时看registration.scope安装激活时看缓存列表刷新时看请求由谁处理断网时看兜底页是否如期出现。把这几个环节观察明白了你会发现 Service Worker 的缓存策略本质上并不难难的只是那些隐藏的时序和边界条件。按这一路思路走完再回到真实项目里去做缓存分层和版本管理就会顺手得多。