浏览器 / 前端性能 / HTTP
前端 HTTP 缓存实战:Cache-Control、ETag 与版本化资源
前端缓存问题常常表现为两个极端:静态资源每次刷新都重新下载,页面慢且浪费流量;新版本已经上线,用户却仍看到旧 HTML,甚至出现“旧页面引用新脚本”或“新页面引用已删除资源”的白屏。
HTTP 缓存不是简单的“开或关”。正确策略要区分 HTML、带内容哈希的静态资源、公开接口和私密响应,再组合 Cache-Control、验证器与稳定的发布流程。
一、先区分新鲜缓存与协商缓存
浏览器收到可缓存响应后,会保存内容和缓存元数据。下一次请求时大致有两条路径:
- 缓存仍然新鲜,直接复用,不访问服务器。
- 缓存需要验证,携带 ETag 或 Last-Modified 请求服务器;内容没变时返回 304。
两者都叫“用了缓存”,但网络成本不同。新鲜缓存最快;304 不传完整响应体,仍然需要一次网络往返。
二、Cache-Control 最重要的几个指令
max-age
1 | Cache-Control: public, max-age=3600 |
响应在 3600 秒内可视为新鲜。浏览器通常可以直接复用。
no-cache
1 | Cache-Control: no-cache |
no-cache 不是“不允许存储”。它表示复用前必须向源站验证。配合 ETag,未变化时可以得到 304。
no-store
1 | Cache-Control: no-store |
不应存储响应。登录凭证、支付结果、包含高度敏感个人数据且不应留在缓存中的响应可以考虑使用。不要为了“保证最新”给所有资源都用 no-store,那会完全失去缓存收益。
private 与 public
1 | Cache-Control: private, max-age=60 |
private 允许浏览器等私有缓存保存,但不允许共享缓存把响应提供给其他用户。包含用户定制内容的页面通常需要 private。
public 则明确允许共享缓存,在 CDN 场景更常见,但仍要结合鉴权、Cookie 和响应内容判断,不能把用户数据误设成 public。
三、带内容哈希的静态资源可以长期缓存
现代构建工具通常生成:
1 | app.4f9c2a1.js |
文件内容变化,文件名也变化。旧文件可以放心长期缓存:
1 | Cache-Control: public, max-age=31536000, immutable |
immutable 告诉客户端在新鲜期内不必为了刷新动作重新验证。它适合内容寻址资源,不适合固定文件名的 app.js。如果固定 URL 的内容会更新,却缓存一年,用户就可能一年拿不到新版本。
长期缓存需要发布系统保留一段时间的旧资源。新 HTML 部署后,已打开的旧页面可能仍会动态加载旧 chunk;立即删除所有旧文件会造成运行时 404。
四、HTML 通常需要每次验证
入口 HTML 决定当前应该加载哪些版本化资源,因此不宜像哈希脚本一样缓存一年。常见策略是:
1 | Cache-Control: no-cache |
浏览器可以保存 HTML,但再次访问时先验证。内容未变返回 304,内容变化则拿到新 HTML。
对于必须始终从网络获取且不应存储的页面才使用 no-store。大多数公开站点 HTML 使用 no-cache 加验证器更平衡。
部署顺序也很重要:
- 先上传新的哈希静态资源。
- 确认资源可访问。
- 最后发布引用它们的新 HTML。
- 保留旧资源,直到旧页面自然退出。
这样可以避免 HTML 指向尚未上传的脚本。
五、ETag 与条件请求
服务器可以为响应提供实体标签:
1 | ETag: "a1b2c3" |
客户端再次验证时发送:
1 | If-None-Match: "a1b2c3" |
内容未变化,服务器返回:
1 | 304 Not Modified |
304 不包含完整响应体,浏览器继续使用本地副本。ETag 应由内容或稳定版本生成,不要每次请求都产生不同值,否则验证永远无法命中。
Last-Modified/If-Modified-Since 也能验证,但时间精度和内容语义通常不如 ETag 灵活。两者如何生成应由服务器或 CDN 统一管理。
六、API 响应要按数据性质设计
公开、不频繁变化的商品分类可以短缓存:
1 | Cache-Control: public, max-age=60, stale-while-revalidate=300 |
用户资料则可能是:
1 | Cache-Control: private, no-cache |
一次性敏感响应可能是:
1 | Cache-Control: no-store |
缓存键还要考虑 URL 查询参数、请求头和用户身份。不能只看到 GET 就认为可以 public 缓存。
Vary 告诉缓存哪些请求头会改变响应:
1 | Vary: Accept-Encoding, Accept-Language |
如果服务端按语言返回不同内容,却没有正确 Vary,共享缓存可能把中文响应发给英文用户。Vary 维度过多又会降低命中率,因此只加入真正影响表示的请求头。
七、浏览器 HTTP 缓存和 Service Worker 不是一回事
Service Worker 的 Cache Storage 由应用脚本显式控制;HTTP 缓存由响应头和浏览器策略控制。两者可以同时存在。
如果 Service Worker 使用 cache-first 返回旧文件,修改服务器 Cache-Control 也不一定立即生效,因为请求可能根本没到 HTTP 层。排查时要分别检查:
- 页面是否被 Service Worker 控制;
- Cache Storage 中存了什么;
- 网络请求是否真正发出;
- HTTP 响应头和 Age 是什么;
- CDN 是否还有一层缓存。
不要把所有“刷新后没更新”都归因于浏览器缓存。
八、开发工具中的常见误判
DevTools 的 Disable cache 通常只在工具打开时生效。它适合排除缓存影响,不代表真实用户环境。
强制刷新也不是缓存策略测试。验收时应关闭 Disable cache,按正常访问流程检查:
- 首次加载哪些资源返回 200?
- 第二次加载哪些来自 memory/disk cache?
- HTML 是否发出条件请求并正确获得 304 或新内容?
- 新版本发布后,旧 HTML 是否能安全运行?
- 用户私密响应是否误入共享缓存?
九、一套可直接使用的策略
1 | HTML: no-cache + ETag |
这只是起点,最终仍要根据更新频率、数据风险、CDN 行为和回滚策略调整。
十、上线检查清单
- 静态文件名是否包含内容哈希?
- HTML 是否避免了过长新鲜期?
- no-cache 和 no-store 是否使用在正确语义上?
- ETag 是否稳定,304 是否真实命中?
- 用户数据是否被错误标记为 public?
- Vary 是否覆盖真正影响内容的请求头?
- Service Worker 和 CDN 是否有独立缓存规则?
- 发布时是否先上传资源、后更新 HTML并保留旧 chunk?
- 回滚后旧 HTML 与静态资源是否仍能组合工作?
缓存优化不是把 max-age 调大,而是让每类资源拥有可证明的更新策略。入口文档保持可验证,内容哈希资源长期缓存,私密数据限制共享,再把发布顺序和旧资源保留纳入部署流程,前端才能同时获得速度与更新可靠性。
参考资料
- MDN:HTTP caching — https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching