Service Worker离线缓存怎么做?Cache API与PWA缓存策略实战

Service Worker离线缓存怎么做?Cache API与PWA缓存策略实战
张文保 26 分钟阅读 4,041 阅读
本文目录
  1. Service Worker到底是什么?和服务器端缓存差在哪?
  2. Service Worker的生命周期怎么走?register、install、activate讲清楚
  3. Cache API怎么用才能把离线缓存管明白?
  4. 几种缓存策略怎么选?Cache First还是Network First?
  5. App Shell和预缓存清单怎么设计?
  6. 缓存更新和版本控制怎么做才不会让用户卡在旧版本?
  7. Service Worker对SEO和抓取有什么影响?
  8. 实战里踩过哪些坑?
  9. 常见问题解答
  10. Service Worker和HTTP缓存(Cache-Control)有什么区别?该用哪个?
  11. 为什么我改了Service Worker代码,刷新后还是旧的?
  12. Service Worker会拖慢首次访问吗?对新用户有好处吗?
  13. 必须用Workbox这类库吗?手写Service Worker行不行?
  14. Service Worker能缓存API接口数据吗?会不会导致数据不更新?
  15. 权威参考资料

Service Worker是跑在浏览器里、独立于页面的一段脚本,它能拦下页面发出的每一个网络请求,决定是走网络、读缓存还是两者结合。它和Nginx页面缓存、Redis对象缓存、CDN那几层最大的不同,是缓存逻辑由你用JavaScript写死在客户端,连断网都能出页面。

保哥这篇把生命周期(注册、install、activate、fetch)、Cache API的几个核心方法、四五种常见缓存策略怎么选、版本更新怎么不把用户卡在旧页面,以及它对Googlebot抓取意味着什么,一次讲透。代码能抄走就用,坑也都标了出来。

先把一个误会说清楚:很多人一听“缓存”,脑子里只有服务器那几层——Nginx的fastcgi_cache把整页HTML存起来、Redis把数据库查询结果存起来、Cloudflare在边缘节点存一份。这些都对,但它们有个共同点:都发生在请求离开浏览器、到达服务器或CDN之后。

Service Worker不一样。它住在浏览器里,是页面和网络之间的一道可编程关卡。请求还没出门,它就先拦下来问一句:这个要不要走网络?要不要直接给缓存里的旧货?这意味着哪怕用户的网断了、信号烂到只剩一格,你的站点照样能把页面渲染出来。这一篇就专门讲它这一层,和服务器端那几层是泾渭分明的两件事。

Service Worker到底是什么?和服务器端缓存差在哪?

用一句话概括,Service Worker是一个注册到浏览器、在后台独立运行、能拦截网络请求的脚本。它不挂在某个页面的生命周期上——页面关了它还能活着(比如处理推送通知),页面没开它也能被唤醒。正因为脱离了页面主线程,它里面不能碰DOM,所有跨页通信都得靠消息传递。

它和服务器端缓存的分工,保哥喜欢用“四层各管一段”来理解。浏览器的 HTTP缓存头(Cache-Control、ETag)是最被动的一层,浏览器按响应头里的规则自动存、自动用,你只能配规则不能写逻辑。CDN(比如 Cloudflare的边缘缓存)在网络中间替你挡掉大量回源。服务器上的 Nginx fastcgi_cache把PHP生成的整页存成静态。这三层都不需要你写代码,配置好就生效。

Service Worker是唯一一层“可编程”的缓存。命中不命中、命中了走不走网络更新、断网了拿什么兜底,全是你用JavaScript一行行写出来的。HTTP缓存头你只能说“缓存一小时”,Service Worker你能说“先给缓存里的旧版本秒开,同时偷偷去后台拉新版本存起来下次用”。这种精细到每个请求的控制力,是它和前面几层的本质区别,也是它能做到真正离线可用的根本原因。

还有一个绕不开的硬门槛:Service Worker只能在HTTPS下注册(localhost例外,方便本地开发)。这不是建议,是浏览器的强制规定。理由也好理解——它有改写网络响应的能力,要是能在不安全的连接上被注入,那就是个现成的中间人攻击工具。

Service Worker的生命周期怎么走?register、install、activate讲清楚

一个Service Worker从“被写出来”到“开始干活”,要走一条固定的路:注册 → 下载 → install → activate → 接管请求。这条路上每一步都有坑,搞懂了后面的缓存策略才有地方落脚。

第一步是在页面里注册。注意这段代码是写在普通页面脚本里的,不是写在Service Worker文件里:

// 写在页面的主脚本里
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker.register("/sw.js", { scope: "/" })
      .then((reg) => console.log("注册成功,作用域:", reg.scope))
      .catch((err) => console.error("注册失败:", err));
  });
}

这里有个高频踩坑点——作用域(scope)。Service Worker只能控制它所在路径及以下的请求。你把 sw.js 放在 /js/sw.js,它默认就只能管 /js/ 下面的东西,根目录的页面它够不着。所以约定俗成把Service Worker文件放在网站根目录,让它能接管整站。想放深一点又要管根目录,得靠服务器响应头 Service-Worker-Allowed 放宽,多数人不知道这个,白白折腾半天。

注册成功后浏览器会下载这个脚本并触发 install 事件。这是预缓存的黄金时机——把页面骨架、关键CSS、JS、logo这些“无论如何都得有”的资源一次性塞进缓存:

const CACHE_NAME = "site-shell-v1";
const PRECACHE_URLS = [
  "/",
  "/offline.html",
  "/css/app.css",
  "/js/app.js",
  "/img/logo.svg",
];

self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
  );
});

event.waitUntil() 是个关键动作,它告诉浏览器“install还没完,等我这个Promise兑现了再说”。不写它,浏览器可能在缓存还没塞完就认为安装结束,预缓存就成了薛定谔的猫。

接着是 activate。这一步最重要的活儿是清理旧缓存。每次你改了缓存内容、把版本号从v1升到v2,旧的v1缓存就该被扫掉,不然用户的硬盘会被一代代旧缓存慢慢撑爆:

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
      )
    )
  );
});

这里要特别提醒一个生命周期的“延迟接管”特性:默认情况下,新的Service Worker装好后不会立刻接管已经打开的页面,它会进入“等待(waiting)”状态,要等所有用旧版本的标签页都关掉,新版本才上岗。这个设计是为了避免页面用着一半版本逻辑突然被换掉。但它也带来了那个经典抱怨——“我明明改了代码刷新了,怎么还是旧的”。这个问题怎么治,后面专门有一节讲。

Cache API怎么用才能把离线缓存管明白?

Service Worker自己不存东西,真正存缓存的是Cache API,全局对象叫 caches。它本质上是一个键值仓库,键是Request对象,值是Response对象。注意它和HTTP缓存是两套独立系统——Cache API完全不理会响应头里的Cache-Control,你存进去就一直在,什么时候删全凭你的代码说了算。这点MDN文档里写得很直白,也是新手最容易混淆的地方。

常用的方法就那么几个,记熟了基本够用:

  • caches.open(name):打开(不存在就新建)一个命名缓存,返回这个cache对象。
  • cache.put(request, response):手动把一对请求-响应存进去,最灵活,适合在fetch里拿到响应后顺手缓存。
  • cache.add(url) / cache.addAll([urls]):传URL进去,浏览器自己去抓取再存,预缓存清单最常用 addAll
  • caches.match(request):跨所有命名缓存找匹配的响应,找不到返回undefined,这是取缓存的主力。
  • cache.delete(request)caches.keys():删条目、列出所有缓存名字,清理时用。

有个细节值得单独拎出来:Response对象是“一次性”的,它的body是个流,读一次就空了。所以你想既把响应返回给页面、又存一份进缓存,必须先用 response.clone() 克隆一份。忘了克隆,要么页面拿到空响应,要么缓存存进去个空壳,这是写fetch拦截时最常见的低级错误:

self.addEventListener("fetch", (event) => {
  event.respondWith(
    fetch(event.request).then((networkResponse) => {
      const copy = networkResponse.clone(); // 先克隆
      caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
      return networkResponse; // 原件给页面
    })
  );
});

还有个匹配上的细节值得知道:caches.match() 默认拿整个Request(含查询参数)当键去比对,所以 /list?page=1/list?page=2 会被当成两条完全不同的缓存。要是你希望忽略查询参数、把它们当同一条处理,得传 { ignoreSearch: true } 选项。带时间戳、带随机参数的URL如果不管,很容易把缓存撑出一堆几乎一样的副本,白白占掉存储配额,这点在缓存第三方脚本、广告资源时尤其要留神。

几种缓存策略怎么选?Cache First还是Network First?

所谓缓存策略,说白了就是在fetch事件里,你决定“先问网络还是先问缓存、问不到怎么兜底”的那段逻辑。web.dev那本《Offline Cookbook》把常见套路总结得很全,落到实战里就这么几种,按资源类型对号入座即可。

Cache First(缓存优先):先查缓存,命中就直接返回,没命中才走网络并顺手存起来。适合那些“几乎不变”的资源——带哈希指纹的CSS/JS、字体、logo。它的好处是秒开且省流量,代价是更新得靠换文件名触发。

// Cache First
event.respondWith(
  caches.match(event.request).then((cached) => {
    return cached || fetch(event.request).then((res) => {
      const copy = res.clone();
      caches.open(CACHE_NAME).then((c) => c.put(event.request, copy));
      return res;
    });
  })
);

Network First(网络优先):先走网络,拿到新内容就用并更新缓存,网络挂了才退回缓存。适合对“新鲜度”敏感的内容——商品价格、库存、新闻列表。它保证联网时永远是最新的,断网时还有个旧版本兜底,不至于白屏。

Stale-While-Revalidate(先旧后新):这是体验最讨喜的一种。立刻把缓存里的旧版本返回去让页面秒开,同时在后台偷偷发请求拉新版本存起来,下次访问就是新的了。代价是用户这次看到的可能是上一版,适合那些“稍微旧一点也无所谓但要快”的内容,比如头像、非关键的配置数据。

// Stale-While-Revalidate
event.respondWith(
  caches.open(CACHE_NAME).then((cache) =>
    cache.match(event.request).then((cached) => {
      const fetching = fetch(event.request).then((res) => {
        cache.put(event.request, res.clone());
        return res;
      });
      return cached || fetching; // 有旧的先给旧的,后台照样更新
    })
  )
);

另外还有两种极端:Network Only(只走网络,适合不能缓存的接口,比如下单、支付回调)和 Cache Only(只读缓存,适合明确预缓存过、保证存在的资源)。真实项目里很少全站用一种策略,更常见的是按请求类型分流:HTML文档用Network First,静态资源用Cache First,API数据看情况用Stale-While-Revalidate或Network Only。

Network First还有个实战增强很值得加——超时兜底。纯Network First在网络极慢(不是断网,是龟速)时会傻等服务器响应,那种体验比直接断网还煎熬。老练的写法是给网络请求套一个两三秒的超时,一旦超时就主动退回缓存,让用户先看到旧内容也别干等转圈。这种“网络优先但限时”的变体,在移动端弱网、跨境高延迟的场景下特别管用,保哥给做跨境的客户配PWA时几乎都会默认加上,实测能把弱网下的“感知卡顿”砍掉一大截。

保哥之前帮一个做户外装备的独立站排查“断网就白屏”的问题,根子就是他们图省事全站套了Cache First,连商品库存接口都被缓存死了,用户看到的库存永远是第一次访问时的快照,下单老是超卖。改成按类型分流——页面壳Cache First、库存价格Network First——白屏没了,超卖也治住了。策略选错比不选还麻烦,这是真金白银的教训。

App Shell和预缓存清单怎么设计?

App Shell(应用外壳)是PWA里一个很实用的模型:把页面里“每一页都长一样”的部分——顶部导航、底部页脚、侧边栏、基础样式——抽出来当成一层稳定的骨架预缓存掉,内容区再按需从网络或缓存填进去。这样第二次访问时,外壳直接从缓存秒出,用户感觉“唰”地就开了,剩下的内容慢慢补。

设计预缓存清单有几条经验。第一,清单要克制,只放真正的关键路径资源,别把整站几百个文件全塞进去——install阶段 addAll 是“全有或全无”,里头任何一个URL抓取失败,整个install就失败,Service Worker装不上。第二,清单里的每个资源最好带版本指纹(文件名带哈希),这样配合Cache First既能长缓存又能在内容变了时自然失效。第三,准备一个 offline.html 兜底页,当用户彻底断网又访问了没缓存过的页面时,至少给个体面的“当前离线”提示,而不是浏览器那个难看的恐龙。

还要分清预缓存和运行时缓存这两个概念,混淆它们是后期缓存失控的根源。预缓存是install阶段一次性塞进去的固定清单,是离线兜底的地基,内容稳定、随版本号整体更新;运行时缓存是用户实际访问过程中、由fetch拦截按策略动态存下来的内容,比如用户翻过的某篇文章页、看过的某张图片。这两类最好用不同的缓存名分开管理——预缓存跟着版本号走,运行时缓存设个数量上限定期淘汰最老的,避免它随用户浏览无限膨胀吃掉配额。要是把两者混在一个缓存里,清理时极容易误删掉本该常驻的外壳资源,离线兜底跟着崩。

清单的版本管理直接绑在缓存名字上是最省事的做法——site-shell-v1site-shell-v2。版本号一变,activate里的清理逻辑就会把旧缓存扫掉,新清单重新预缓存。手动维护清单容易漏,规模大了建议上Workbox这类工具自动生成预缓存清单(它会扫描构建产物、自动算指纹、自动注入清单),但原理还是这一套,工具只是把体力活自动化了。

缓存更新和版本控制怎么做才不会让用户卡在旧版本?

这是Service Worker的头号疑难杂症,也是保哥被问得最多的问题:明明发了新版本,部分用户怎么死活还是旧页面?根源就在前面提过的“延迟接管”——新Service Worker装好后默认在旁边等着,非要等所有旧标签页关掉才上岗。可现在的人谁还关标签页?几十个标签页挂着,旧Service Worker就一直赖着不走。

想让新版本尽快接管,有两个动作要成对使用。self.skipWaiting() 让新Service Worker跳过等待、装完立刻激活;clients.claim() 让它激活后立刻接管所有已打开的页面,而不是等下次导航:

self.addEventListener("install", (event) => {
  self.skipWaiting(); // 别在旁边等了,直接上
});

self.addEventListener("activate", (event) => {
  event.waitUntil(clients.claim()); // 立刻接管现有页面
});

但这俩也不是无脑加就好。skipWaiting 会让页面在没刷新的情况下,前后两半请求由不同版本的Service Worker处理,万一新旧版本的缓存结构、接口契约对不上,可能出现样式错乱、接口报错。所以更稳妥的做法,是检测到有新版本就绪时弹个提示条——“有新版本,点此刷新”——让用户主动触发刷新,而不是偷偷换。要快还是要稳,看你的站点能不能接受瞬间的版本不一致。

另外一个常被忽略的点:浏览器对Service Worker文件本身(sw.js)也会做HTTP缓存。要是你的服务器给 sw.js 配了长缓存,浏览器可能拿着旧的Service Worker文件不更新,你改了半天它压根没下载新的。所以约定俗成给Service Worker文件单独配 Cache-Control: no-cache,让浏览器每次都去校验有没有更新。这个坑很隐蔽,排查“代码改了不生效”时一定要先看这里。

Service Worker对SEO和抓取有什么影响?

做技术的容易只盯着功能,但保哥做了二十多年SEO,必须提醒一句:Service Worker用得不对,是能伤到收录的。最核心的一条原则——首屏内容绝对不能依赖Service Worker才能出来

原因是Googlebot抓取页面时,基本上是“全新访客”的身份,没有任何已注册的Service Worker,更不会等你的离线缓存生效。它要的是服务器直接吐出来的、首次请求就完整的HTML。如果你的页面把关键内容藏在“等Service Worker接管后再从缓存渲染”的逻辑里,爬虫第一次来看到的就是个空壳,收录自然出问题。

所以Service Worker是“锦上添花”的体验增强层,渲染兜底必须靠服务器端渲染(SSR)或静态生成(SSG)扛住。关于Service Worker和PWA对抓取索引的具体影响机制,保哥单独写过一篇拆解,想深挖的可以去看。

第二个角度是性能。Google早就把页面速度、Core Web Vitals当成排名信号,而Service Worker配合缓存策略,恰恰能显著拉低回头客的LCP(最大内容绘制)。这一点和服务器端的缓存优化是同向用力的——你想把 TTFB和整体加载速度压到底,多层缓存得协同工作,Service Worker是离用户最近、最后那一道。它管不了首次访问的TTFB(那是服务器的事),但回头客的体验它能托起来。

说到底,Service Worker在SEO这件事上的定位很清楚:它优化的是“已经认识你的人”再次访问的体验,对“第一次来的爬虫”几乎没有正面贡献,用错了反而帮倒忙。把这条边界守住,剩下的就是放开手脚优化体验了。

实战里踩过哪些坑?

把这些年趟过的雷集中列一下,照着避能省不少头发:

  • 非HTTPS注册失败:本地用localhost没问题,一上测试环境用了IP或HTTP域名,注册直接静默失败,控制台还不一定有明显报错,先查协议。
  • scope没覆盖到目标路径:Service Worker文件位置决定能管的范围,要管全站就放根目录,别放 /assets/ 里还纳闷为什么首页不受控。
  • 缓存爆掉存储配额:浏览器给每个站点的存储是有上限的,无脑缓存大文件、视频,容易触发配额上限导致写入失败。该清的旧缓存一定在activate里清干净。
  • HTML被长缓存死:和HTTP缓存一个道理,HTML文档别用Cache First长缓,否则用户永远停在某个旧版本。文档类走Network First或Stale-While-Revalidate。
  • 第三方资源的不透明响应(opaque response):跨域且没开CORS的资源(比如某些第三方CDN的图片)缓存进去是opaque的,你读不到状态码,没法判断是不是真的成功,还特别占配额,缓存第三方资源要当心。
  • 忘了用DevTools的Application面板调试:Chrome开发者工具的Application → Service Workers能看注册状态、强制更新、模拟离线,Cache Storage能直接看缓存了什么。不会用这个面板,调Service Worker等于盲人摸象。
  • 开发时被自己的缓存骗:调试时勾上DevTools里的“Update on reload”和“Bypass for network”,否则你会被自己写的缓存逻辑反复戏弄,改了代码看不到效果。

Service Worker这东西,原理不复杂,但它运行在一个“脱离页面、有自己生命周期、还能拦网络”的特殊语境里,新手最容易栽在生命周期和缓存克隆这些机制细节上。把本文这几节的机制吃透,配合DevTools反复观察,基本就能驾驭它了。

常见问题解答

Service Worker和HTTP缓存(Cache-Control)有什么区别?该用哪个?

两者是独立的两套系统,不是二选一,而是配合用。HTTP缓存是浏览器按响应头自动管理的,你只能配规则、改不了逻辑,且对每个资源生效;Service Worker配合Cache API是你用JavaScript完全掌控的可编程层,能做到断网出页面、按策略精细分流。一般做法是:常规静态资源交给HTTP缓存头管就够了;需要离线可用、需要复杂缓存策略(比如先旧后新)、需要断网兜底时,才上Service Worker。web.dev有专门一篇讲两者如何协同,建议读一读。

为什么我改了Service Worker代码,刷新后还是旧的?

两个最常见原因。一是“延迟接管”机制——新版本装好后默认在waiting状态等所有旧标签页关闭才激活,你只刷新没关页面,旧的还赖着。可以用 skipWaiting()clients.claim() 让它尽快接管,或在DevTools里手动点“skipWaiting”。二是 sw.js 文件本身被HTTP长缓存了,浏览器压根没下载到新文件,给它配 Cache-Control: no-cache 即可。

Service Worker会拖慢首次访问吗?对新用户有好处吗?

首次访问时Service Worker还在注册和预缓存,那一次基本享受不到加速,甚至预缓存还会占用一点带宽。它的价值几乎全在“回头客”身上——第二次起,缓存生效,加载速度和离线能力才显现。对搜索引擎爬虫这种“永远的首次访客”更是几乎没有正面作用,所以千万别让首屏内容依赖它渲染。

必须用Workbox这类库吗?手写Service Worker行不行?

完全可以手写,本文的代码就是原生写法,小站点手写反而更可控、包更小。Workbox的价值在于把预缓存清单生成、缓存策略封装、版本管理这些重复劳动自动化了,项目大、资源多、构建流程复杂时能省很多体力,但它底层用的还是本文讲的这套机制。建议先手写理解原理,再决定要不要上工具。

Service Worker能缓存API接口数据吗?会不会导致数据不更新?

能缓存,但要挑策略。读多写少、新鲜度要求不高的接口(比如分类列表、配置)适合Stale-While-Revalidate,秒出旧的同时后台更新。新鲜度敏感的(价格、库存、订单状态)要用Network First,联网时永远拿最新,断网才退缓存。绝对不能缓存的(下单、支付、登录态相关)直接用Network Only。把不该缓存的接口也缓存了,是导致“数据不更新”事故的最常见原因,按接口性质分流是关键。

权威参考资料

FAQPage + Article AI 引用友好版

TL;DR · 60–80 字摘要 · 适用 ChatGPT / Perplexity / Gemini / 文心 引用

Service Worker是浏览器里可编程的缓存层,本文讲透它和Nginx、Redis、CDN缓存的分工、注册与生命周期、Cache API用法、Cache First等策略怎么按资源选,以及版本更新怎么不卡住用户。

关键实体 · Key Entities

  • PWA
  • Service Worker
  • 缓存与CDN
  • 离线缓存

引用元数据 · Citation Metadata

title:       Service Worker离线缓存怎么做?Cache API与PWA缓存策略实战
author:      张文保 (Paul Zhang) — PatPat SEO 经理
url:         https://zhangwenbao.com/service-worker-cache-api-offline-pwa-strategies.html
published:   2026-05-06
modified:    2026-05-06
source-type: First-hand expert commentary
language:    zh-CN
license:     CC BY-NC-SA 4.0 (要求保留原文链接与作者归属)
分享到
标签
版权声明

本文标题:《Service Worker离线缓存怎么做?Cache API与PWA缓存策略实战》

本文链接:https://zhangwenbao.com/service-worker-cache-api-offline-pwa-strategies.html

版权声明:本文原创,转载请注明出处和链接。许可协议: CC BY-NC-SA 4.0

继续阅读
发表评论
分享到微信 或在下方手动填写
支持 Ctrl + Enter 提交