离线可用与版本新鲜,为什么很难同时拿到:Service Worker 的缓存取舍
Service Worker 是浏览器放在页面与网络之间的一层可编程代理,它本身不缓存任何东西,缓存什么、先给谁、何时替换,全由 fetch 事件里的代码决定。它的生命周期被拆成安装、等待、激活三道关卡,等待期正是为了让旧页面用完整套旧资源,而随手加上的 skipWaiting 会把这条保证拆掉。最常见的误解有两个:以为注册后就自动离线可用,以及以为缓存命中越多越好;前者漏了首次访问根本不受控,后者会让 HTML 被锁死在旧版本。读完你能按资源类型选对 cache-first、network-first 与 stale-while-revalidate,说清 Cache Storage 与 HTTP 缓存的分工,并知道缓存为何随时可能被整体驱逐。
是什么
Service Worker(下称 SW)是浏览器给页面配的一个后台脚本:注册在某个源的某个作用域上,独立于页面主线程运行,没有 DOM,空闲时会被随时终止再重启。它唯一稳定的能力是站在页面和网络之间看请求——页面发出的同源请求,以及页面里引用的跨源资源,都会先经过它的 fetch 事件,它可以放行、可以改写,也可以直接从 Cache Storage 里取一份响应返回。
直觉上它像小区门口的代收点:包裹本来由快递员直接送到家,现在一律先到代收点,代收点可以从货架上拿一件给你,可以转给快递员,也可以顺手把新到的货放上架。但代收点不会自动进货,SW 也不会自动缓存任何东西。它依赖安全上下文(HTTPS 或 localhost),典型用途是离线打开应用外壳、按需缓存图片与接口数据、给弱网兜底,以及配合推送与后台同步。
核心原理
第一道关:注册不等于控制。 首次访问时,页面早在注册之前就开始下载资源了,SW 只能在后台完成安装,这一趟文档从头到尾都不受它控制。要等下一次导航进入作用域,页面才会被接管;想让当前页面立刻受控,得在 activate 里调 clients.claim()。这解释了一个常见现象:开发者本机反复刷新一切正常,新用户第一次断网却只看到白屏。
第二道关:install 是一次全有或全无的快照。 安装阶段通常用 cache.addAll() 预热关键资源,并把它包进 event.waitUntil()。这个 promise 不结束,安装就不算完成;其中任何一个请求失败,整次安装失败,这个 SW 被丢弃,旧版本继续服务。这种原子性不是缺陷,它保证被激活的版本手里一定有一份完整可用的资源集合,不会出现“外壳在、入口缺”的半成品状态。
第三道关:waiting 是版本一致性的闸门。 新 SW 装好后不会立刻接管,因为旧页面还开着、还在跑旧版本的 JS。此时旧 SW 继续服务旧页面,新 SW 排队等待;等所有受控页面关闭或导航离开,它才收到 activate。这段等待常被当成“更新不及时”的 bug,于是有人在 install 里加 self.skipWaiting(),在 activate 里加 clients.claim()。代价是:一个还跑着旧 HTML、旧 JS 的页面突然被新 SW 接管,而新 SW 的 activate 往往刚把旧缓存删掉;页面这时去动态 import 一个带旧 hash 的分块,请求落空——这就是发版后部分用户白屏、控制台报动态导入失败的经典成因。等待期不是拖延,它是“同一时刻只有一个版本在服务”的保证。
fetch 里的三个决定。 其一,respondWith() 必须在事件处理函数里同步调用;一旦你把决定推到事件派发结束之后,浏览器就按默认行为直接走网络。其二,Response 的 body 只能读一次,要既给页面又写缓存,必须 clone()。其三,Cache Storage 是一张显式的键值表,不是 HTTP 缓存:它不认 Cache-Control,不会自己过期,也不会因为响应头写了 no-store 就拒绝写入,清理全靠你的代码。反过来,SW 里的 fetch() 默认仍可能命中浏览器 HTTP 缓存,所以“CDN 头改了、用户还是拿到旧文件”时,要检查是不是这一层把旧响应递了回来。
三种策略差在“谁先谁后、谁负责更新”。 cache-first 先查表,命中就返回,不命中再走网络并写回;它最快、离线最稳,但更新责任被推给了文件名,只有构建产物带 content hash 时才安全,因为新版本换了 URL,旧条目自然不会被命中。network-first 先请求网络,失败或超时再回缓存;新鲜度最好,弱网首屏最慢,通常要配超时。stale-while-revalidate 先返回缓存,同时后台请求新版本写回,下一次访问才生效;它把“快”和“最终新鲜”折中,代价是用户可能看到一次旧数据,而且后台写回必须挂在 waitUntil 上,否则 worker 被终止,这次更新就丢了。
const CACHE = 'assets-v3';
self.addEventListener('fetch', (event) => {
const { request } = event;
// 只接管构建产物,HTML 与接口不在这里处理
if (request.destination !== 'script' && request.destination !== 'style') return;
event.respondWith((async () => {
const cache = await caches.open(CACHE);
const hit = await cache.match(request);
if (hit) return hit; // 文件名带 hash,命中即安全
const res = await fetch(request);
if (res.ok) await cache.put(request, res.clone()); // body 只能读一次
return res;
})());
});
这段代码的正确性有一半不在代码里:它敢直接返回缓存,是因为构建给文件名加了 hash。把判断条件换成 HTML,同一段逻辑就会把用户永久锁在旧版本,因为入口文档本身再也不会更新。
常见考点与误区
- “注册了 SW,页面就离线可用”:错。首次加载的文档在注册前就开始取资源,整趟都不受控;SW 要到下一次导航才接管,除非显式调用
clients.claim()。“断网刷新白屏”经常发生在只来过一次的用户身上。 - “Cache Storage 会遵守 Cache-Control,也会自动过期”:不会。它只是 Request 到 Response 的持久化映射,没有 TTL、没有 revalidate,你不删就一直在,直到被浏览器驱逐。面试追问两层缓存的关系时要答清:SW 里的
fetch()仍走 HTTP 缓存,而cache.match()的命中完全由你控制。 - “skipWaiting 让更新更快,属于优化”:它是用一致性换即时性。旧页面、新 SW、已经删掉的旧分块三者凑在一起就是 404,单页应用的懒加载路由最容易中招。要用就得保留上一版资源一个周期,或者只在新版本就绪时提示用户刷新。
- “cache-first 最快,所以 HTML 也该 cache-first”:HTML 是资源清单的入口。它被缓存住,用户就永远拿不到指向新 hash 的新 HTML,后面所有“版本化更新”全部失效。HTML 必须 network-first,或至少 SWR 加快速失效。
- “缓存命中率越高越好”:命中率高只说明返回旧东西的比例高。指标要按资源类型拆开:静态产物的命中是收益,HTML 与接口的命中可能是事故。
- “缓存写进去就不会丢”:浏览器在磁盘压力下可能把某个源的 Cache Storage 整个清掉;Safari 的智能追踪防护(ITP)还会在连续 7 天无用户交互后清除脚本可写存储,其中就包括 SW 注册与 Cache。所以缓存只能是可重建的加速层,不能是唯一数据源。
- “respondWith 返回 undefined 也没关系”:没关系是错觉。拿不到 Response 就是一次网络错误,页面看到的是请求失败;catch 分支必须给出兜底的 Response。
实践建议
- 按资源定策略:HTML 用 network-first,配两三秒超时后回落离线页;带 hash 的 JS、CSS、字体用 cache-first;图片、头像、非关键 JSON 用 stale-while-revalidate;POST、PUT 这类写请求一律放行,不要进缓存。
- 缓存名带版本号,在 activate 里用
caches.keys()对比白名单删除旧缓存,但别删当前正在用的那个。install 的预缓存只放外壳和首屏必需,清单越长,一个 404 拖垮整次安装的概率越高。 - 更新走“提示加确认”,不要硬接管:监听 registration 的
updatefound事件,等新 worker 进入 installed 再提示“有新版本”;或者在下一次冷启动时切换。确实需要 skipWaiting,就保留上一版分块至少一个发布周期。 - 给 SW 留一个开关:让 fetch 处理器直接 return、不再拦截。线上出现缓存事故时,这比让用户手动清站点数据快得多。
- 每条分支都要有兜底。
respondWith的 promise 一旦 reject,页面拿到的是网络错误,而不是“没命中缓存”;catch 里返回离线页或一个明确的错误响应。 - 调试要覆盖四条路径:首次访问不受控、二次访问受控、断网、更新中。DevTools 的 Update on reload 与 Bypass for network 只覆盖一部分,“更新中”那条得开两个标签页手动复现。缓存上限可以用
navigator.storage.estimate()观察,重要数据另外申请持久化。
一句话总结:SW 的缓存策略本质是版本策略——决定哪个版本、在什么时候、服务哪些页面。