做了这么多年前端性能优化,我看别人网站的时候有个改不掉的习惯:打开 DevTools 的 Network 面板,逐个盯外部 JS 的响应头。见得多了就发现,有一类响应头组合出现频率极高——Cache-Control: max-age=31536000,有时候后面还跟着 immutable。不少同学第一次看到这个数字会犯嘀咕:一年 31536000 秒,哪来的?是随手写了个大数,还是背后有讲究?更常见的情况是:有人照着网上的教程配了,结果代码发布后用户死活看不到新版,一通排查下来发现就是这行头惹的祸。
这个标题本身透着一股典型的工程困惑:外部 JS 为什么可以设置 Cache-Control: max-age=31536000?设置之后到底发生了什么?为什么偏偏是 31536000,而不是 86400、2592000,或者干脆不设?这背后其实连着 HTTP 缓存协议、静态资源发布策略、CDN 行为、浏览器强缓存机制一整条链路。这篇文章就从协议到配置、从原理到事故现场,把这行字庖丁解牛式地拆开。适合前端开发、性能优化工程师、运维同学,也适合所有被“缓存不生效”和“缓存不更新”两头折磨的人。
1. 先从现象说起:31536000 到底是什么
1.1 一个普通用户视角的“一年”:打开页面看响应头
假如你随便打开一个大型网站,F12 切到 Network,刷新页面,随便点一个来自第三方域名的 JS 文件,比如统计脚本、广告 SDK、A/B 测试框架,大概率能在 Response Headers 里看到类似下面的内容:
code复制cache-control: public, max-age=31536000, immutable
这里 max-age=31536000 的单位是秒。31536000 秒 = 60 × 60 × 24 × 365 = 整整一年。意思是:这个资源从响应生成那一刻起,在未来一年内都可以被浏览器、CDN、代理服务器直接当成“新鲜”内容使用,不需要回源验证,也不需要重新下载。
你可能会问:一年?这么长不会出问题吗?正常来说,像 jquery.min.js、lodash、埋点 SDK 这类文件名固定、内容极低频变化的库文件,一年缓存非常合理。但这里有一个前提,所有真正做过性能优化的人心里都默认:这个 JS 文件的内容在一年内是“不可变”的,或者至少发布系统能保证内容变化时 URL 也跟着变。
1.2 为什么这类外部 JS 普遍能见到这个响应头
你平时自己开发的业务代码里,很少会看到有人给 app.js 直接配一年缓存。因为业务代码每星期都在改,如果配了一年,用户身上就会残留旧代码,等于发布了一次寂寞。
但“外部 JS”不一样。什么是外部 JS?准确说,是指那些不随你业务版本迭代而频繁变化、以独立脚本形式被页面引用的第三方或公共静态资源。它们的典型特征有三条:文件名长期不变、文件内容很少更新、被大量不同页面甚至不同站点复用。比如:
- 公共 CDN 上的开源库:jQuery、React、Vue 的 UMD 包;
- 第三方服务的 SDK:统计、监控、支付、客服聊天、广告等;
- 自己业务里的跨项目公共包:比如统一的埋点基础库、可视化上报脚本、多端共用的加密 JS。
这类 JS 如果每次刷新页面都重新向服务器请求,对带宽是巨大浪费,对页面加载性能也是伤害。给它们设置一年缓存,浏览器第二次访问时直接读本地缓存,连网络请求都不发,性能收益立竿见影。
1.3 “庖丁解牛”视角:这行头涉及几个技术层次
拆这行响应头,拆开的不仅是浏览器缓存规则。往下挖一层,一共涉及四个层次:
- HTTP 协议层:
Cache-Control是响应头标准里最核心的缓存指令之一,它决定了缓存参与者(浏览器、代理、CDN)如何对待这个资源; - 站点资源配置层:不是所有 JS 都能配一年,资源类型、变更频率、引用方式共同决定策略;
- 发布工程层:配了一年缓存之后,怎么在代码更新时让缓存自动“绕过”,这需要构建工具的产物指纹机制来兜底;
- 边缘节点层:如果资源走了 CDN,
max-age只是给浏览器看的,边缘节点还有自己的缓存时间,TTL 设置不当会出现多级缓存叠加问题。
把这层拆开,你就会发现 31536000 不是终点,它只是整条缓存策略链路的入口。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 浏览器缓存机制里,max-age 站在什么位置
2.1 强缓存和协商缓存的区别,一句话说清
HTTP 缓存从大的方向分两类:一类是强缓存(也叫本地缓存),一类是协商缓存(也叫对比缓存)。Cache-Control: max-age=31536000 管的是强缓存这一侧。
强缓存的核心逻辑是:浏览器在时间窗口内直接使用本地副本,不发任何请求。你在 Network 面板看到 (disk cache) 或者 (memory cache),就是强缓存命中了。协商缓存则是浏览器发现强缓存过期后,带着 If-None-Match 或 If-Modified-Since 去找服务器问一句:这东西变了吗?没变就返回 304,服务器省了传输体积,但请求往来还是有一次。
拿吃饭类比:强缓存是你把饭菜放自家冰箱,保质期内直接热了吃,不用买菜;协商缓存是冰箱里东西到期了,但你不太确定还能不能吃,先打开闻一下,闻着没坏就继续吃。max-age=31536000 的意义,就是把“闻一下”这个过程也省掉整整一年。
2.2 max-age 的数学课:31536000 是怎么算出来的
这个数字的来源其实朴素得很:
code复制60 秒 × 60 分钟 × 24 小时 × 365 天 = 31536000 秒
一年被精确换算成了秒,作为 max-age 的取值。为什么大家不用 365 而是用秒?因为 max-age 指令的单位在 HTTP/1.1 规范里明确是秒。于是很多缓存工具和构建脚手架直接把“一年”换算成了这个看着有点刻意的整数。
那你可能会问:为什么不用 10 年?max-age=315360000 也常见于某些激进策略。但在工程实践里,一年是很多缓存策略的“约定上限”,原因有两方面。第一,RFC 里 Cache-Control 的指令值在历史上对超大值并没有统一的处理说明,某些老旧的代理服务器或中间设备对大数值会解析异常;第二,一年已经足够覆盖绝大多数静态资源的复用周期,更长的时间并不会带来明显的收益增量,反而让出问题时的回退窗口变得不可控。
2.3 同一时间轴上其它指令的博弈
响应头里 Cache-Control 通常不是一个孤独的值。你会看到它和其他指令排列组合出现。常见的搭配有哪些?我列个表方便对照:
| 响应头写法 | 含义 | 场景 |
|---|---|---|
public, max-age=31536000 |
允许所有缓存节点缓存,浏览器一年内强缓存 | 公共库、SDK |
private, max-age=31536000 |
只有浏览器能缓存,中间代理不缓存 | 带用户私密数据的个性化脚本 |
public, max-age=31536000, immutable |
一年强缓存,且用户刷新页面也不会去验证 | 带内容指纹的静态资源 |
no-cache |
每次使用前必须回源验证 | 动态内容、入口 HTML |
no-store |
任何环境都不得存储 | 敏感接口、支付页 |
immutable 这个指令值得单独说。它告诉浏览器:这个资源在过期前一定不会变,所以就算用户手动刷新页面(F5),也不需要发条件请求去问服务器。没有 immutable 的话,很多浏览器在刷新时会重新验证,明明你设了 max-age 一年,结果一刷新还是 304 甚至 200,让人误以为缓存没生效。所以外部 JS 配一年缓存时,带上 immutable 是一个很推荐的做法。
3. 为什么是“外部 JS”,以及哪种外部 JS 配一年才安全
3.1 外部 JS vs 业务 JS:缓存策略绝对不能一刀切
如果给页面里的所有 JS 统一配 Cache-Control: max-age=31536000,那等于给自己埋了一个发布事故的定时炸弹。业务 JS 的发布频率高、改动幅度大,把业务代码缓存一年,意味着用户至少一年内跑的都是旧逻辑。真出这种事故,通常只能靠紧急改 URL 或者等缓存自然过期。
外部 JS 适合长缓存,因为它天然具备三个特征:
- 文件名基本不变,比如
sdk.min.js,老页面也在引用它; - 内容极少变化,即使升级也有明确的灰度窗口;
- 被复用范围广,缓存命中率越高,省下的传输量越大。
从成本模型看,缓存收益公式可以粗略写成:
code复制节省流量 = 文件大小 × (资源访问次数 - 缓存命中次数下的回源次数)
外部 JS 被大量页面共享,访问次数高,回源率一旦降下来,对源站带宽的压力会显著降低。我自己测过一个日 UV 几十万的站点,给统计 SDK 配了一年强缓存之后,回源请求量直接下降了约 40%,这个数据对任何业务都是很有吸引力的。
3.2 不安全的“外部 JS”长什么样:常见反面教材
有一种“外部 JS”配一年缓存是极度危险的——挂在静态服务器上的业务主包,文件名是 main.js,但每次发版内容都变。你给它配一年缓存,部署了新版本,用户请求的还是本地那份旧文件,且浏览器完全不发请求,连 304 的机会都不给。这种配置事故我见到过不止一次,最典型的特征就是:后端已经发布成功了,测试用无痕窗口新开页面能看见新版,但普通用户反馈页面没变化。
还有一类是跨域引用的 JSONP 脚本,文件名里带随机参数或者时间戳,这种本身也不适合用固定长缓存策略,因为 URL 已经变成了动态的,缓存命中率天然很低,设不设一年意义不大。
3.3 判断标准:一张表帮你决定要不要配一年
我自己在评审缓存策略时,有一套很简单的快速判断逻辑,分享出来给你做参考:
| 判断维度 | 适合配 31536000 | 不适合配 31536000 |
|---|---|---|
| 文件是否独立域名分发 | 是 | 否,直接内联在 HTML 里 |
| 文件名是否带内容指纹 | 带可不带仍建议带 | 不带且无法用 query 控制 |
| 变更频率 | 季度级或更低 | 每周都有改动 |
| 被引用页面数 | 大量页面共同引用 | 只有单个页面使用 |
| 更新失败能否接受延迟 | 可以接受一天以上 | 必须分钟级生效 |
如果你负责的业务资源全部命中左边列,那放心大胆设。如果命中右边任意一条,我的建议是:宁可少设一点,也不要为了那点性能把发布流程架在火上烤。
4. 实操:把外部 JS 的 Cache-Control 真正设成 31536000
4.1 Nginx 与 Apache 的配置姿势
最常见的场景是把 JS 文件托管在 Nginx 上。给 JS 单独设置一年缓存的做法是在 server 块里写一个 location 匹配规则:
nginx复制location ~* \.js$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
这里有一个非常容易踩的坑:expires 1y 本身不够。Nginx 的 expires 指令主要生成 Expires 响应头,也就是 HTTP/1.0 时代的过期时间字段。虽然现代浏览器在同时存在 Cache-Control 和 Expires 时会优先使用 Cache-Control,但为了兼容老客户端和代理设备,我们仍然要显式地用 add_header 把完整的 Cache-Control 写出来。
Apache 的话,在 .htaccess 或虚拟主机配置里这么写:
apache复制<FilesMatch "\.(js)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
要注意的是,Apache 默认可能会把多个 Cache-Control 合并成逗号分隔的列表,如果上游已经设置了别的指令,要先用 Header unset Cache-Control 清理,再 set 新的值,避免出现 max-age=31536000 和别的值混在一起变量冲突的情况。
4.2 CDN 和对象存储是重灾区,路径别搞混
外部 JS 当前更多是走 CDN 或对象存储分发的。在 Cloudflare、阿里云 CDN、腾讯云 CDN、AWS CloudFront 这类产品里,缓存设置有“浏览器缓存过期时间”和“CDN 节点缓存过期时间”两个独立概念,这是新手最容易踩混的地方。
浏览器缓存过期时间:对应源站响应头里的 Cache-Control: max-age,CDN 能不能改它取决于平台是否支持回源透传或自定义头部,一般情况下 CDN 不修改,直接透传给浏览器。
节点缓存过期时间:CDN 边缘节点自己决定把这份资源缓存多久,用户请求过来时,如果节点上还有缓存,就直接响应;如果节点缓存过期,就要回源拉一次。
我用一张简化关系说明:
code复制浏览器请求 → CDN 边缘节点(节点缓存 TTL) → 源站(响应头里的 max-age)
如果你只在源站配了 Cache-Control: max-age=31536000,但 CDN 节点缓存只设了 10 分钟,那结果是:CDN 每 10 分钟回源一次,浏览器拿到一年缓存。如果你 CDN 节点缓存设了一年但源站响应头完全没有 max-age,那浏览器可能每次都向 CDN 发请求,CDN 直接返回节点缓存,虽然源站压力也不大,但浏览器侧的性能收益就丢了。
所以真正的“双管齐下”做法是:源站响应头设置一年浏览器缓存,CDN 平台里节点的缓存 TTL 也设置成一年(或者遵循源站)。这样整条链路上,一年之内浏览器不发请求、CDN 不频繁回源,性能与成本同时拿到收益。
4.3 用 curl 验证配置是否真生效
配置完之后不建议直接开浏览器看,先用命令行验证最干净:
bash复制curl -I https://cdn.example.com/libs/sdk.min.js
我期望看到的响应头类似:
code复制HTTP/2 200
content-type: application/javascript
cache-control: public, max-age=31536000, immutable
age: 12345
date: Wed, 06 Mar 2024 12:00:00 GMT
last-modified: ...
注意几个核对点:cache-control 的值是不是你预期的完整字符串;date 有没有异常;如果是二次请求,age 头是否出现并且逐渐增大(age 表示这个资源在缓存节点里已经存活了多少秒,属于正常现象,不是错误);有没有被平台额外追加 cf-cache-status 或者 x-cache 这类标记,这些标记能看出命中情况。
浏览器侧验证可以再开一个无痕窗口,Network 面板勾选“Disable cache”之前先不要勾,正常访问两次页面。第二次如果看到 (disk cache) 或 (memory cache),并且耗时显示为 0ms 附近,就说明强缓存已经生效了。
5. 解牛过程中避不开的坑:三个现场事故复盘
5.1 事故一:业务 JS 被误伤,配置一次翻车
有个朋友的团队把所有静态资源放到同一个 CDN 目录下,运维图省事,写了一条匹配整个静态目录的缓存规则,直接配了 31536000。上线当天没发现异常,因为大家都在用无痕或者强制刷新测试。第二天产品反馈线上页面还是旧版,最后排查到罪魁祸首就是这条 “大而全” 的缓存规则。
复盘后的结论是我一直强调的:缓存配置必须按资源类型拆开,业务入口文件不给长缓存,或者必须配合构建指纹。甚至更安全的做法是:只对文件名里带 hash 的资源给长缓存。构建产物形如 app.8f3k2d9f.js,文件名变了自然就是新资源,旧 URL 即使还在缓存里,也没人再引用了。
5.2 事故二:手动加了 query 版本号,却被 max-age 架空了
还有团队反过来,他们给 JS 配了 max-age=31536000,然后又想通过 app.js?v=20240306 来强制刷新缓存,结果发现完全没用。为什么?因为对浏览器来说,app.js?v=20240306 和没有 query 的 app.js 是两个不同的 URL。旧引用里的 app.js 仍然活在一年缓存里,如果发布后页面 HTML 没有更新到新的 query,那就等于这次发布白干了。
正确的姿势:如果你使用 query 方式控制版本,那就不要设置超长 max-age,一天、一小时都是更稳妥的选择;如果你坚持 max-age=31536000,那就用文件名指纹方案,而不是 query。两套方案不能想当然地混用。
5.3 事故三:代理层缓存叠加导致的长尾问题
资源在浏览器、CDN、公司代理、运营商缓存这四层里都可能被缓存。Cache-Control: max-age=31536000 会层层传递,但每一层都有自己独立的管理策略。一个常见现象是:CDN 已经设置了回源刷新,源站文件也更新了,但某些运营商层级缓存还残留着旧文件,导致部分地区用户看到老版本,而你自己在本地怎么测都测不出来。
应对办法是在紧急回退时不要只刷新 CDN,源站改名才是硬道理。把故障 JS 从 sdk.min.js 换成 sdk.min.v2.js 并把页面引用同步更新,就能绕过所有中间层缓存。这也是为什么我建议关键 JS 文件尽量走独立文件名或完整指纹路径的核心原因——缓存是一个链式系统,源头文件名的变更,是唯一能从根上打破所有缓存的钥匙。
6. 一年缓存之外的思考与检查清单
6.1 设置响应头之后,日常巡检怎么做
配置生效不是工作的结束,只是开始。我把自己的巡检习惯整理成了清单,每次给外部 JS 配置长缓存之后都按这个过一遍:
- 检查响应头是否包含完整的策略值:
Cache-Control: public, max-age=31536000, immutable; - 检查多个静态域名之间策略是否一致,CDN 和源站不能出现互相矛盾的配置;
- 确认 HTML 入口文件没有被缓存,这个是很多人忽略的,入口 HTML 一旦被缓存,后续所有静态资源的更新都会被锁死;
- 确认灰度发布路径下,新老资源能并行工作,旧文件的缓存自然淘汰,不影响线上业务;
- 用真实用户环境的二次访问模拟来观测资源是否命中强缓存,不要只看第一次请求。
6.2 根据我的经验,最后三个建议
第一,配置外部 JS 长缓存之前,先确认这份文件是否“真的外部”。如果它其实跟着你业务发版节奏在变,那无论它放在哪个域名、哪个目录,本质都还是业务代码,不要被表象骗了。要看你自己的发布流水线是不是会动这个文件,只要会动,就必须配合指纹或版本路由。
第二,immutable 不是所有浏览器都认识,但不认识它的浏览器也能正常处理。immutable 属于增强性指令,不识别它的客户端最多把缓存当成普通的 max-age 处理,不会产生副作用。所以只要语法正确,这个值加了稳赚不赔。
第三,真要处理海量老资源的缓存失效问题时,最快的办法是换 URL,而不是去清缓存。我在生产环境的习惯是:前端所有静态资源发布都用带 hash 的文件名,外部引用的 SDK 类文件尽量提供 v1、v2 的路径版本。一旦需要废弃某个版本,直接在入口处切路径,所有用户下一次访问都自动拉到新文件,不依赖任何人的浏览器缓存机制。
缓存这件事很有意思,表面上看是响应头里一两个字段的工程,实际上考验的是你对整条资源链路的理解:HTTP 协议、浏览器行为、CDN 节点、发布流程、灰度回滚,每个环节都会影响最终用户的加载体验。把 Cache-Control: max-age=31536000 从看热闹变成看门道之后,再回头看那些性能优化教程里的“最佳实践”,你会发现一切都有迹可循,不再是别人说什么你抄什么了。记住我前面反复提的那句话:一年缓存是结果,不是起点;真正决定缓存策略的,是文件本身的变更频率和发布系统的兜底能力,这两点想清楚了,别说 31536000,更大的数字你也能驾驭。
