彻底搞懂Cache-Control缓存控制头的实际应用与常见误区
优化前:卡得不行
上个月上线一个后台管理页,用户反馈“点个按钮要等三四秒才出数据”,我第一反应是“不可能吧?接口就几百毫秒”。结果打开 Network 面板一看——好家伙,每次点“刷新列表”,浏览器都重新拉了一遍 vendor.js、main.css、连 logo.svg 都在重新 GET。首屏加载 5.2s,二次访问还是 4.8s,缓存形同虚设。
更离谱的是,有个用户在内网用 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-cache或must-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.js、main.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-cache或must-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 做渐进式更新,后续会继续分享这类博客。
