浏览器缓存:为什么用户刷新了,看到的还是旧页面
同一份代码,强刷能看到新版,普通刷新却还是旧页面,这类问题十有八九不是部署没生效,而是缓存策略出了岔子。那次发版翻车的现场就很典型:促销页的 bug 已经修掉,构建和上线都完成了,测试却一直说页面还是旧的。
症状更耐人寻味的是,按住 Shift 强刷,一切正常;再普通刷新,旧页面又回来了。服务器上的文件明明是新的,浏览器却像认定了自己手里的那份 HTML 更可信。最后顺着 Nginx 配置往下查,答案落在一条看起来再普通不过的响应头上:index.html 被配成了 Cache-Control: max-age=86400,浏览器连请求都没重新发,自然也就看不到新的资源引用。
这类问题不把缓存链路理清,很容易修一次、下次再摔同一个坑里。所以我把强缓存、协商缓存、304 和前端发版策略重新串了一遍,重点不是背概念,而是回答一个最实际的问题:为什么用户已经刷新了,看到的却还是旧页面。
浏览器根本没发请求
排查的第一步是打开 Network 面板,重现"刷新还是旧页面"。看到的情况是:index.html 那一行,Size 列写着 from disk cache,状态码是一个灰色的 200。
这就是强缓存。强缓存命中时,浏览器不会向服务器发请求——连一次握手都省了,资源直接从磁盘或内存里拿。这是它和协商缓存最本质的区别。服务器上的文件再新,浏览器压根没来问,自然给用户看旧的。
强缓存靠响应头声明。最常见的:
1Cache-Control: max-age=31536000
表示资源在指定时间内可以直接使用缓存。31536000 是一年的秒数,业界几乎默认的"长缓存"写法。我们的 index.html 被配了 max-age=86400,就是缓存一天。
老一些的写法还有:
1Expires: Wed, 21 Oct 2020 07:28:00 GMT
Expires 是 HTTP/1.0 时代的产物,依赖一个绝对时间点,而这个时间点是和客户端本地时间比的。要是用户电脑时间不准(我真见过有用户系统时间差了好几天的),缓存判断就全乱了。Cache-Control: max-age 用的是相对时长,从响应到达那一刻开始算,不依赖本地时钟,优先级也更高。两个都写的话,Cache-Control 赢。所以现在基本只看 Cache-Control,Expires 当个兼容兜底。同一辈分的老古董还有请求头里的 Pragma: no-cache,也是 HTTP/1.0 的遗产,如今只在需要伺候特别老的代理时才捎上一份,正常项目认 Cache-Control 一个就够。
补课时还挖到一个冷知识,正好解释了我们另一个页面的怪现象:如果响应里什么缓存头都没配,浏览器并不是"不缓存",而是走启发式缓存——常见实现是拿 (Date - Last-Modified) 的 10% 当缓存时长。一个一年没改过的文件,能被浏览器自作主张缓存一个多月。"没配缓存头"不等于"没有缓存",而是把决定权交给了浏览器的猜测,这比明确配置更难排查。
Cache-Control 还有几个常被混用的值,我这次特意记牢了:
1Cache-Control: no-store # 真·不缓存,任何地方都不存 2Cache-Control: no-cache # 存,但用之前必须协商 3Cache-Control: private # 只有浏览器能缓存,CDN/代理不能 4Cache-Control: public # 中间代理也可以缓存 5Cache-Control: max-age=0 # 立刻过期,几乎等价于每次协商 6Cache-Control: must-revalidate # 过期后必须回源验证,不许拿陈货凑合
最容易搞错的就是 no-cache 和 no-store。no-store 是真的什么都不留;no-cache 是"缓存可以存,但每次用前都要去服务器确认一下还新不新",本质走的是协商缓存。命名确实反直觉,我第一遍也记反了。涉及用户隐私数据的接口要用 no-store,别用错成 no-cache,否则代理层可能还留着副本。must-revalidate 则管的是"过期之后":默认情况下缓存过期又联系不上服务器时,浏览器有权拿旧缓存顶一下,加了它就不许顶,必须验证成功才能用。
304 是什么
顺着 Network 面板往下看,我注意到有些资源的状态码是 304。这是另一套机制:协商缓存。
协商缓存会向服务器确认资源有没有变化。它发生在强缓存没命中(或者头里写了 no-cache)的时候:浏览器带着上次的"标识"去问服务器"这东西变了没",没变就返回 304,让浏览器继续用本地副本。
常见头是 ETag,服务器第一次返回时给:
1ETag: "abc123"
下次请求浏览器自动带上:
1If-None-Match: "abc123"
如果没变,服务器返回:
1304 Not Modified
响应体是空的,浏览器继续使用本地缓存。
为了验证自己真懂了,我用命令行把这套流程完整复现了一遍,不靠浏览器。先用 curl -I 看服务器给的 ETag:
1curl -I https://example.com/ 2# 响应头里会有类似: 3# Cache-Control: max-age=... 4# ETag: "3147526947+gzip" ← 真实值因服务器而异,这里是占位示意 5# Last-Modified: ...
把拿到的 ETag 原样塞进 If-None-Match 再请求一次(注意带上引号、值要跟上面完全一致),没变就会回 304:
1curl -I -H 'If-None-Match: "3147526947+gzip"' https://example.com/ 2# 第一行变成:HTTP/2 304 3# 而且没有响应体——这就是协商缓存命中
把引号里的值改成一个对不上的,比如 "wrong",服务器会重新返回 200 和完整内容。Last-Modified 那条路同理,用 -H 'If-Modified-Since: <上次的时间>' 也能复现 304。
另一组更老的机制就是基于修改时间:
1Last-Modified: Wed, 15 May 2019 07:28:00 GMT
下次请求带上:
1If-Modified-Since: Wed, 15 May 2019 07:28:00 GMT
ETag 通常比 Last-Modified 更精确,原因有几个,有的我们项目里就撞见过:
Last-Modified精度只到秒,一秒内改两次它认不出来。- 有些文件内容没变,只是被重新部署了一遍,修改时间却变了——
Last-Modified会误判成"变了",白下载一次;ETag基于内容算就不会。我们每次发版全量覆盖 dist,就属于这种。 - 反过来,CDN 多机部署时同一文件在不同机器上
Last-Modified可能不一致,导致缓存命中率忽高忽低。
但 ETag 也不是没坑。它默认通常是用 inode、大小、修改时间算出来的(比如 Nginx 的弱 ETag),多台机器算出来不一样,照样命中不了。要么关掉让它走 Last-Modified,要么自己用文件内容 hash 当 ETag。两个头同时存在时,If-None-Match(ETag)优先于 If-Modified-Since。
静态资源为什么要带 hash
弄清楚这两套机制之后,回头看我们的发版链路哪里断的。前端构建后的资源本来长这样:
1app.8f3a9c.js 2vendor.1c2d3e.css
文件名带 hash 后,可以给 JS、CSS 设置长缓存,再加上 immutable 告诉浏览器"这文件永远不会变,连协商都别来":
1Cache-Control: max-age=31536000, immutable
文件内容变了,hash 也变,URL 变了,浏览器会当成一个全新的资源重新下载。旧文件的缓存还在磁盘上,但没人再引用它,过期或被淘汰就行。这套机制的精髓是:用 URL 的变化代替缓存的失效。我们从不"清缓存",只是换 URL。
顺带考古了一下更老的方案:在 URL 后面挂查询串版本号,app.js?v=1.0.3,发版时改版本号。它也是"换 URL"的思路,但坑不少——有些代理和 CDN 对带查询串的资源干脆不缓存,或者忽略查询串把 ?v=1.0.3 和 ?v=1.0.4 当同一个文件,等于版本号形同虚设。所以现在主流全面转向 hash 进文件名,查询串版本号只在没有构建工具的老页面里凑合用。
这里要分清三种 hash,我读项目 webpack 配置时发现前人用的是最粗的那种:
hash:整个构建一个 hash,任何文件改了所有文件名都变,等于长缓存全废。chunkhash:按 chunk 算,业务代码改了不影响第三方库 chunk。contenthash:按文件内容算,最精确,CSS 用mini-css-extract-plugin抽出来时尤其要用它。
1// webpack.config.js(webpack 4 常见写法) 2output: { 3 filename: '[name].[chunkhash:8].js', 4} 5// CSS 单独抽出来时 6new MiniCssExtractPlugin({ 7 filename: '[name].[contenthash:8].css', 8})
项目原来全用 [hash],结果每次发版连一行没改的 vendor.js 文件名都变,用户每次都得重新下载几百 K 的第三方库,长缓存形同虚设。我这次顺手改成了 chunkhash/contenthash,没动过的依赖就稳稳命中缓存。这就是为什么构建工具生成 hash 文件名很重要,但选哪种 hash 更重要。
index.html 不能长缓存
带 hash 的 JS、CSS 靠"换 URL"更新,这套机制有一个前提:得有人告诉浏览器新 URL 是什么。这个角色就是 index.html。
如果 index.html 被缓存很久,用户拿到旧 HTML,里面写死引用的还是旧 JS、CSS 的文件名,新资源发得再勤也没用——旧 HTML 压根不知道新文件的存在。我们那次翻车正是如此:用户拿着昨天缓存的 HTML,引用 app.old.js,而服务器上新版叫 app.new.js。前人配 max-age=86400 的初衷是"减少 HTML 请求",平时看不出问题,紧急发版才暴露。
入口 HTML 更推荐:
1Cache-Control: no-cache
no-cache 不是不缓存,而是每次使用前要向服务器确认。这样用户每次访问都会带 If-None-Match 去问一句,HTML 没变就返回 304,省了传输体积;HTML 变了(因为引用的 JS 文件名带新 hash)就拿到新的入口,自然加载新资源。
对应到 Nginx 配置,我改完之后的模板是这样:
1# 入口 HTML:协商缓存,保证发版即时生效 2location = /index.html { 3 add_header Cache-Control "no-cache"; 4} 5 6# 带 hash 的静态资源:长缓存 + immutable 7location ~* \.(js|css|png|jpg|svg|woff2)$ { 8 add_header Cache-Control "public, max-age=31536000, immutable"; 9}
入口短缓存,静态资源长缓存,是单页应用最经典也最稳的策略。这套配合可以记成一句话:HTML 永远要新,JS/CSS 永远靠 hash 换名。
CDN 和 Service Worker 这两层也会拦截
Nginx 改完,我以为问题解决了,运维提醒我还有一层:CDN。促销页走了 CDN 加速,CDN 有自己的缓存策略,源站更新了它不一定立刻同步,得手动"刷新"或"预热"对应 URL。那次翻车里 CDN 也缓存了旧 HTML,两层缓存叠在一起。
跟 CDN 相关还有两个头值得记。一个是 s-maxage,专门指挥共享缓存(CDN、代理),可以和 max-age 配不同的值——比如让 CDN 缓存 HTML 五分钟扛流量、浏览器每次协商:
1Cache-Control: no-cache, s-maxage=300
另一个是 Vary。开了 gzip 的资源要配 Vary: Accept-Encoding,告诉中间缓存"这个 URL 按客户端支持的压缩格式区分版本",否则可能把 gzip 过的内容原样喂给不支持 gzip 的老客户端。
Service Worker 是另一个容易被忽略的环节。它一旦注册并缓存了资源,会拦在所有请求最前面,比 HTTP 缓存还靠前。排查时我在这个接手的项目里就翻出过一段前人实验性写的 SW 代码(还好只在测试环境注册过),缓存策略是 cache-first,连 index.html 一起缓存,等于把入口给焊死了:
1// 一段会坑死人的 SW 写法:cache-first 缓存了 HTML 2self.addEventListener('fetch', function (e) { 3 e.respondWith( 4 caches.match(e.request).then(function (cached) { 5 return cached || fetch(e.request) // 命中缓存就永远不更新 6 }) 7 ) 8})
HTML 这种入口资源在 SW 里要用 network-first,拿不到再退回缓存,别用 cache-first。这段实验代码我评估之后直接删了。
所以现在再遇到"发版后用户还是旧页面",我的排查清单是固定的:
index.html是否被长缓存- 静态资源文件名是否带 hash
- CDN 是否还缓存旧文件
- Service Worker 是否接管了缓存
实际顺序上,先让用户硬刷(Ctrl+Shift+R)一下:如果硬刷就好,那基本锁定是浏览器缓存或 CDN 缓存,代码本身没问题;如果硬刷还是旧的,那大概率 CDN 还在吐旧文件,得去刷 CDN。很多线上"发版没生效"的问题,都不是代码没发布,而是缓存链路没有清干净。
接口缓存要谨慎
补课时我也审了一遍我们的接口。接口数据不是都适合缓存。
适合缓存:
- 字典配置
- 城市列表
- 文章内容
- 不常变的公共数据
不适合缓存:
- 用户信息
- 订单状态
- 权限
- 消息未读数
不要为了性能把所有接口都缓存。业务数据过期会比慢更麻烦——慢用户最多抱怨两句,看到错的余额、错的库存、错的权限,那是事故。电商后台里订单状态尤其碰不得,运营盯着它发货。
接口缓存我更倾向不靠 HTTP 头,而是前端自己控。HTTP 缓存对接口有个尴尬:要么命中强缓存连服务器都不问(数据真变了你也不知道),要么每次协商(省不了多少)。所以我在项目里加了个内存级的、带过期时间的小缓存,配置类接口加个短 TTL:
1var cache = {} 2 3function getDict(key, ttl) { 4 var hit = cache[key] 5 if (hit && Date.now() - hit.time < ttl) { 6 return Promise.resolve(hit.data) 7 } 8 return fetch('/api/dict/' + key) 9 .then(function (r) { return r.json() }) 10 .then(function (data) { 11 cache[key] = { data: data, time: Date.now() } 12 return data 13 }) 14} 15 16// 城市列表这种基本不变的,缓存 10 分钟足够 17getDict('cities', 10 * 60 * 1000)
这样我能精确控制哪个接口缓存多久,还能在用户主动刷新时一键清掉,比配 HTTP 头灵活得多。GET 才适合这么干,POST 这类带副作用的请求不要缓存。
Network 面板怎么读
最后把这次用到的"读图"技巧整理一下,因为很多人看到 304 就以为没有走网络——其实协商缓存仍然发了请求,只是响应体为空,节省的是传输体积。如果想完全不发请求,要命中强缓存。这也是强缓存和协商缓存最重要的区别。
Network 面板的验证方法:
- Size 列写
(memory cache)/(disk cache),且 Status 列那个200是灰色的:强缓存命中,根本没发请求(关掉页面再开走内存、刷新走磁盘)。 - 状态码
304:协商缓存命中,发了请求但响应体为空。点进这条请求看 Request Headers,能看到浏览器自动带上的If-None-Match或If-Modified-Since,那正是它拿去问服务器「变了没」的凭证。 - 状态码
200(黑色实色):真正从网络下载了完整内容。
补一句:勾上 Network 面板的 Disable cache,强缓存和协商缓存会全部失效、每条都变成实色 200,这也是验证「到底是不是缓存在作怪」最快的开关。我那天要是早点勾上它对比一下,能少懵十分钟。
还有个复现时容易把自己绕晕的细节:刷新方式不同,缓存行为不同。地址栏回车(或点链接进入)会正常走强缓存;按 F5 刷新,浏览器会给当前页面资源带上 max-age=0 之类的条件去协商,强缓存对入口就"失灵"了;Ctrl+Shift+R 硬刷则连协商都跳过,全部重新下载。我一开始就是自己按 F5 看到新页面、用户点链接进来还是旧的,两边现象对不上白白怀疑了半天人生——测缓存问题,要按用户的进入方式复现,别拿 F5 的结果下结论。
弱网下强缓存和协商缓存的差别被放大得很明显。协商缓存虽然省了响应体,但那一次 RTT(往返时延)在 3G 下可能就是几百毫秒,一个页面几十个资源全去协商,累积起来就是肉眼可见的白屏。所以能上强缓存的(带 hash 的资源)就别让它走协商,这也是 immutable 存在的意义——告诉浏览器连那次确认都免了。
结果
促销页的 bug 最终在改完 Nginx 配置、刷完 CDN 之后的当晚全量生效,之后的几次发版再没出过"还是旧的"。
入口的缓存时长,等于发版的"最坏生效延迟"。HTML 缓存一天,最坏情况就是用户一天后才看到新版。所以入口要么 no-cache,要么直接交给协商缓存,别图那点 HTML 请求的便宜。
强缓存决定浏览器是否直接用本地资源,协商缓存决定是否向服务器确认资源变化。项目里最稳的策略通常是:入口 HTML 短缓存或协商缓存,带 hash 的静态资源长缓存,接口按业务新鲜度决定。缓存不是越久越好,正确更新和正确命中同样重要——这句话现在贴在我显示器边上,每次改 Nginx 配置前都看一眼。