导航
导航
文章目录󰁋
  1. 一、先把整条判定链走一遍
  2. 二、强缓存的原理
    1. 2.1 命中强缓存在面板上长什么样
    2. 2.2 Expires 缓存原理
    3. 2.3 Cache-Control 缓存原理
    4. 2.4 Cache-Control 各个指令到底在管什么
  3. 三、强缓存怎么配,以及开发时怎么绕开它
  4. 四、强缓存的应用,以及它带来的发布难题
  5. 五、协商缓存的原理
    1. 5.1 命中协商缓存长什么样
    2. 5.2 Last-Modified 与 If-Modified-Since
    3. 5.3 ETag 与 If-None-Match
  6. 六、协商缓存的管理,以及两对 header 怎么选
  7. 七、刷新方式对缓存的影响
  8. 八、Service Worker 站在哪一层
  9. 总结
  10. 参考

浏览器缓存原理总结 强缓存与协商缓存的判定顺序

发一次版,用户说页面还是老的,同事回一句「让他清一下缓存」。这话你大概率也听过,甚至自己说过。可「清缓存」并不是解法,它只是把一次配置失误的成本转嫁给了用户。浏览器缓存这套机制本身不算复杂,难的是它由好几层拼起来,强缓存、协商缓存、刷新行为、Service Worker,每一层都能单独让你的资源该更新的时候不更新,或者不该重下的时候重下一遍。这篇把这条判定链从头走到尾,讲清每个 header 卡在哪一步、no-cacheno-store 差在哪、ETagLast-Modified 该留哪个、为什么 HTML 通常不配强缓存。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 浏览器拿到一个 URL 之后,缓存判定的完整顺序是什么
  • 强缓存怎么工作,Expires 为什么被 Cache-Control 取代
  • Cache-Control 各个指令的实际效果,no-cacheno-store 到底哪个才是真不缓存
  • 协商缓存的两对 header,Last-ModifiedIf-Modified-SinceETagIf-None-Match
  • 分布式部署下为什么建议关掉 ETag,京东为什么只留 Last-Modified
  • 强缓存配长了怎么解决发布更新问题,为什么 HTML 要单独对待
  • F5Ctrl+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 Network 面板中命中强缓存的请求,size 列显示 from cache

这里补一句现在的情况。新版 Chrome 把这一列拆成了两种显示,from memory cachefrom disk cache。前者存在渲染进程的内存里,关掉标签页就没了,速度最快;后者落在磁盘上,跨会话也在。你看到的是哪一种,取决于资源大小和当前内存压力,不用去纠结,两种都属于强缓存命中,都没有发出网络请求。

强缓存是利用 Expires 或者 Cache-Control 这两个 http response header 实现的,它们都用来表示资源在客户端缓存的有效期。

2.2 Expires 缓存原理

Expireshttp1.0 提出的一个表示资源过期时间的 header,它描述的是一个绝对时间,由服务器返回,用 GMT 格式的字符串表示,如:Expires:Thu, 31 Dec 2037 23:55:55 GMT

浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 responeheader 加上 Expires

服务器响应头中返回的 Expires 字段,值是一个 GMT 格式的绝对时间

拿到响应之后,浏览器会把这个资源连同所有 response header 一起缓存下来。这一点很容易被忽略,缓存命中的那次请求,你在面板里看到的 header 并不是这一次从服务器拿的,而是来自之前那次真实请求存下来的副本。所以有时候你改了服务端的响应头,本地面板里却还是老值,不是服务端没生效,是你在看一份快照。

浏览器再请求这个资源时,先从缓存中寻找,找到之后拿出它的 Expires 跟当前的请求时间比较,请求时间在 Expires 指定的时间之前就命中缓存,否则就不行。如果缓存没有命中,浏览器直接从服务器加载资源,Expires Header 会在重新加载的时候被更新。

Expires 的毛病出在「绝对时间」这三个字上。它由服务器生成,却由客户端来比对,两边的时钟只要对不上,判定结果就跑偏。用户手动把系统时间往前调一年,站点上所有静态资源当场全部过期;往后调,本该更新的资源可能永远不更新。这种问题在客服那边听到的描述往往是「我这台电脑就是不刷新」,排查起来相当劝退。

所以在 http1.1 的时候,提出了一个新的 header,就是 Cache-Control,这是一个相对时间,在配置缓存的时候,以秒为单位,用数值表示,如:Cache-Control:max-age=315360000

2.3 Cache-Control 缓存原理

流程跟 Expires 那套是一样的。浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 responeheader 加上 Cache-Control

服务器响应头中返回的 Cache-Control 字段,用 max-age 指定相对秒数

浏览器同样把资源连同所有 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 headerExpiresCache-Control 同时存在时,Cache-Control 优先级高于 Expires

响应头中 Expires 与 Cache-Control 同时存在,浏览器以 Cache-Control 为准

同时下发两个的写法现在还很常见,纯粹是给早年只认 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-cacheno-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。超过一年的部分很多实现会自行截断,写得再大也没有额外收益。

三、强缓存怎么配,以及开发时怎么绕开它

强缓存的开关通常有两种设法,一种是在代码里给响应加 ExpiresCache-Control Header,另一种是配置 web 服务器,让它在响应资源的时候统一加上。

比如在 javaweb 里面,我们可以使用类似下面的代码设置强缓存。这段是原文里的写法,用来演示「在业务代码里手工塞缓存头」这件事:

java.util.Date date = new java.util.Date();    
response.setDateHeader("Expires",date.getTime()+20000); //Expires:过时期限值
response.setHeader("Cache-Control", "public"); //Cache-Control来控制页面的缓存与否,public:浏览器和缓存服务器都可以缓存页面信息;
response.setHeader("Pragma", "Pragma"); //Pragma:设置页面是否缓存,为Pragma则缓存,no-cache则不缓存

上面第三行原文的注释有点问题,这里要纠正一下。Pragmahttp1.0 的遗留字段,规范里只定义了 no-cache 这一个值,写成 Pragma 这种自造值没有任何标准行为,各家实现直接忽略。真要兼容老客户端,只写 Pragma: no-cache 就够了,而且今天基本已经没必要写。

反过来,关掉强缓存的老写法是这样:

response.setHeader( "Pragma", "no-cache" );   
response.setDateHeader("Expires", 0);
response.addHeader( "Cache-Control", "no-cache" );//浏览器和缓存服务器都不应该缓存页面信息

这三行是同一件事说三遍,分别照顾 http1.0Pragmahttp1.0Expireshttp1.1Cache-Control。现在的项目一般在网关或者静态服务器层统一配,业务代码里很少再手写这几行了。

nginxapache 作为专业的 web 服务器,都有专门的配置文件,可以配置 expirescache-control,搜索 nginx 设置 expires cache-controlapache 设置 expires cache-control 都能找到不少相关的文章。

顺着上面聊,开发阶段最烦人的一件事就是浏览器默认会缓存图片、cssjs 这些静态资源,改完代码刷新看不到效果。常用的破解办法有这么几种。

ctrl+f5 是最直接的,它能解决页面直接引用的资源更新的问题。或者用浏览器的隐私模式开发,每次开一个干净的会话。

如果用的是 chrome,可以 f12network 那里把缓存给禁掉,这是个非常有效的方法:

Chrome DevTools Network 面板勾选 Disable cache 关闭缓存

要注意这个勾选框只在 DevTools 打开时生效,关掉面板就恢复正常缓存了。这个我踩过,调完样式顺手关掉面板,再刷新又变回旧的,白排查了一会儿。

第二类办法是给 URL 加干扰。在开发阶段给资源加上一个动态的参数,如 css/index.css?v=0.0001。这招的麻烦在于每次资源修改都要更新引用的位置并同时改参数值,操作起来不方便,除非你是在 jsp 这类动态页面里开发,可以用服务器变量来解决(v=${sysRnd}),或者用前端构建工具来处理这个参数修改的问题。同理,ajax 请求的缓存问题也可以靠给请求地址追加随机数解决,动态设置 iframesrc 时也一样,在 src 后面添加随机数能绕开。

如果资源引用的页面被嵌入到了一个 iframe 里面,可以在 iframe 的区域右键单击重新加载该页面,以 chrome 为例:

在 iframe 区域右键选择重新加载框架,单独刷新内嵌页面

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

本地静态开发服务器返回的响应头中 cache-control 被设置为不缓存

gruntgulp 这两个名字现在基本退出主流了,不过这个思路一点没变。今天用 Vite 或者 webpack-dev-server,dev server 给资源下发的也是 no-cache 一类的头,外加模块热替换,连刷新都省了。工具换了,解决的还是同一个问题。

四、强缓存的应用,以及它带来的发布难题

强缓存是前端性能优化最有力的工具,没有之一。对于有大量静态资源的网页,一定要利用强缓存提高响应速度。通常的做法是给这些静态资源全部配置一个超时时间超长的 ExpiresCache-Control,这样用户访问网页时只会在第一次加载时从服务器请求静态资源,其它时候只要缓存没有失效并且用户没有强制刷新,都会从自己的缓存中加载。比如前面提到的京东首页,它的缓存过期时间都设置到了 2026 年:

京东首页静态资源的响应头,过期时间被设置到 2026 年

然而这种缓存配置方式会带来一个新的问题,就是发布时资源更新的问题。比如某一张图片,在用户访问第一个版本的时候已经缓存到了用户的电脑上,当网站发布新版本替换了这个图片,已经访问过第一个版本的用户由于缓存的设置,在默认情况下不会请求服务器最新的图片资源,除非他清掉或禁用缓存或者强制刷新,否则就看不到最新的图片效果。

那为什么不干脆把有效期调短一点呢?调短就等于放弃了强缓存的大部分收益,每次访问都要重新校验,首屏又慢回去了。这是个跷跷板,两头都要显然不成立。

真正的解法是换个思路:不要指望让缓存过期,而是让 URL 变掉。文件内容变了,文件名跟着变,浏览器眼里那就是一个从没见过的新资源,压根不存在缓存命中的问题;文件内容没变,文件名也不变,缓存继续用,一年都不用重下。这就是构建产物里那串 app.8f3c1a.js hash 的来历。

这个问题已经有成熟的解决方案,具体内容可阅读知乎这篇文章详细了解:http://www.zhihu.com/question/20790576

文章提到的东西都属于理论上的解决方案,不过现在已经有很多前端工具能够实际地解决这个问题,由于每个工具涉及到的内容细节都有很多,本文没有办法一一深入介绍。有兴趣的可以去了解下 gruntgulpwebpackfis 还有 edp 这几个工具,基于这几个工具都能解决这个问题,尤其是 fisedp 是百度推出的前端开发平台,有现成的文档可以参考:

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,可以看到有不少请求就是命中了协商缓存的:

Chrome Network 面板中一批返回 304 Not Modified 的请求

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

单个请求的响应头,状态码 304 Not Modified

304 的响应没有 body,传的只是几行 header,通常几百个字节。所以协商缓存省的是带宽,省不掉那次网络往返。资源多的页面上,几十个 304 加起来的往返时间照样很可观,这也是为什么带 hash 的资源要用长 max-age 直接跳过这一步,而不是满足于「反正只有 304」。

协商缓存有两对 header 可以用,下面分开看。

5.2 Last-Modified 与 If-Modified-Since

浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 responeheader 加上 Last-Modified,这个 header 表示这个资源在服务器上的最后修改时间:

响应头中的 Last-Modified 字段,表示资源在服务器上的最后修改时间

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

请求头中的 If-Modified-Since 字段,值来自上次响应的 Last-Modified

服务器再次收到资源请求时,根据浏览器传过来的 If-Modified-Since 和资源在服务器上的最后修改时间判断资源是否有变化。没有变化则返回 304 Not Modified,不返回资源内容;有变化就正常返回资源内容。

当服务器返回 304 Not Modified 的响应时,response header 中不会再添加 Last-Modifiedheader,因为既然资源没有变化,那么 Last-Modified 也就不会改变。这是服务器返回 304 时的 response header

返回 304 时的响应头,其中不再携带 Last-Modified

浏览器收到 304 的响应后,就会从缓存中加载资源。如果协商缓存没有命中,浏览器直接从服务器加载资源时,Last-Modified Header 在重新加载的时候会被更新,下次请求时 If-Modified-Since 会启用上次返回的 Last-Modified 值。

这一对 header 都是根据服务器时间返回的,一般来说,在没有调整服务器时间和篡改客户端缓存的情况下,它们配合起来管理协商缓存是非常可靠的。但有时候会出现服务器上资源其实有变化、最后修改时间却没有变化的情况,这种问题很不容易被定位出来,一旦出现就会影响协商缓存的可靠性。

具体是哪些情况会这样?最典型的是修改时间的精度只到秒。一秒之内改两次,第二次改动在 Last-Modified 上完全看不出来。另外有些部署流程会把文件重新写一遍,内容一模一样但 mtime 变了,服务端于是判定「有变化」,把整份资源又传了一次,白花一次带宽。还有一类是资源根本不是磁盘上的文件,而是接口动态生成的,它压根没有「最后修改时间」这个概念。

所以就有了另外一对 header 来管理协商缓存,这对 header 就是 ETagIf-None-Match

5.3 ETag 与 If-None-Match

浏览器第一次跟服务器请求一个资源,服务器在返回这个资源的同时,在 responeheader 加上 ETag。这个 header 是服务器根据当前请求的资源生成的一个唯一标识,是一个字符串,只要资源有变化这个串就不同,跟最后修改时间没有关系,所以能很好地补上 Last-Modified 的短板:

响应头中的 ETag 字段,是服务器根据资源内容生成的唯一标识

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

请求头中的 If-None-Match 字段,值来自上次响应的 ETag

服务器再次收到资源请求时,根据当前资源重新生成一个 ETag,拿它跟浏览器传过来的 If-None-Match 比对。两个值相同就说明资源没有变化,返回 304 Not Modified 且不返回资源内容;不同就是有变化,正常返回资源内容。

(原文这一段的描述是「根据浏览器传过来 If-None-Match 和然后再根据资源生成一个新的 ETag」,句子中间断了,这里按实际比对流程重写了一遍。)

Last-Modified 不一样的是,当服务器返回 304 Not Modified 的响应时,由于 ETag 重新生成过,response header 中还会把这个 ETag 返回,即使这个 ETag 跟之前的没有变化:

返回 304 时的响应头,其中仍然携带 ETag

浏览器收到 304 的响应后,就会从缓存中加载资源。

ETag 还有强弱之分。值前面带 W/ 前缀的是弱校验,表示两份资源语义上等价即可,不要求字节完全一致;不带前缀的是强校验,要求字节级完全相同。断点续传这类场景必须用强校验,因为拼接的时候差一个字节都不行。日常静态资源用哪种影响不大,知道有这回事就行。

六、协商缓存的管理,以及两对 header 怎么选

协商缓存跟强缓存不一样。强缓存不发请求到服务器,所以有时候资源更新了浏览器还不知道;协商缓存会发请求到服务器,资源是否更新,服务器肯定知道。

大部分 web 服务器都默认开启协商缓存,而且是同时启用两对 header,比如 apache

Apache 默认同时启用 Last-Modified 和 ETag 两套协商缓存

如果没有协商缓存,每个到服务器的请求就都得返回资源内容,服务器的性能会极差。两对 header 一般都是同时启用,这是为了处理 Last-Modified 不可靠的情况。当两者同时存在时,服务端的判定以 ETag 为准,Last-Modified 只作为辅助。

有一种场景需要特别注意,就是多机部署。

分布式系统里多台机器间文件的 Last-Modified 必须保持一致,以免负载均衡到不同机器导致比对失败;同时分布式系统尽量关闭掉 ETag,因为每台机器生成的 ETag 都会不一样。

这个坑的表现很有欺骗性:资源明明没改,用户那边却时不时整份重下一次,命中率忽高忽低,看日志还找不出规律。原因就在于两次请求打到了不同的机器上,ETag 对不上,服务端认为资源变了。Nginx 的 ETag 是用文件 mtime 加 size 拼出来的,只要各台机器的部署时间有差,值就不一样。要么关掉它,要么在 CI 里把产物的 mtime 统一掉。

京东页面的资源请求,返回的 repsones header 就只有 Last-Modified,没有 ETag

京东资源的响应头,只有 Last-Modified 没有 ETag

协商缓存需要配合强缓存使用。你看前面这个截图中,除了 Last-Modified 这个 header,还有强缓存的相关 header,因为如果不启用强缓存的话,协商缓存根本没有意义。

为什么这么说?因为浏览器判定强缓存是在前面那一步。如果响应里根本没有 Expires 也没有 Cache-Control,浏览器就没有判定新鲜度的依据,只能走一套启发式的推算(一般是拿 Last-Modified 距今时长的十分之一当有效期),行为在不同浏览器上并不一致。想让缓存策略可预期,强缓存那一组 header 就必须显式写出来。

选型上我的判断是这样:单机或者内容真的会在一秒内多次变动的场景,ETag 有价值;多机部署又用了 hash 文件名的常规前端项目,ETag 带来的麻烦大于收益,关掉它只留 Last-Modified 更省心。这也是京东那张截图给出的答案。

七、刷新方式对缓存的影响

如果资源已经被浏览器缓存下来,在缓存失效之前再次请求时,默认会先检查是否命中强缓存,命中则直接读取缓存;强缓存没有命中则发请求到服务器检查是否命中协商缓存,协商缓存命中就告诉浏览器还是可以从缓存读取,否则才从服务器返回最新的资源。

这是默认的处理方式,而这个方式会被浏览器的行为改变:

  • ctrl+f5 强制刷新网页时,直接从服务器加载,跳过强缓存和协商缓存
  • f5 刷新网页时,跳过强缓存,但是会检查协商缓存

补两条现在的观察。强制刷新之所以能穿透两层,是因为浏览器给请求加了 Cache-Control: no-cachePragma: 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-ControlExpires
3 协商缓存,问服务器变没变 ETagIf-None-MatchLast-ModifiedIf-Modified-Since
4 都不命中,完整下载

真正落到配置上,需要记的其实就三条。

带 hash 的静态资源配 Cache-Control: public, max-age=31536000, immutable,一年不回源;HTML 入口配 no-cache,每次校验但通常只回 304,几百字节的代价换来发布即时生效;多机部署关掉 ETag,只留 Last-Modified,避免机器之间对不上导致的重复下载。

最后提醒一句最容易翻车的地方:no-cache 不等于不缓存,no-store 才是。把这两个搞反的后果是完全相反的,前者让你以为关了缓存实际上没关,后者让你的静态资源每次都全量重传。

想把缓存放进更完整的性能优化盘子里看,可以接着读 前端性能优化总结

参考