彻底搞懂Cache-Control缓存控制头的实际应用与常见误区

令狐东俊 优化 阅读 2,829
赞 10 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月上线一个后台管理页,用户反馈“点个按钮要等三四秒才出数据”,我第一反应是“不可能吧?接口就几百毫秒”。结果打开 Network 面板一看——好家伙,每次点“刷新列表”,浏览器都重新拉了一遍 vendor.jsmain.css、连 logo.svg 都在重新 GET。首屏加载 5.2s,二次访问还是 4.8s,缓存形同虚设。

彻底搞懂Cache-Control缓存控制头的实际应用与常见误区

更离谱的是,有个用户在内网用 IE11(别问,问就是政企),点了三次“导出 Excel”,前端发了三次一样的请求,后端直接扛不住,503 了。我盯着瀑布图看了五分钟,心里只有一个念头:这哪是前端优化,这是救火。

找到病灶了!

先甩工具:Chrome DevTools 的 Network → Response Headers 栏,Ctrl+F 搜 cache-control。结果发现所有静态资源返回头都是:

Cache-Control: no-cache, no-store, must-revalidate

再看服务器配置——Node.js + Express,静态文件全走 express.static(),但没传任何 options。翻文档才发现,默认 behavior 是:开发环境开 debug 模式,自动加 no-cache;生产环境也默认不设 max-age,结果 fallback 到 HTTP/1.1 默认规则:不带 cache-control 就算 no-cache(很多老浏览器真这么干)。

另外还顺手查了下 CDN(阿里云 OSS + CDN 加速),CDN 缓存策略配置里写的 “忽略源站 Cache-Control”,但源站压根没给,CDN 就自己瞎猜,最后统一按 60 秒缓存——结果 JS 文件改了,CDN 还在返旧版本,用户白刷新十次。

试了几种方案,最后这个效果最好

一开始想大改:上 Service Worker + precache,搞离线优先。写了半天,发现项目里用了 webpack-dev-server 的热更新,SW 和 HMR 冲突,build 后又一堆 chunkhash 路径问题,折腾两天,放弃。

后来试了 Nginx 层加 header,但部署权限在运维手里,提单排队三天起步,等不及。

最后回到最朴素的路子:**让 Express 自己吐正确的 Cache-Control,并且按资源类型分级控制**。核心就两步:

  • .js.css.woff2 这类带 hash 的构建产物,给强缓存:public, max-age=31536000(1年)
  • .html 和 API 接口,必须协商缓存或不缓存:no-cachemust-revalidate

关键代码就这几行(Express 中间件):

app.use(express.static('dist', {
  // 对带 hash 的静态资源启用强缓存
  setHeaders: (res, path) => {
    if (/.([0-9a-f]{8,}).(js|css|svg|woff2|png|jpg|gif)$/i.test(path)) {
      res.setHeader('Cache-Control', 'public, max-age=31536000');
    } else if (path.endsWith('.html')) {
      // HTML 必须每次都校验,防止 index.html 更新后资源没更新
      res.setHeader('Cache-Control', 'no-cache');
    }
  }
}));

这里注意:我踩过好几次坑,max-age=31536000 看似没问题,但如果用户手动 Ctrl+F5 强刷,浏览器仍会发 If-None-Match 请求去校验 ETag。而 Express 默认对静态文件生成的 ETag 是 weak ETag(W/"..."),有些老 CDN 不认,直接穿透回源。所以后面加了 ETag 强制关闭:

app.use(express.static('dist', {
  etag: false,
  setHeaders: (res, path) => {
    if (/.([0-9a-f]{8,}).(js|css|svg|woff2|png|jpg|gif)$/i.test(path)) {
      res.setHeader('Cache-Control', 'public, max-age=31536000');
      res.setHeader('Expires', new Date(Date.now() + 31536000 * 1000).toUTCString());
    } else if (path.endsWith('.html')) {
      res.setHeader('Cache-Control', 'no-cache, must-revalidate');
    }
  }
}));

另外补充一点:Webpack 构建时,确保 filename 里有 contenthash。我们用的是:

// webpack.config.js
output: {
  filename: 'js/[name].[contenthash:8].js',
  chunkFilename: 'js/[name].[contenthash:8].chunk.js',
  assetModuleFilename: 'assets/[name].[contenthash:8][ext]'
}

这样只要文件内容一变,hash 就变,CDN 和浏览器缓存自然失效,不用手动清缓存。

优化后:流畅多了

上线当天我就蹲着看监控。首屏加载时间从平均 5.2s 直降到 820ms(实测 780~860ms 区间)。二次访问直接掉到 320ms 左右——因为 vendor.jsmain.css 全走内存缓存(memory cache),Network 里那几条直接标灰了。

更爽的是用户反馈少了。之前每天至少三条“页面卡死”工单,优化后一周就一条,还是因为用户点了 F5 刷新导致 index.html 拿到了旧版(我们后来加了个简单的版本检查弹窗,略过不表)。

CDN 命中率从 43% 拉到 91%,回源请求降了 87%。运维同事微信问我:“你干啥了?OSS 流量突然少了一半。”

性能数据对比

以下是同一台测试机(MacBook Pro M1,Chrome 124)、同一网络环境(公司内网千兆)下的三次平均值:

指标 优化前 优化后 提升
首屏加载(DOMContentLoaded) 5210ms 820ms ↓ 84%
资源请求数(首屏) 47 22 ↓ 53%
静态资源总大小(首屏) 4.2MB 1.1MB(首次)→ 0KB(二次) ↓ 100%(二次访问)
CDN 命中率(日均) 43% 91% ↑ 112%

额外收获:API 接口的 Cache-Control: no-cache 加上之后,之前偶发的“点两次导出按钮触发两次任务”的问题也消失了——因为浏览器不再复用响应,每次请求都真实走到后端,后端做了幂等校验,稳了。

踩坑提醒:这三点一定注意

  • 不要给 HTML 加 max-age:哪怕你用了 hash,index.html 本身没 hash,它变了,浏览器缓存里的旧 HTML 还会继续引用老资源路径,页面直接白屏。必须 no-cachemust-revalidate
  • CDN 配置优先级高于源站:我们阿里云 CDN 有个“强制缓存”开关,一开就把源站的 Cache-Control 给覆盖了。上线前一定要关掉,让 CDN 尊重源站 header。
  • 本地开发别被 dev-server 带偏:webpack-dev-server 默认加 Cache-Control: no-store,这是故意的。别看到本地没缓存就慌,build 后看 dist 目录的真实输出。

以上是我踩坑后的总结,希望对你有帮助

这个方案不是最炫的(没上 SW,没搞 Brotli+CDN 边缘计算),但它是最快落地、风险最低、效果最稳的。上线一周没出任何缓存相关 bug,老板路过我工位说“最近页面快多了”,我就知道这事成了。

当然也有不完美的地方:比如图片资源如果没加 hash,还是得靠 max-age=604800(7天)这类折中方案;还有部分老旧 Android WebView 对 immutable 支持不好,我们干脆没加——能省事就省事。

以上是我个人对 Cache-Control 的实战优化经验,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多,比如配合 stale-while-revalidate 做渐进式更新,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
A. 倩云
A. 倩云 Lv1
学到的思路和方法,会对我接下来的职业发展有很大帮助,感谢作者的分享。
点赞
2026-02-18 22:25