每次发版之后,总有人来问我:“我明明清了缓存,怎么页面还是旧的?”或者说“为什么我手机上是新版本,电脑上打开还是老样式?”。这类问题背后,绕不开的就是资源加载和缓存机制。这篇原理篇,就把浏览器从输入URL到拿到资源的完整路径拆开看一遍,重点讲清楚缓存机制在设计上到底是怎么工作的,以及我们在日常开发里应该怎么利用这些规则,而不是被它们坑。
这个话题适合前端开发者、性能优化工程师,也适合后端同学了解浏览器行为的边界。弄懂这一块,很多线上诡异问题都能一眼定位。
1. 资源加载的完整链路拆解
1.1 从URL输入到资源就绪,浏览器到底做了什么
很多人觉得资源加载就是“浏览器发个请求,服务器返回文件”,但实际链路比这个长得多。我习惯把它拆成四段:
- DNS解析:把域名解析成IP地址。这一步听起来简单,但DNS缓存、DNS预解析、CDN调度都会在这里介入。
- 建立连接:TCP三次握手,HTTPS还要加一次TLS握手。HTTP/1.1有连接复用,HTTP/2有连接多路复用,这个差异会影响多个资源的加载速度。
- 发送HTTP请求:浏览器根据资源类型生成请求头,重点关注
Cache-Control、If-None-Match、If-Modified-Since这些缓存相关字段。 - 接收响应并处理:服务器返回状态码和响应头,浏览器根据响应头决定“是直接用本地缓存,还是把新文件存下来,还是每次都要问服务器”。
其中,缓存机制作用在最后两段之间。浏览器收到响应后,会根据响应头把资源缓存到本地;下一次请求同样资源时,浏览器先问“我这个缓存还能不能用”,而不用傻乎乎地重新下载。
你可以把整个过程理解成去食堂打饭:DNS解析相当于查地图找食堂位置,TCP连接相当于排队走到窗口,HTTP请求相当于递盘子,而缓存机制就是“上次多打了一份放桌上,这次先看看还能不能吃”。剩菜能吃就直接吃,不用再去窗口排队;不确定能不能吃,就端着盘子问阿姨“这份还行不行”,阿姨说行你就端走,说不行你再重新打。
1.2 为什么加载链路和缓存机制是绑在一起看的
我见过不少团队把“资源加载优化”和“缓存配置”分成两件事做,结果就是:加载优化做了图片压缩、代码分割、CDN加速,但缓存策略没跟上,导致用户每次打开页面都要重新下载一堆根本没变化的文件,前面的优化白费了。
反过来也有问题:缓存策略配得太强,文件名不变,资源永远走本地缓存,发版之后用户看到的还是旧页面。
所以这两个话题必须放在一起理解才完整。资源加载链路决定了缓存机制在哪个环节生效,缓存机制反过来决定了加载链路的实际开销。理解了这条链路的全貌,你就知道为什么Cache-Control: max-age=31536000配contenthash文件名是黄金组合,也知道为什么index.html要设成no-cache。
2. 浏览器缓存机制的两大核心
2.1 强缓存:Cache-Control与Expires的取舍
强缓存的意思是:浏览器在缓存有效期内直接使用本地副本,不发任何请求到服务器。控制它的头有两个,一个是Expires(HTTP/1.0产物),一个是Cache-Control(HTTP/1.1标准)。
先看看Expires的写法:
Expires: Wed, 21 Oct 2026 07:28:00 GMT它的问题是,用的是一个绝对时间。服务器时间和用户本地时间不一致的话,缓存判断就会出错。用户把系统时间改早了一小时,缓存就能多活一小时;改晚了,缓存提前失效,又得重新请求。
Cache-Control是现在的主力,它用相对时间,避免了这个坑:
Cache-Control: max-age=3600这就告诉浏览器:这个资源在3600秒内都新鲜,直接用本地缓存就行。
但Cache-Control不只是max-age,它的指令组合才是精髓。举几个我实际配置过的组合:
Cache-Control: no-cache:名字很容易让人误解,它并不是禁止缓存,而是“每次使用前必须去服务器验证一下”。验证通过(返回304)就用缓存,验证不通过就下载新的。这是HTML文件最常见的配置。Cache-Control: no-store:这才是真正的完全不缓存,每次都要完整下载。登录态接口、交易请求建议这么配。Cache-Control: max-age=31536000, immutable:一年内绝对新鲜且不允许更新。搭配带hash的文件名使用,强缓存拉满,性能最优。
我在项目里给静态资源配的一般是最后这种,配完基本不再有“文件没更新”的困扰,因为文件名变了,URL就变了,不存在旧缓存覆盖的问题。
2.2 协商缓存:Last-Modified与ETag是怎么配合的
协商缓存的意思就是:浏览器发请求时带上自己那份缓存文件的“身份证”,服务器看一眼,觉得你那份还能用,就返回304 Not Modified,不返回文件体,节省带宽。
这个“身份证”有两套体系:
第一套基于时间:Last-Modified(服务器返回)和If-Modified-Since(浏览器下次请求时带上)。
响应头: Last-Modified: Tue, 15 Nov 2024 12:45:26 GMT 请求头: If-Modified-Since: Tue, 15 Nov 2024 12:45:26 GMT这套方案有个硬伤:时间粒度只能到秒。文件在1秒内改了两次,第二次修改的时间跟第一次没区别,服务器判断“没变”,返回304,浏览器就拿到了旧内容。另外,服务器和服务器之间时间不同步也会导致判断出错。
第二套基于内容指纹:ETag和If-None-Match。服务器根据文件内容计算出一个哈希值,内容变了,哈希值就变。
响应头: ETag: "33a64df551425df0935f8a6d1a4c4f0a" 请求头: If-None-Match: "33a64df551425df0935f8a6d1a4c4f0a"这里我要多说一句,不同服务器的ETag计算方式不一样,Nginx默认用的是文件修改时间加文件大小算的,所以ETag用得好不好,很依赖服务器配置和框架实现。
下面这个表格能帮你快速记住差异:
| 对比项 | 强缓存 | 协商缓存 |
|---|---|---|
| 请求是否发出 | 不发出,直接本地取 | 发出,但文件体不重复下载 |
| 关键头 | Cache-Control / Expires | Last-Modified / ETag |
| 状态码 | 200 (from cache) | 304 |
| 优点 | 零网络开销,极快 | 保证内容新鲜,节省带宽 |
| 缺点 | 缓存更新不可控 | 仍有HTTP请求往返,延迟高 |
实际场景中,强缓存和协商缓存经常叠加使用。比如给图片配置Cache-Control: max-age=86400,同时让它带ETag。缓存还在有效期时走强缓存;过期之后走协商缓存,304就继续用,内容变了就重新下载。两条链路配合起来,才算是一个完整的缓存策略。
2.3 内存缓存和磁盘缓存的区别
这是很多人忽略的细节。浏览器本地缓存放哪,是有讲究的。Chrome的Network面板里,你能看到两种标识:from memory cache和from disk cache。
from memory cache:资源被缓存在内存里,读取速度极快,但进程关闭就没了。通常是当前页面渲染时需要的脚本、样式、图片。from disk cache:资源被缓存在磁盘上,读取速度相对慢一些,但持久化保留,浏览器重启之后依然有效。大文件、跨会话的资源一般在这里。
浏览器决定放内存还是放磁盘,主要依据资源大小和当前页面的内存压力。这属于浏览器内部策略,我们开发时无法直接控制,但了解它有助于解读Network面板里的现象。比如你刷新页面发现JS走的是from memory cache,关掉标签页重新打开变成from disk cache,这其实是正常的缓存层级变化,不是配置出了问题。
内存缓存和磁盘缓存之间的这个差异还有一个实际影响:同一个资源在内存里读取是不走网络栈的,所以你在Performance面板里看到的资源耗时会有明显差异。你要是没分清这个,排查性能问题时就容易误判。
3. 缓存策略设计与版本更新最佳实践
3.1 静态资源指纹与文件名hash策略
讲缓存绕不开一个经典难题:如何让用户在缓存有效期内尽可能复用资源,同时又在发新版本时第一时间拿到最新代码?
答案是:文件名带上内容哈希。现在Webpack、Vite、Rollup都默认支持这个能力,产物长这样:
app.8f4c3b9a.js vendor.2e7d5c11.js index.c9be0f4a.css文件名里的8f4c3b9a就是内容哈希,文件内容一变,哈希就变,文件名就变,URL就变,对于浏览器来说这就是一个全新的资源,强缓存对它没有任何影响。
这套方案的精髓在于分两层看待:
- HTML文件:配置
Cache-Control: no-cache,每次都要回源验证。文件名变化,HTML里引用的URL就会变成新的,浏览器自然就去请求新文件。 - 静态资源(JS/CSS/图片):配置
Cache-Control: max-age=31536000, immutable,一年内强缓存。因为文件名变了URL才变,文件名不变说明内容真的没变,缓存一年完全没问题。
实际项目里我见过配反的情况:HTML配了max-age=3600,静态资源配了no-cache。结果发版后一小时内用户看到的都是旧页面,静态资源却每次都要回源验证,性能损耗巨大。这个配置顺序千万别弄反。
有人会问,immutable这个指令有什么实际意义?它的作用是告诉浏览器“这个文件在当前版本内不会变”,所以即使用户手动刷新页面(强刷新除外),浏览器也不用重新验证。浏览器默认刷新时会对过期但没过期的资源重新请求?实际上,对于没过期的强缓存资源,普通刷新一般会继续用缓存,但加不加immutable在最老版本的Chrome和Edge里有行为差异。加上没坏处,而且语义明确。
3.2 CDN与缓存的组合打法
静态资源上了CDN之后,缓存链路又多了一层,同时也多挂了几个关键头。CDN节点本质上也遵循HTTP缓存语义,但它和浏览器缓存的关注点不太一样。
这里要搞清楚Cache-Control里一个容易被忽略的指令:s-maxage。
Cache-Control: max-age=3600, s-maxage=86400这个配置的意思分两级:浏览器本地缓存一小时,CDN节点缓存一天。s-maxage优先覆盖max-age,但只对共享缓存(典型代表就是CDN中间节点)生效,个人浏览器会忽略它。
实际使用中,我是这么配静态资源的:
Cache-Control: max-age=31536000, s-maxage=31536000, immutable如果你不希望CDN缓存HTML(因为HTML需要实时感知发版),那就给HTML单独配置:
Cache-Control: no-cache并且让CDN在回源时完全透传这个头,不做覆盖。这里有一个常见的坑:很多团队的CDN平台上默认开启了“强制缓存”或“改写源站缓存头”的选项,源站明明设了no-cache,CDN平台却强制给你设成max-age=600,结果就是发版后10分钟内用户拿到的还是旧HTML。排查这类问题时,别只盯着源站Nginx配置,也要看看CDN控制台里的缓存配置。
使用CDN时,还有一个“缓存键”的概念很关键。CDN默认的缓存键往往是整个URL,但部分云厂商允许自定义缓存键,比如去掉URL里的某个参数、忽略请求头里的某个字段。如果你配置不合理,会导致CDN命中率异常低下。比如你在URL上挂了一个随机的?timestamp=xxx参数,CDN会认为这是无数个不同资源,每一次请求都回源,缓存形同虚设。所以我一直强调:CDN默认元数据不对业务无所谓,但一旦你开始往线上加参数,务必同时确认CDN缓存键的配置能兜住。
3.3 Service Worker与缓存进阶
Service Worker是另一套缓存体系,它在浏览器层面拦截请求,与HTTP缓存的语义基本不同,优先级更高、更灵活。
我自己的项目里用过这么几种策略:
Cache First(缓存优先):命中缓存直接返回,不请求网络。适合图片、图标、稳定的静态资源。
Network First(网络优先):先请求网络,失败或超时后退回缓存。适合页面HTML、需要快速感知更新的数据接口。
Stale While Revalidate(过期时后台更新):立即返回缓存内容,同时后台发起网络请求更新缓存。适合对时效要求不高但希望更新平滑的资源。
Service Worker缓存设计有一个容易踩的坑:如果你使用了Cache First策略,但缓存名字是固定的,比如my-app-assets-v1,那发版之后旧缓存还在,用户看到的还是旧资源。解决办法是把版本号加进缓存名,升级时删除旧缓存:
const CACHE_NAME = 'my-app-assets-v2'; self.addEventListener('install', (event) => { event.waitUntil( caches.open(CACHE_NAME).then((cache) => { return cache.addAll(['/', '/index.css', '/index.js']); }) ); }); self.addEventListener('activate', (event) => { event.waitUntil( caches.keys().then((keys) => { return Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))); }) ); }); self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request).then((cached) => { const fetchPromise = fetch(event.request).then((response) => { if (response.ok) { const clone = response.clone(); caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone)); } return response; }); return cached || fetchPromise; }) ); });这段代码里最重要的是activate时的缓存清理。如果漏了这一步,每次发版之后缓存都在膨胀,而且用户永远拿到旧资源,你排查问题又排查不到,因为Service Worker的缓存跟HTTP缓存还不一样,DevTools里清“清除缓存”不一定能清掉Service Worker的缓存,得专门到Application面板里删。
顺带说一句,Service Worker的fetch事件拦截范围也包括跨域请求,但跨域资源的cache.put有诸多限制,最常见的就是不透明响应(opaque response)也可以缓存,但状态码是0,无法读取内容。如果你盲目把跨域资源缓存进Service Worker,可能出现缓存了但无法验证内容的情况,建议只对同源请求做完整的Network First策略。
4. 常见问题与排查技巧实录
4.1 发版后用户还在用旧资源:三分钟定位
这是我被问得最多的问题。按照下面的思路排查,基本能解决九成的情况:
第一步,看HTML有没有被缓存。打开线上首页,打开DevTools的Network面板,刷新页面,找到index.html的请求。如果你看到状态码是200且Size列显示from memory cache或from disk cache,说明HTML被强缓存了。正常应该是200且走网络,或者304 Not Modified。
这里插一句,很多人会混淆200-from cache和304。强缓存命中时,状态码是200,但文件不是从网络回来的,所以Size列会标注from cache;协商缓存返回304时,也是200?不对,304就是304,Not Modified,没有文件体。看到200 (from cache),直接定位到HTML缓存了。
第二步,看静态资源文件名有没有变化。发版后,JS/CSS文件名里的hash应该变化。如果HTML里引用的还是旧hash文件名,说明HTML文件本身没有更新,服务器上大概率是旧文件——先检查部署流程,是不是静态资源目录没有正确覆盖。如果HTML引用了新hash文件名,但请求的还是旧URL,那多半是浏览器缓存了旧的HTML。
第三步,看Cache-Control头。在Network面板里点开资源详情,看Response Headers。如果静态资源配了max-age=31536000但没有hash文件名,那就会造成“内容变了但URL没变”的灾难。这里有且只有一个解法:文件名必须带hash。
4.2 缓存命中率分析与优化思路
排查完问题之后,我们要往优化方向走。缓存命中率这个概念分两块:强缓存命中率和协商缓存命中率。
强缓存命中率看的是Network面板里from cache的资源占比。如果想要更高,优先确认静态资源是否都配置了长缓存和hash名字。Cherry-pick式的调整方式是:把大图片、第三方库、框架运行时这些几乎不变的资源设置成长缓存;把业务代码资源设置成中等缓存(比如max-age=86400),并根据发版频率调整。
协商缓存命中率看的是304请求占比。304依然有一次HTTP往返,所以它不是零成本。如果发现某个资源的304比例极高,说明它的文件内容很长时间没变了,你可以考虑把它的Cache-Control从no-cache改成max-age=86400,甚至更长。这个调整需要测试,不要拍脑袋改。
DevTools里查命中率的另一个技巧是看请求头。如果请求头里有If-None-Match或If-Modified-Since,说明这个资源正在走协商缓存路径;如果什么都没有且状态200,说明走的是网络全量下载。
4.3 接口缓存与页面缓存的差异
很多后端同学问过:为什么我用fetch请求接口,每次都能拿到最新数据,但浏览器开发者工具里有时显示200 (from disk cache)?
这里要区分两件事。fetch请求也是普通HTTP请求,它同样受缓存策略约束。浏览器的默认行为是隐式缓存GET响应?不对,准确说,如果没有明确的Cache-Control响应头,浏览器可能不会缓存,也可能根据启发式规则缓存(比如根据Last-Modified推断出一个新鲜期)。这个“隐式缓存”非常坑,因为它不在你的预期里,但确实发生了。
所以,接口的缓存策略必须显式配置:
- 实时性要求高的接口:
Cache-Control: no-store,每次强制请求。 - 可以有短时缓存的接口:比如首页聚合数据,
Cache-Control: max-age=60。 - 详情页、列表页等GET接口:可以用协商缓存,服务端根据ETag返回304。
页面缓存和接口缓存还有一个本质区别:页面缓存处理的是文档,不只是文件内容;接口缓存处理的是数据,可能涉及权限差异。同一个URL如果不同用户看到的内容不同,接口一定不能开强缓存,协商缓存也要谨慎——除非服务端能把用户维度写进缓存键。
4.4 一个我反复使用的调试技巧
最后分享一个我个人一直在用的调试方法:快速验证资源有没有走缓存。
打开DevTools的Network面板,勾选“Disable cache”。这个选项的作用是:页面打开期间,所有请求都不使用浏览器缓存,每次都是完整请求。它和手动刷新不太一样,手动刷新在Chrome里默认其实会复用部分缓存,而勾选Disable cache是从根本绕开浏览器缓存,方便你对比“有缓存”和“无缓存”两套请求行为。
如果你要验证线上资源的实际缓存行为,可以新开一个无痕窗口来测。无痕窗口默认没有旧缓存数据,能帮你排除本地缓存干扰,看清从零开始加载一个资源的完整链路。
另外,我不建议用强刷新(Cmd+Shift+R)代替上面这两种方式来做缓存行为验证。强刷新会发请求头带上Cache-Control: no-cache?实际上强刷新会让Chrome产生一个类似的“绕过缓存”的请求,但它和正常用户的访问行为不一样,所以看到的结果不能代表真实用户环境。
4.5 缓存策略速查表
| 资源类型 | 推荐缓存策略 | 关键配置 |
|---|---|---|
| HTML入口文件 | 协商缓存 | Cache-Control: no-cache |
| JS/CSS(带hash) | 强缓存 | Cache-Control: max-age=31536000, immutable |
| 图片/字体(带hash) | 强缓存 | Cache-Control: max-age=31536000, immutable |
| 无hash的老接口数据 | 短强缓存或协商缓存 | max-age=60 或 ETag |
| 用户登录态/交易接口 | 禁止缓存 | Cache-Control: no-store |
这张表我贴过很多次,基本覆盖了一个常规Web项目的所有资源类型。照着这个表去配Nginx或者云服务商的CDN配置,就能少踩大部分缓存坑。
我自己在这些年的项目里攒下来一条最重要的心得:别指望一个缓存策略适配所有资源。资源类型不同、时效要求不同,缓存策略就该分门别类地配。一套裸配走天下的方案,到最后一定会遇到“要么更新不及时,要么性能上不去”的两难。花半天时间把缓存策略理清楚,后面能省下无数次排查问题的功夫。