前端缓存基础:强缓存、协商缓存和资源更新到底该怎么理解

这次发布之后,有用户反馈页面样式还是旧的。代码确实已经上线,CDN 上也能看到新资源,但对方浏览器里的入口 HTML 还被某个环节缓存着,继续引用着旧的 JS 文件名。排查这类问题最忌讳一上来就让用户清缓存——清了确实能解决眼前这一个人的问题,但解释不了策略到底哪里配错了,明天可能还有下一个人反馈同样的情况。

真正要盯的是强缓存和协商缓存这两层谁在起作用、命中之后到底发没发请求。这两个概念第一次接触容易记混,而且不同资源类型该用哪种缓存方式也不是一套配置能打天下——HTML、JS、CSS、图片、接口数据、字体文件,更新频率和风险都不一样,缓存的难点从来不在字段本身,而在策略怎么按资源类型拆开配。

为什么浏览器要缓存资源

原因很直接:减少重复请求、加快页面加载、降低服务器压力。像 JS、CSS、图片这类静态资源,如果每次访问都重新下载,成本会很高。浏览器缓存就是为了避免这种重复消耗。

缓存命中时,收益不只是少一次网络下载。它还会减少等待时间、降低弱网下的失败概率,并减轻服务器和 CDN 压力。对移动端用户来说,这些收益会更明显。

但缓存收益越大,更新策略越要清楚。缓存命中是好事,命中旧版本就是事故。

什么是强缓存

强缓存的意思是:浏览器认为本地资源还有效,所以直接使用,不再向服务器发请求。

常见相关字段有:

  • Cache-Control
  • Expires

只要资源还在有效期内,浏览器就会直接从本地取。

这也是为什么有时你明明改了线上文件,但用户访问页面却看不到最新效果,因为浏览器根本没再请求服务器。

现在更常用的是 Cache-Control

1Cache-Control: max-age=31536000

max-age 表示资源在多少秒内有效。Expires 是一个具体过期时间,受客户端时间影响更明显,所以现代项目通常优先看 Cache-Control

还有几个常见指令:

  • public:资源可以被浏览器和中间缓存缓存
  • private:只允许用户浏览器缓存
  • no-cache:可以缓存,但使用前必须向服务器确认
  • no-store:不存储任何缓存
  • immutable:有效期内资源不会变化

no-cache 很容易被误解。它不是“不缓存”,而是“每次使用前都要协商”。真正不存储是 no-store

这些指令到底配没配上,curl -I 看响应头一眼就知道,不用猜:

1curl -I https://your-cdn.com/app.a1b2c3.js
2# 期望看到:Cache-Control: public, max-age=31536000, immutable
3
4curl -I https://your-site.com/index.html
5# 期望看到:Cache-Control: no-cache(或 no-store / max-age=0)

几个指令的真实差异也都能在响应头里对上号:max-age=N 在有效期内浏览器压根不发请求;no-cache 会缓存但每次带条件请求去协商(你会看到后续请求带 If-None-Match);no-store 连存都不存、每次都是完整下载;immutable 则告诉浏览器有效期内连协商都免了,用户点刷新也不会发条件请求。配错了——比如本想长缓存却写成 no-store——curl 一打就露馅,比线上「感觉好像没缓存」靠谱得多。

什么是协商缓存

协商缓存的意思是:浏览器会先去问服务器,这个资源有没有变。

如果服务器确认没变,就返回一个轻量响应,告诉浏览器继续用本地版本。

常见相关字段有:

  • Last-Modified / If-Modified-Since
  • ETag / If-None-Match

它和强缓存的区别在于,协商缓存会发请求,但不一定重新下载资源内容。

如果资源没变,服务器会返回 304 Not Modified,响应体通常为空。浏览器继续使用本地缓存内容。

ETag 一般比 Last-Modified 更精确,因为文件可能在一秒内变化,也可能内容没变但修改时间变了。实际项目里如果服务端支持,通常优先使用 ETag

两者关系怎么理解

可以简单理解成强缓存优先,强缓存失效后才会走协商缓存。也就是说,一个资源如果还在强缓存有效期内,浏览器通常不会走协商缓存。

流程可以简化成:

  1. 浏览器先看本地缓存是否存在
  2. 如果强缓存还有效,直接使用
  3. 如果强缓存失效,带条件请求问服务器
  4. 服务器返回 304,继续用本地
  5. 服务器返回 200,下载新内容并更新缓存

理解这个流程后,很多“为什么没请求”和“为什么请求了但没下载”的问题就容易解释。

为什么前端项目常用带 hash 的文件名

因为静态资源更新时,最大的难题之一就是“如何让浏览器知道这个资源变了”。

如果文件名不变,浏览器可能继续用旧缓存。

所以构建工具通常会产出像下面这样的文件名:

  • app.a1b2c3.js
  • main.9f8e7d.css

当内容变化时,hash 也会变化,浏览器会把它当成一个新资源重新请求。

这就是为什么现代前端项目常常会把“长缓存 + 文件名 hash”组合在一起使用。

典型策略是:

1Cache-Control: public, max-age=31536000, immutable

用于带内容 hash 的 JS、CSS、字体、图片资源。因为文件内容一变,URL 就变了,所以旧资源缓存很久也没关系。

但入口 HTML 不能这样缓存太久。HTML 负责引用最新资源,如果 HTML 被长缓存,用户可能一直拿到旧的资源地址。

HTML 和静态资源要分开策略

现代前端应用常见做法是:

  • HTML:不强缓存或短缓存,走协商缓存
  • 带 hash 的静态资源:长强缓存
  • 接口数据:根据业务单独设计

例如 HTML 可以使用:

1Cache-Control: no-cache

这表示浏览器可以缓存 HTML,但每次使用前要向服务器确认是否更新。

而静态资源可以使用长缓存:

1Cache-Control: public, max-age=31536000, immutable

这套组合能兼顾性能和更新能力:HTML 及时更新,HTML 引用的新 hash 资源也会被重新下载。

我现在会把缓存策略先写成一张表:

1入口 HTML:no-cache,确保能拿到最新资源引用
2带 hash 的 JS/CSS:长期缓存,immutable
3图片/字体:长期缓存,必要时换 URL
4接口数据:按业务实时性单独设计
5用户私有响应:谨慎缓存,必要时 no-store

有了这张表,排查问题时就不会混在一起讨论“缓存是不是有问题”。不同资源本来就应该有不同策略。

更新不生效的常见原因

1. 文件名没变

资源内容更新了,但 URL 没变,浏览器可能继续命中缓存。

2. HTML 本身缓存策略不合理

很多项目对静态资源做了 hash,却忘了入口 HTML 也需要合理控制缓存。

如果 HTML 还是旧的,那它引用的资源地址也可能还是旧的。

3. 本地调试和生产环境策略混淆

开发环境通常更强调实时更新,生产环境更强调缓存效率。两边策略如果不区分,问题就会变多。

4. CDN 缓存没有刷新

很多线上资源会经过 CDN。你在源站更新了文件,不代表 CDN 节点马上更新。如果 URL 没变,CDN 也可能继续返回旧内容。

这也是为什么静态资源文件名 hash 很重要。相比发布后手动刷新 CDN,改 URL 通常更稳定。

5. Service Worker 缓存

如果项目启用了 PWA 或 Service Worker,还要检查它自己的缓存策略。Service Worker 可以拦截请求,即使浏览器 HTTP 缓存已经更新,它仍然可能返回旧缓存。

排查这类问题时,要同时看 Network 面板和 Application 面板里的 Service Worker、Cache Storage。

我还会看响应来自哪一层:

1浏览器内存缓存
2浏览器磁盘缓存
3Service Worker Cache Storage
4CDN 边缘节点
5源站服务器

很多缓存问题不是浏览器一个地方造成的。尤其是用了 CDN 和 Service Worker 后,资源可能在多层都被缓存。只看 Network 面板里的状态码,有时不够。

用响应头判断问题在哪一层

线上排缓存问题时,我不会只看浏览器里的 200304。浏览器面板会受本地缓存、Disable cache、Service Worker 影响,最好再用命令行直接看响应头。

第一步看入口 HTML:

1curl -I https://example.com/

重点看这几个字段:

1cache-control: no-cache
2etag: "abc123"
3age: 120
4via: 1.1 cdn
5x-cache: HIT

Cache-Control 告诉你源站希望怎么缓存,Age 告诉你这个响应在共享缓存里已经待了多久,Via / X-Cache 这类字段通常能看出是否命中 CDN。不同 CDN 字段名不一样,但思路相同:先判断响应是源站新鲜返回,还是边缘节点直接吐出来的旧内容。

第二步用条件请求验证协商缓存:

1curl -I https://example.com/app.js \
2  -H 'If-None-Match: "abc123"'

如果内容没变,服务端应该返回 304;如果仍然返回 200,要看它是不是生成了新的 ETag,或者中间层有没有把条件请求头吃掉。我遇到过一次 Nginx 前面套了 CDN,源站 ETag 正常,但 CDN 没把 If-None-Match 往后透传,结果协商缓存一直失效。

第三步绕过 CDN 对比源站。如果有源站域名或回源地址,分别请求 CDN 和源站,看 ETagLast-Modified、HTML 里引用的资源 hash 是否一致。CDN 旧、源站新,问题在刷新或缓存 key;两边都旧,问题在发布产物或入口 HTML。

还有一类更隐蔽的 CDN 问题跟 Vary 头有关。如果源站会根据 Accept-EncodingUser-Agent 或者自定义头返回不同内容,但没有正确声明 Vary,CDN 可能会把一个用户的响应缓存下来,返给另一个请求特征不同的用户——比如给不支持 Brotli 的浏览器返回了压缩格式不对的内容,或者移动端和桌面端拿到了对方的响应。排查时如果发现同一个 URL 在不同客户端表现不一致,先看响应头里有没有 Vary,再看 CDN 是不是真的按这个字段区分了缓存 key。

缓存不是越久越好

缓存确实能提升性能,但并不是缓存时间越长越好。

如果更新策略没有设计好,过长缓存会直接变成发布风险。

所以前端缓存真正需要平衡的是性能和可更新性这两件事,还要考虑资源类型:

  • 用户头像、文章封面:可以缓存,但要能跟随 URL 或版本更新
  • 接口数据:通常不能只靠浏览器 HTTP 缓存,需要业务缓存策略
  • 登录态相关响应:谨慎缓存,避免隐私风险
  • 配置文件:如果影响功能开关,缓存时间不能太长

缓存策略不是一套配置打天下。越接近业务实时状态,越要谨慎。

发布时要有缓存验证清单

我现在会把缓存验证放进发布检查里。

至少确认:

  • HTML 响应头是否符合预期
  • 新构建的 JS/CSS 是否带 hash
  • HTML 是否引用了新 hash 资源
  • CDN 是否返回新 HTML
  • Service Worker 是否有更新策略
  • 回滚时旧资源是否仍然可访问

最后一点很容易被忽略。前端发布后,如果发现问题要回滚,旧 HTML 可能会重新引用旧 hash 资源。如果旧资源已经被清理,回滚也会失败。所以静态资源通常不要发布后立刻删除,要保留一段时间。

如何排查缓存问题

浏览器 DevTools 的 Network 面板能看到资源来自哪里。打开面板刷新页面,重点看 Size 列和 Status 列:

  • Size 列是 (memory cache) / (disk cache)、Status 那个 200灰色的:强缓存命中,没发请求。
  • Status 是 304:协商缓存命中,发了请求但响应体为空;点进去看 Request Headers 会有 If-None-Match / If-Modified-Since
  • Status 是实色 200:真正从网络下载了完整内容。

想验证「contenthash + immutable」这套到底生效没,思路很直接:第一次访问,带 hash 的 JS/CSS 应该是实色 200 下载;不改代码刷新页面,它们应该全变成灰色 200 走强缓存(immutable 下连 304 都不该有);改一行业务代码重新构建,那个文件的 hash 变了、URL 变了,浏览器自然把它当新资源重新下载,而没动过的 vendor chunk 文件名不变、继续命中缓存。要是你改了代码但文件名没变,那就是 hash 策略没配对(比如全用了 [hash]),DevTools 里会看到旧 URL 还在走缓存——这就是该回去查构建配置的信号。

排查时可以重点看:

  • 请求 URL 是否带了新 hash
  • 响应头里的 Cache-Control
  • 是否命中 304
  • HTML 是否仍引用旧资源
  • CDN 或 Service Worker 是否参与缓存

不要一上来就让用户清缓存。清缓存能解决眼前问题,但不能解释为什么策略失效。

接口缓存和静态资源缓存不同

接口数据更复杂。比如用户信息、订单状态、消息数量,这些数据有业务实时性要求,不能简单设置一年强缓存。

有些接口可以短缓存,例如公共配置、地区列表、字典项;有些接口则应该禁止存储:

1Cache-Control: no-store

尤其是包含个人隐私或安全信息的响应,不应该被共享缓存或长期存储。

接口缓存还要和前端状态缓存区分。React Query、SWR、本地 store、浏览器 HTTP 缓存解决的问题不同。不要因为前端状态库里有缓存,就忽略 HTTP 响应头;也不要指望 HTTP 缓存解决所有业务数据刷新问题。

我现在排查“用户还在看旧代码”,不会先让他清缓存。

先看入口 HTML 有没有被长缓存,再看 HTML 里引用的 JS/CSS 是否带新 hash;接着看响应头里的 Cache-ControlETagLast-Modified,确认是强缓存命中、协商缓存 304,还是 CDN/Service Worker 在中间兜住了旧资源。只有把这条链看清楚,才知道策略到底错在哪。

设计缓存时我会先分资源:入口 HTML 短缓存或不强缓存,带 hash 的 JS/CSS 长缓存,图片和字体按更新频率配置,接口数据按业务实时性单独处理。缓存做得好,用户访问会更快;更新策略没想清楚,缓存就会变成发布风险。