发一次版,用户说页面还是老的,同事回一句「让他清一下缓存」。这话你大概率也听过,甚至自己说过。可「清缓存」并不是解法,它只是把一次配置失误的成本转嫁给了用户。浏览器缓存这套机制本身不算复杂,难的是它由好几层拼起来,强缓存、协商缓存、刷新行为、Service Worker,每一层都能单独让你的资源该更新的时候不更新,或者不该重下的时候重下一遍。这篇把这条判定链从头走到尾,讲清每个 header 卡在哪一步、no-cache 和 no-store 差在哪、ETag 和 Last-Modified 该留哪个、为什么 HTML 通常不配强缓存。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 浏览器拿到一个 URL 之后,缓存判定的完整顺序是什么
- 强缓存怎么工作,
Expires为什么被Cache-Control取代 Cache-Control各个指令的实际效果,no-cache和no-store到底哪个才是真不缓存- 协商缓存的两对 header,
Last-Modified加If-Modified-Since和ETag加If-None-Match - 分布式部署下为什么建议关掉
ETag,京东为什么只留Last-Modified - 强缓存配长了怎么解决发布更新问题,为什么 HTML 要单独对待
F5、Ctrl+F5、地址栏回车、前进后退,各自跳过哪一层缓存- Service Worker 站在这条链的哪个位置
一、先把整条判定链走一遍
浏览器缓存分两种,强缓存和协商缓存。名字听着像并列关系,实际是先后关系,它们在一次请求里是串在一起判定的。
浏览器加载资源时,先根据这个资源的一些 http header 判断它是否命中强缓存。强缓存如果命中,浏览器直接从自己的缓存中读取资源,不会发请求到服务器。比如某个 css 文件,浏览器在加载它所在的网页时,如果这个 css 文件的缓存配置命中了强缓存,浏览器就直接从缓存中加载这个 css,连请求都不会发送到网页所在服务器。
当强缓存没有命中的时候,浏览器一定会发一个请求到服务器,由服务器端依据资源的另外一些 http header 验证这个资源是否命中协商缓存。如果协商缓存命中,服务器会把这个请求返回,但是不会返回这个资源的数据,而是告诉客户端可以直接从缓存中加载这个资源,于是浏览器就又会从自己的缓存中去加载它。
当协商缓存也没有命中的时候,浏览器才从服务器加载资源数据。
所以这两者的共同点是,只要命中,最终读的都是客户端缓存里那份数据,而不是从服务器传回来的;区别在于强缓存不发请求到服务器,协商缓存会发请求到服务器,只是这个请求很轻,只传 header 不传 body。
先记住这个顺序,后面所有的行为差异都是从它推出来的。
二、强缓存的原理
2.1 命中强缓存在面板上长什么样
当浏览器对某个资源的请求命中了强缓存时,返回的 http 状态为 200,在 chrome 的开发者工具的 network 里面 size 会显示为 from cache。比如京东的首页里就有很多静态资源配置了强缓存,用 chrome 打开几次,再用 f12 查看 network,可以看到有不少请求就是从缓存中加载的。

这里补一句现在的情况。新版 Chrome 把这一列拆成了两种显示,from memory cache 和 from disk cache。前者存在渲染进程的内存里,关掉标签页就没了,速度最快;后者落在磁盘上,跨会话也在。你看到的是哪一种,取决于资源大小和当前内存压力,不用去纠结,两种都属于强缓存命中,都没有发出网络请求。
强缓存是利用 Expires 或者 Cache-Control 这两个 http response header 实现的,它们都用来表示资源在客户端缓存的有效期。
2.2 Expires 缓存原理
Expires 是 http1.0 提出的一个表示资源过期时间的 header,它描述的是一个绝对时间,由服务器返回,用 GMT 格式的字符串表示,如:Expires:Thu, 31 Dec 2037 23:55:55 GMT。
浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 respone 的 header 加上 Expires:

拿到响应之后,浏览器会把这个资源连同所有 response header 一起缓存下来。这一点很容易被忽略,缓存命中的那次请求,你在面板里看到的 header 并不是这一次从服务器拿的,而是来自之前那次真实请求存下来的副本。所以有时候你改了服务端的响应头,本地面板里却还是老值,不是服务端没生效,是你在看一份快照。
浏览器再请求这个资源时,先从缓存中寻找,找到之后拿出它的 Expires 跟当前的请求时间比较,请求时间在 Expires 指定的时间之前就命中缓存,否则就不行。如果缓存没有命中,浏览器直接从服务器加载资源,Expires Header 会在重新加载的时候被更新。
Expires 的毛病出在「绝对时间」这三个字上。它由服务器生成,却由客户端来比对,两边的时钟只要对不上,判定结果就跑偏。用户手动把系统时间往前调一年,站点上所有静态资源当场全部过期;往后调,本该更新的资源可能永远不更新。这种问题在客服那边听到的描述往往是「我这台电脑就是不刷新」,排查起来相当劝退。
所以在 http1.1 的时候,提出了一个新的 header,就是 Cache-Control,这是一个相对时间,在配置缓存的时候,以秒为单位,用数值表示,如:Cache-Control:max-age=315360000。
2.3 Cache-Control 缓存原理
流程跟 Expires 那套是一样的。浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 respone 的 header 加上 Cache-Control:

浏览器同样把资源连同所有 response header 一起缓存下来。再请求这个资源时,先从缓存中寻找,找到之后根据它第一次的请求时间和 Cache-Control 设定的有效期,计算出一个资源过期时间,再拿这个过期时间跟当前的请求时间比较,请求时间在过期时间之前就命中缓存。没命中就走服务器,Cache-Control Header 在重新加载的时候会被更新。
这里可以再往下抠一层。规范里算的其实不是「第一次请求时间」,而是响应里的 Date 头,再叠加 Age 头。Age 是中间代理(CDN、反向代理)填的,表示这份响应在代理那里已经躺了多少秒。所以一份 max-age=600 的响应,如果 CDN 上已经存了 500 秒,它回给你的时候会带 Age: 500,你本地只能再用 100 秒。调 CDN 缓存的时候盯一眼 Age,比盯 max-age 有用得多。
Cache-Control 描述的是相对时间,判定时全程只用客户端自己的时钟做减法,不涉及两台机器对表,所以比 Expires 可靠。这两个 header 可以只启用一个,也可以同时启用。当 response header 中 Expires 和 Cache-Control 同时存在时,Cache-Control 优先级高于 Expires:

同时下发两个的写法现在还很常见,纯粹是给早年只认 http1.0 的客户端兜底。今天基本可以只写 Cache-Control。
2.4 Cache-Control 各个指令到底在管什么
面试里问「强缓存怎么配」,一句 max-age 就答完了的,多半没在生产上真配过。Cache-Control 是一组指令,可以用逗号组合,不同组合的行为差得很远。
| 指令 | 实际效果 |
|---|---|
max-age=<秒> |
资源在本地的新鲜时长,超时后进入需要校验的状态 |
s-maxage=<秒> |
只对共享缓存(CDN、代理)生效,同时存在时对 CDN 覆盖 max-age |
public |
允许包括 CDN 在内的任何环节缓存 |
private |
只允许最终用户的浏览器缓存,CDN 不许存。带用户身份的接口响应必须用它 |
no-cache |
可以存,但每次用之前都必须回源校验一次 |
no-store |
完全不许存,硬盘内存都不许,每次都得完整重下 |
must-revalidate |
过期之后必须回源校验,不许在断网等情况下继续拿旧的顶上 |
immutable |
声明这份资源在有效期内绝不会变,用户按刷新时也不必回源校验 |
其中最容易搞混的是 no-cache 和 no-store。名字看着像同义词,行为完全不同。
no-cache 不是「不缓存」,它是「缓存了,但每次都要问一下服务器还能不能用」。资源照样存在磁盘上,请求照样发出去,只是不再依赖 max-age 那个倒计时,而是直接进入协商缓存的流程。服务端说没变,返回 304,浏览器还是读本地那份,一个字节的 body 都不传。所以 no-cache 其实是很省流量的配置。
no-store 才是字面意义上的不缓存。资源不落盘、不进内存,每次请求都得把完整内容重新传一遍。它的场景很窄,基本只有银行流水、验证码、包含敏感信息的接口响应才用得上。给静态资源配 no-store,等于主动放弃了所有缓存收益。
我一开始也是这么想的,以为 no-cache 就是关缓存,直到有次给一个 HTML 配了 no-cache,在 Network 里看到一片 304,才反应过来它压根没关,只是每次都去校验了一遍。
immutable 也值得单说。它解决的是这么一个场景:用户在页面上按了 F5,按常规行为浏览器会给所有子资源发一遍校验请求,哪怕它们的 max-age 还剩三百天。对于文件名里已经带内容 hash 的构建产物来说,这些校验请求全是白发的,因为内容变了文件名一定会变。加上 immutable,浏览器就会跳过这层校验。配合 hash 文件名,Cache-Control: public, max-age=31536000, immutable 是目前静态资源比较标准的一套写法。
再提一句 max-age 的取值。原文里那个 315360000 是十年,RFC 建议不要超过一年,也就是 31536000。超过一年的部分很多实现会自行截断,写得再大也没有额外收益。
三、强缓存怎么配,以及开发时怎么绕开它
强缓存的开关通常有两种设法,一种是在代码里给响应加 Expires 和 Cache-Control Header,另一种是配置 web 服务器,让它在响应资源的时候统一加上。
比如在 javaweb 里面,我们可以使用类似下面的代码设置强缓存。这段是原文里的写法,用来演示「在业务代码里手工塞缓存头」这件事:
java.util.Date date = new java.util.Date(); |
上面第三行原文的注释有点问题,这里要纠正一下。Pragma 是 http1.0 的遗留字段,规范里只定义了 no-cache 这一个值,写成 Pragma 这种自造值没有任何标准行为,各家实现直接忽略。真要兼容老客户端,只写 Pragma: no-cache 就够了,而且今天基本已经没必要写。
反过来,关掉强缓存的老写法是这样:
response.setHeader( "Pragma", "no-cache" ); |
这三行是同一件事说三遍,分别照顾 http1.0 的 Pragma、http1.0 的 Expires、http1.1 的 Cache-Control。现在的项目一般在网关或者静态服务器层统一配,业务代码里很少再手写这几行了。
nginx 和 apache 作为专业的 web 服务器,都有专门的配置文件,可以配置 expires 和 cache-control,搜索 nginx 设置 expires cache-control 或 apache 设置 expires cache-control 都能找到不少相关的文章。
顺着上面聊,开发阶段最烦人的一件事就是浏览器默认会缓存图片、css 和 js 这些静态资源,改完代码刷新看不到效果。常用的破解办法有这么几种。
ctrl+f5 是最直接的,它能解决页面直接引用的资源更新的问题。或者用浏览器的隐私模式开发,每次开一个干净的会话。
如果用的是 chrome,可以 f12 在 network 那里把缓存给禁掉,这是个非常有效的方法:

要注意这个勾选框只在 DevTools 打开时生效,关掉面板就恢复正常缓存了。这个我踩过,调完样式顺手关掉面板,再刷新又变回旧的,白排查了一会儿。
第二类办法是给 URL 加干扰。在开发阶段给资源加上一个动态的参数,如 css/index.css?v=0.0001。这招的麻烦在于每次资源修改都要更新引用的位置并同时改参数值,操作起来不方便,除非你是在 jsp 这类动态页面里开发,可以用服务器变量来解决(v=${sysRnd}),或者用前端构建工具来处理这个参数修改的问题。同理,ajax 请求的缓存问题也可以靠给请求地址追加随机数解决,动态设置 iframe 的 src 时也一样,在 src 后面添加随机数能绕开。
如果资源引用的页面被嵌入到了一个 iframe 里面,可以在 iframe 的区域右键单击重新加载该页面,以 chrome 为例:

第三类办法是从源头上不要产生缓存。如果你用的是 grunt、gulp、webpack 这种前端工具开发,通过它们的插件比如 grunt-contrib-connect 启动一个静态服务器,就完全不用担心开发阶段的资源更新问题,因为这个静态服务器下所有资源返回的 respone header 中,cache-control 始终被设置为不缓存:

grunt 和 gulp 这两个名字现在基本退出主流了,不过这个思路一点没变。今天用 Vite 或者 webpack-dev-server,dev server 给资源下发的也是 no-cache 一类的头,外加模块热替换,连刷新都省了。工具换了,解决的还是同一个问题。
四、强缓存的应用,以及它带来的发布难题
强缓存是前端性能优化最有力的工具,没有之一。对于有大量静态资源的网页,一定要利用强缓存提高响应速度。通常的做法是给这些静态资源全部配置一个超时时间超长的 Expires 或 Cache-Control,这样用户访问网页时只会在第一次加载时从服务器请求静态资源,其它时候只要缓存没有失效并且用户没有强制刷新,都会从自己的缓存中加载。比如前面提到的京东首页,它的缓存过期时间都设置到了 2026 年:

然而这种缓存配置方式会带来一个新的问题,就是发布时资源更新的问题。比如某一张图片,在用户访问第一个版本的时候已经缓存到了用户的电脑上,当网站发布新版本替换了这个图片,已经访问过第一个版本的用户由于缓存的设置,在默认情况下不会请求服务器最新的图片资源,除非他清掉或禁用缓存或者强制刷新,否则就看不到最新的图片效果。
那为什么不干脆把有效期调短一点呢?调短就等于放弃了强缓存的大部分收益,每次访问都要重新校验,首屏又慢回去了。这是个跷跷板,两头都要显然不成立。
真正的解法是换个思路:不要指望让缓存过期,而是让 URL 变掉。文件内容变了,文件名跟着变,浏览器眼里那就是一个从没见过的新资源,压根不存在缓存命中的问题;文件内容没变,文件名也不变,缓存继续用,一年都不用重下。这就是构建产物里那串 app.8f3c1a.js hash 的来历。
这个问题已经有成熟的解决方案,具体内容可阅读知乎这篇文章详细了解:http://www.zhihu.com/question/20790576
文章提到的东西都属于理论上的解决方案,不过现在已经有很多前端工具能够实际地解决这个问题,由于每个工具涉及到的内容细节都有很多,本文没有办法一一深入介绍。有兴趣的可以去了解下 grunt、gulp、webpack、fis 还有 edp 这几个工具,基于这几个工具都能解决这个问题,尤其是 fis 和 edp 是百度推出的前端开发平台,有现成的文档可以参考:
http://fis.baidu.com/fis3/api/index.html
http://ecomfe.github.io/edp/doc/initialization/install/
这几个工具里,现在还活跃的只剩 webpack 这一条线,另外多了 Vite、Rspack 这些新选择。但 hash 文件名这套机制是它们共有的,output.filename 里那个 [contenthash] 就是干这个的,思路和当年 fis 的做法一脉相承。
强缓存还有一点需要注意,通常都是针对静态资源使用,动态资源需要慎用。除了服务端页面可以看作动态资源外,那些引用静态资源的 html 也可以看作是动态资源。如果这种 html 也被缓存,当它更新之后,可能就没有机制能够通知浏览器它有更新了。尤其是前后端分离的应用里,页面都是纯 html 页面,每个访问地址可能都是直接访问 html 页面,这些页面通常不加强缓存,以保证浏览器访问时始终请求服务器最新的资源。
这条规则可以记成一句话:入口文件不缓存,带 hash 的产物往死里缓存。
因为 HTML 是那张指路的地图,它里面写着这次该加载哪几个 hash 文件。地图必须是新的,地图指向的东西可以是旧的,只要地图上没变就说明它确实还能用。如果连 HTML 都被强缓存了,用户手里那张地图指向的还是上个版本的 JS,你在服务器上部署什么都没用。
缓存这块的收益怎么和资源加载顺序配合,我在 关键路径渲染优化 那篇里展开讲过,两者叠在一起用效果比单独用好得多。
五、协商缓存的原理
5.1 命中协商缓存长什么样
当浏览器对某个资源的请求没有命中强缓存,就会发一个请求到服务器,验证协商缓存是否命中。如果协商缓存命中,请求响应返回的 http 状态为 304 并且会显示一个 Not Modified 的字符串。比如你打开京东的首页,按 f12 打开开发者工具,再按 f5 刷新页面,查看 network,可以看到有不少请求就是命中了协商缓存的:

查看单个请求的 Response Header,也能看到 304 的状态码和 Not Modified 的字符串。只要看到这个就说明这个资源是命中了协商缓存,然后从客户端缓存中加载的,而不是服务器最新的资源:

304 的响应没有 body,传的只是几行 header,通常几百个字节。所以协商缓存省的是带宽,省不掉那次网络往返。资源多的页面上,几十个 304 加起来的往返时间照样很可观,这也是为什么带 hash 的资源要用长 max-age 直接跳过这一步,而不是满足于「反正只有 304」。
协商缓存有两对 header 可以用,下面分开看。
5.2 Last-Modified 与 If-Modified-Since
浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 respone 的 header 加上 Last-Modified,这个 header 表示这个资源在服务器上的最后修改时间:

浏览器再次跟服务器请求这个资源时,在 request 的 header 上加上 If-Modified-Since,这个 header 的值就是上一次请求时返回的 Last-Modified 的值:

服务器再次收到资源请求时,根据浏览器传过来的 If-Modified-Since 和资源在服务器上的最后修改时间判断资源是否有变化。没有变化则返回 304 Not Modified,不返回资源内容;有变化就正常返回资源内容。
当服务器返回 304 Not Modified 的响应时,response header 中不会再添加 Last-Modified 的 header,因为既然资源没有变化,那么 Last-Modified 也就不会改变。这是服务器返回 304 时的 response header:

浏览器收到 304 的响应后,就会从缓存中加载资源。如果协商缓存没有命中,浏览器直接从服务器加载资源时,Last-Modified Header 在重新加载的时候会被更新,下次请求时 If-Modified-Since 会启用上次返回的 Last-Modified 值。
这一对 header 都是根据服务器时间返回的,一般来说,在没有调整服务器时间和篡改客户端缓存的情况下,它们配合起来管理协商缓存是非常可靠的。但有时候会出现服务器上资源其实有变化、最后修改时间却没有变化的情况,这种问题很不容易被定位出来,一旦出现就会影响协商缓存的可靠性。
具体是哪些情况会这样?最典型的是修改时间的精度只到秒。一秒之内改两次,第二次改动在 Last-Modified 上完全看不出来。另外有些部署流程会把文件重新写一遍,内容一模一样但 mtime 变了,服务端于是判定「有变化」,把整份资源又传了一次,白花一次带宽。还有一类是资源根本不是磁盘上的文件,而是接口动态生成的,它压根没有「最后修改时间」这个概念。
所以就有了另外一对 header 来管理协商缓存,这对 header 就是 ETag 和 If-None-Match。
5.3 ETag 与 If-None-Match
浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 respone 的 header 加上 ETag。这个 header 是服务器根据当前请求的资源生成的一个唯一标识,是一个字符串,只要资源有变化这个串就不同,跟最后修改时间没有关系,所以能很好地补上 Last-Modified 的短板:

浏览器再次跟服务器请求这个资源时,在 request 的 header 上加上 If-None-Match,这个 header 的值就是上一次请求时返回的 ETag 的值:

服务器再次收到资源请求时,根据当前资源重新生成一个 ETag,拿它跟浏览器传过来的 If-None-Match 比对。两个值相同就说明资源没有变化,返回 304 Not Modified 且不返回资源内容;不同就是有变化,正常返回资源内容。
(原文这一段的描述是「根据浏览器传过来 If-None-Match 和然后再根据资源生成一个新的 ETag」,句子中间断了,这里按实际比对流程重写了一遍。)
与 Last-Modified 不一样的是,当服务器返回 304 Not Modified 的响应时,由于 ETag 重新生成过,response header 中还会把这个 ETag 返回,即使这个 ETag 跟之前的没有变化:

浏览器收到 304 的响应后,就会从缓存中加载资源。
ETag 还有强弱之分。值前面带 W/ 前缀的是弱校验,表示两份资源语义上等价即可,不要求字节完全一致;不带前缀的是强校验,要求字节级完全相同。断点续传这类场景必须用强校验,因为拼接的时候差一个字节都不行。日常静态资源用哪种影响不大,知道有这回事就行。
六、协商缓存的管理,以及两对 header 怎么选
协商缓存跟强缓存不一样。强缓存不发请求到服务器,所以有时候资源更新了浏览器还不知道;协商缓存会发请求到服务器,资源是否更新,服务器肯定知道。
大部分 web 服务器都默认开启协商缓存,而且是同时启用两对 header,比如 apache:

如果没有协商缓存,每个到服务器的请求就都得返回资源内容,服务器的性能会极差。两对 header 一般都是同时启用,这是为了处理 Last-Modified 不可靠的情况。当两者同时存在时,服务端的判定以 ETag 为准,Last-Modified 只作为辅助。
有一种场景需要特别注意,就是多机部署。
分布式系统里多台机器间文件的 Last-Modified 必须保持一致,以免负载均衡到不同机器导致比对失败;同时分布式系统尽量关闭掉 ETag,因为每台机器生成的 ETag 都会不一样。
这个坑的表现很有欺骗性:资源明明没改,用户那边却时不时整份重下一次,命中率忽高忽低,看日志还找不出规律。原因就在于两次请求打到了不同的机器上,ETag 对不上,服务端认为资源变了。Nginx 的 ETag 是用文件 mtime 加 size 拼出来的,只要各台机器的部署时间有差,值就不一样。要么关掉它,要么在 CI 里把产物的 mtime 统一掉。
京东页面的资源请求,返回的 repsones header 就只有 Last-Modified,没有 ETag:

协商缓存需要配合强缓存使用。你看前面这个截图中,除了 Last-Modified 这个 header,还有强缓存的相关 header,因为如果不启用强缓存的话,协商缓存根本没有意义。
为什么这么说?因为浏览器判定强缓存是在前面那一步。如果响应里根本没有 Expires 也没有 Cache-Control,浏览器就没有判定新鲜度的依据,只能走一套启发式的推算(一般是拿 Last-Modified 距今时长的十分之一当有效期),行为在不同浏览器上并不一致。想让缓存策略可预期,强缓存那一组 header 就必须显式写出来。
选型上我的判断是这样:单机或者内容真的会在一秒内多次变动的场景,ETag 有价值;多机部署又用了 hash 文件名的常规前端项目,ETag 带来的麻烦大于收益,关掉它只留 Last-Modified 更省心。这也是京东那张截图给出的答案。
七、刷新方式对缓存的影响
如果资源已经被浏览器缓存下来,在缓存失效之前再次请求时,默认会先检查是否命中强缓存,命中则直接读取缓存;强缓存没有命中则发请求到服务器检查是否命中协商缓存,协商缓存命中就告诉浏览器还是可以从缓存读取,否则才从服务器返回最新的资源。
这是默认的处理方式,而这个方式会被浏览器的行为改变:
- 当
ctrl+f5强制刷新网页时,直接从服务器加载,跳过强缓存和协商缓存 - 当
f5刷新网页时,跳过强缓存,但是会检查协商缓存
补两条现在的观察。强制刷新之所以能穿透两层,是因为浏览器给请求加了 Cache-Control: no-cache 和 Pragma: no-cache 这两个请求头,等于明着告诉链路上所有人「别给我缓存的」。你在 Network 面板里能直接看到这两行。
普通 F5 那条的实际行为要更细一点。浏览器对主文档会强制发起校验请求,但对页面里的子资源,现代 Chrome 并不会一律跳过强缓存,还在有效期内且带了 immutable 的资源会被直接读缓存。这块各浏览器和各版本的处理有出入,我只在 Chrome 上翻过面板,没有逐个版本量过,你要是靠这个行为做判断,建议自己在目标浏览器上验一遍。
还有两种日常操作也值得说。地址栏输入 URL 回车、或者通过链接跳转过来,走的是完整的默认流程,强缓存优先。前进后退按钮更特殊,它走的是回退缓存那一套,很多情况下连 HTTP 缓存都不查,直接把页面连同 JS 状态一起从内存里恢复出来,这也是为什么有些页面点返回之后数据是旧的却不发请求。
八、Service Worker 站在哪一层
前面讲的都是 HTTP 缓存,浏览器自己管,你只能通过 header 去影响它。Service Worker 是另一回事,它把决策权交到了你手里。
位置上,Service Worker 站在页面和网络之间,比 HTTP 缓存更靠前。页面发出的每个请求都会先经过它注册的 fetch 事件,你在那里可以决定:从 Cache Storage 拿一份返回、放行走网络、或者干脆造一个响应回去。只有你选择放行,请求才会往下走到 HTTP 缓存那一层,再按前面讲的强缓存、协商缓存顺序判定。
所以完整的判定链其实是四层:Service Worker,然后内存缓存,然后磁盘缓存(强缓存加协商缓存都在这一层),最后才是网络。
它和 HTTP 缓存的分工也很清楚。HTTP 缓存是声明式的,你写下规则,剩下的交给浏览器,好处是省心,坏处是不可编程,比如做不到「网络失败时回退到本地旧版本」。Service Worker 是命令式的,离线可用、请求兜底、按路由分策略这些都能写出来,代价是你要自己处理版本更新和缓存清理,写错了会把用户永久卡在旧版本上。
我自己的感受是,Service Worker 别一上来就全站铺。先拿静态资源和离线兜底页试水,业务接口那部分想清楚失效策略再动,否则调试成本比收益高。这块我也还在摸索。
总结
把整条链按判定顺序串一遍,这篇的内容就都在里面了:
| 顺序 | 这一层在做什么 | 关键 header |
|---|---|---|
| 1 | Service Worker 拦截,你的代码说了算 | 无,全靠 fetch 事件里的逻辑 |
| 2 | 强缓存,判断本地那份还新不新鲜 | Cache-Control、Expires |
| 3 | 协商缓存,问服务器变没变 | ETag 加 If-None-Match、Last-Modified 加 If-Modified-Since |
| 4 | 都不命中,完整下载 | 无 |
真正落到配置上,需要记的其实就三条。
带 hash 的静态资源配 Cache-Control: public, max-age=31536000, immutable,一年不回源;HTML 入口配 no-cache,每次校验但通常只回 304,几百字节的代价换来发布即时生效;多机部署关掉 ETag,只留 Last-Modified,避免机器之间对不上导致的重复下载。
最后提醒一句最容易翻车的地方:no-cache 不等于不缓存,no-store 才是。把这两个搞反的后果是完全相反的,前者让你以为关了缓存实际上没关,后者让你的静态资源每次都全量重传。
想把缓存放进更完整的性能优化盘子里看,可以接着读 前端性能优化总结。