我在前端群里看到最多的一个提问大概就是我把代码改了、也部署上线了为什么用户浏览器里还是旧页面为什么样式改成了红色线上打开还是蓝色很多人第一反应是怀疑部署流程出了问题但十有八九都是浏览器缓存机制在背后扣留了旧资源。这是前端开发里最绕不开的经典问题也是大厂面试几乎必考的一题浏览器缓存到底是怎样运作的代码更新后要怎么做才能让所有用户都拿到最新版这篇文章不打算只讲几个响应头字段而是把缓存决策原理、工程化解决方案、完整排查链路一起讲透看完你可以直接照着配置。1. 浏览器到底在缓存什么一张资源取用决策表1.1 两个关键概念强缓存和协商缓存要理解代码更新了但页面没变第一步必须搞清楚浏览器缓存的两个基本阶段。我用一个生活化的类比来拆解强缓存就像你办了健身房年卡进门刷脸直接进不用问前台任何问题。对应到 HTTP 请求里就是浏览器在本地缓存里直接找到了资源完全不发请求到服务器。协商缓存就像你去快递柜取件到柜子前先扫码问一下系统我这个件还在不在系统说在你直接取走系统说要换新包裹你就重新排队取。对应到 HTTP 请求里就是浏览器带着一个凭证去问服务器这个资源我手头的版本还能不能用服务器说能就用本地的说不能就返回一份新的。强缓存的判定字段主要是Cache-Control和Expires。命中强缓存时浏览器开发者工具里能看到请求状态是200但 Size 列显示为memory cache或disk cache也就是说这个请求根本没有到达服务器。协商缓存的判定字段是Last-Modified/If-Modified-Since和ETag/If-None-Match。命中协商缓存时状态码是304 Not Modified浏览器仍然使用本地缓存副本但这个过程已经和服务器进行了一次通信。我把两者的核心差异整理成了一张表对比项强缓存协商缓存是否请求服务器否是判定字段Cache-Control、ExpiresETag、Last-Modified命中时状态码200from cache304数据来源本地磁盘/内存本地磁盘/内存经服务器确认优点性能最好几乎零延迟能感知服务器端资源变化缺点无法感知服务器上资源是否更新每次都要发一次请求确认浏览器在真实加载资源时会先看强缓存配额如果命中就直接用如果强缓存过期再走协商缓存协商缓存也没有命中才真正从服务器拉取新资源。这个决策顺序非常重要后面所有更新不生效的问题本质上都是卡在了这个决策链路的最前面一环。1.2 响应头里的字段到底谁说了算很多刚接触这块的同学会被一长串响应头字段绕晕Cache-Control、Expires、ETag、Last-Modified、Pragma……我先按优先级和应用场景把常用字段理清楚。Cache-Control是 HTTP/1.1 时代的标准字段里面可以组合很多指令。比如public表示允许任何中间节点缓存private表示只允许浏览器私有缓存no-cache表示允许缓存但每次必须向服务器确认no-store表示完全不缓存max-age31536000表示缓存 31536000 秒内直接走强缓存。Expires是 HTTP/1.0 的字段它是一个绝对的过期时间点比如Expires: Thu, 01 Dec 2026 16:00:00 GMT。当Cache-Control和Expires同时存在时Cache-Control优先级更高。no-cache和no-store是最容易被混淆的两个指令。no-cache绝对不是不缓存它只是每次使用前必须验证验证 304 时依然可以省下资源体量no-store才是真正的什么都不留。很多老项目就是因为把no-cache理解成了不缓存才在排查缓存问题时走了弯路。ETag是服务器给资源生成的一个指纹值资源内容变了指纹就会变Last-Modified是资源的最后修改时间。在多数服务器实现里如果两个字段都存在服务器会优先校验ETag因为它能精确到文件内容级别。Pragma是 HTTP/1.0 时代的控制字段现在用的已经很少但个别老浏览器或代理层仍会读取所以有的服务端会同时输出Pragma: no-cache做兼容。2. 代码更新后用户却拿到旧版先定位是哪种缓存事故2.1 事故A入口 HTML 被强缓存这是最常见的缓存事故也最坑人。很多人部署完项目后发现自己用无痕窗口打开明明是新版可用户那边怎么刷新都是旧页面连页面框架都是老的。原因通常是入口index.html被设置了很长的强缓存有效期比如Cache-Control: max-age6048007天。浏览器看到这个响应头7天内访问都不会再向服务器要这个页面。你代码就算发到天上去用户浏览器仍然用本地那份旧 HTML。入口 HTML 是整个页面的目录目录是旧的后面引用的脚本、样式自然都是旧版本。这是问题最严重的一种情况因为用户几乎完全感知不到新版本已经发布。2.2 事故BJS/CSS 文件名没变另一种情况是 HTML 已经是最新的了但 HTML 里引用的main.js文件名没有变化。浏览器一看这个 URL 之前访问过且还在强缓存有效期内直接取本地缓存。经典表现是页面结构变了但交互逻辑还是旧的或者样式改了但用户看到的外观还是老样子。这种问题的隐蔽之处在于HTML 和 JS 版本不匹配时大概率会产生运行时错误因为它本质上就是新页面骨架上挂了一段旧逻辑。很多项目就是因为构建时没有给静态资源文件生成 hash 版本号才反复出现这种问题。2.3 事故C代理层 / CDN 缓存前两种都是浏览器端的问题这种情况出在浏览器之前的网络链路上。很多项目上线后会接入 CDN 或 Nginx 反向代理。如果你发现本地强制刷新已经正常但线上用户访问仍然是旧版而且你自己用手机流量访问也正常换公司 Wi-Fi 访问就旧版那大概率就是 CDN 节点或代理层把旧资源缓存住了。CDN 有自己的缓存策略如果源站没有正确下发Cache-ControlCDN 平台可能按照默认规则把index.html也缓存了一定时间。2.4 三种事故的快速特征对比事故类型典型症状根因方向快速验证手段HTML 被强缓存整页都是旧版强制刷新后恢复index.html 的 Cache-Control 被设置了长 max-age无痕窗口打开curl -I 看响应头静态资源文件名没变HTML 是新版脚本/样式是旧版构建产物不带 hash资源 URL 未变化查看 HTML 引用的 JS 文件名是否带 hashCDN/代理缓存本地强制刷新正常外界部分区域旧版CDN 缓存规则或源站响应头配置不当换网络访问查 CDN 刷新记录还有一个容易混淆的情况顺带说一下有时候你改了后端接口的返回但页面上数据没变化这种情况容易误判为前端缓存问题。先别急着怀疑浏览器去 Network 面板里看接口的 Response body 是不是真的变了。如果接口返回还是旧数据那是后端服务没有重启、没有重新构建或者代码没有真正挂载进容器跟前端缓存没有关系。这一点在后端用 Docker 部署时尤其常见改了 Python 代码去 Docker Desktop 里看日志发现页面还是旧版就是因为容器还跑着旧镜像日志自然也是旧进程打出来的。3. 根治方案构建产物指纹 分级缓存策略3.1 为什么文件名带 hash能根治更新问题前面说了那么多真正能彻底解决更新不生效问题的方法核心只有一句话让资源的 URL 成为资源内容的函数。内容变了URL 就跟着变URL 没变说明内容一定没变。这句话怎么落地就是构建时给静态文件名生成内容哈希值。比如main.a1b2c3d4.js只要文件里任何一个字节发生变化哈希值就变成另一个字符串构建产物里 HTML 引用的 URL 也会自动更新。浏览器缓存是按 URL 作为 key 的新 URL 在浏览器里就是一个从未见过的资源自然老老实实去服务器拉取完全绕过了强缓存命中问题。这就是前端工程化里最标准的缓存失效手段不是去手动清缓存而是让过期资源自然失效。Webpack 项目里配置方式是在output.filename中使用contenthash// webpack.config.js module.exports { output: { filename: js/[name].[contenthash:8].js, chunkFilename: js/[name].[contenthash:8].chunk.js, }, optimization: { moduleIds: deterministic, runtimeChunk: single, }, }这里推荐contenthash而不是chunkhash或hash。hash跟整个构建相关一次构建中所有文件共享同一个值改动一个文件会导致所有文件 hash 变化chunkhash按 chunk 维度生成如果你的 JS 和 CSS 在同一个 chunk 里改 JS 会导致同 chunk 的 CSS 也变contenthash才会精确到单个文件的内容命中缓存的范围最小。Vite 项目不太需要操心这个Vite 默认就会为产物生成带 hash 的文件名比如index-Dq4bXmYq.js。需要重点检查的是你用的老旧脚手架或自定义 webpack 配置有没有把filename写死成main.js。3.2 入口文件 no-cache静态资源长缓存有了带 hash 的产物之后剩下的就是分级配置缓存策略。核心逻辑分两层index.html 入口文件设置Cache-Control: no-cache让浏览器永远在每次访问时向服务器确认一次。确认结果是 304就用本地缓存确认结果是 200就刷新文件。这样新版本发布后用户首次刷新页面就能拿到新 HTML而新 HTML 里自然引用了新的 hash 文件名。js/css/img 等静态资源设置Cache-Control: public, max-age31536000, immutable。因为文件名带 hash文件内容不变 URL 才不变变了 URL 就变所以给一年甚至更长的强缓存都没有问题冗余的旧缓存不会造成任何影响。Nginx 配置可以参考这样一份最小可用配置server { listen 80; server_name example.com; root /usr/share/nginx/html; # 入口 HTML location /index.html { add_header Cache-Control no-cache, no-transform; } # 带 hash 的静态资源 location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|woff2?|webp)$ { add_header Cache-Control public, max-age31536000, immutable; } }如果你想让用户刷新时尽量快地拿到最新入口还可以给index.html单独设置一个短max-age比如max-age600意味着 10 分钟内强缓存10 分钟后再验证。这个属于激进程度的选择追求秒级生效就用no-cache追求兼顾性能就设个短暂时长。3.3 部署时的配套动作构建配置和 Nginx 配置都到位之后还有几个部署细节很容易被忽略。第一个教训关于旧资源文件。带 hash 的旧文件不要在新版本发布后立刻删掉。因为总有用户在你发布前加载了旧版 HTML然后在下一次刷新时才请求旧版 HTML 里引用的旧资源。如果这个旧 JS 文件已经被你从服务器上删掉了那些用户会直接白屏。稳妥做法是部署时保留最近两个甚至三个版本的构建目录等旧缓存自然淘汰后再清理。第二个教训关于CDN 刷新。如果你用了 CDN即使源站 Nginx 配置完全正确CDN 边缘节点也未必立刻同步。尤其是index.html入口这个 URL发布后最好去 CDN 控制台做一次 URL 强制刷新。很多团队的更新事故就是源站已经新了、CDN 还挂着旧内容。第三个是应急手段。线上已经炸了、没有时间改配置重发布的时候可以通过这些方式临时救急让用户强制刷新Windows 下CtrlF5Mac 下CmdShiftR原理是绕过强缓存、直接带Cache-Control: no-cache请求。浏览器开发者工具里打开 Network 面板勾选 Disable cache 后刷新适合自己和同事验证。临时给入口 URL 加查询参数https://example.com/index.html?v20260618。这种方式能快速绕开缓存但不建议作为长期方案因为它依赖人工维护版本号容易忘记更新。4. 一次真实的排查过程改了代码但线上没反应的完整链路4.1 现场描述有一次我给一个 Vue 项目改页面改了接口字段和几处样式构建后推到测试服务器自己在本机打开已经是新效果。可是没过多久产品经理在群里反馈说线上还是老样子。我当时的第一个动作不是去翻代码而是让自己冷静下来把用户看到旧页面当成一个系统性问题来排查。我按照一个固定顺序排查先看自己的浏览器是不是也有问题再确认服务器上文件是不是新的再检查响应头最后看是不是链路中某个代理层缓存了。这个顺序可以避免在错误的方向上浪费大量时间。4.2 用 DevTools 判断走了哪一层缓存打开线上页面按下F12进入 Network 面板勾选 Disable cache 后刷新一次先看index.html这条请求如果状态显示304说明入口走了协商缓存服务器确认 HTML 没有变化这时候问题一定出在源站文件没更新或者服务器返回的响应头有问题。如果状态显示200但 Size 列写着from disk cache说明入口走了磁盘强缓存服务器根本没收到请求。接着看index.html的 Response Headers 里Cache-Control是不是被设成了较大的max-age。如果入口已经是新的 200再顺着点开 HTML 引用的main.[hash].js看这条静态资源是不是也命中了缓存。只要底下的资源文件名没变或强缓存未过期页面照样跑旧逻辑。这个观察过程能直接帮你判断问题处在哪一环。我最终的排查结果就是测试服务器的 Nginx 配置里没有单独给index.html设置 Cache-Control导致它落到了通用静态资源规则里被设置了 7 天强缓存发布后用户自然拿不到新入口。4.3 用 curl 验证服务端响应头浏览器开发者工具只能看到你自己这台机器的请求结果要确认服务器端实际的响应头配置更可靠的方式是直接在终端里模拟请求。比如我的项目部署在https://example.com我会分别执行curl -sI https://example.com/index.html curl -sI https://example.com/assets/main.a1b2c3d4.js第一个命令返回的cache-control应该能看到no-cache或max-age0之类的值第二个命令应该能看到max-age31536000和immutable。如果index.html返回的是max-age604800基本可以实锤就是入口强缓存导致的问题。如果部署使用了 CDN还要再确认一下 CDN 节点对这两个 URL 的响应。有些 CDN 控制台提供URL 诊断或缓存刷新功能可以在那里输入index.html地址看节点缓存情况。本地 curl 拿到的是源站结果CDN 节点上的结果可能不一样。4.4 定位到后端容器未更新的场景还有一种非常经典的代码没更新场景跟前端缓存没有半毛钱关系但排查链路会交叉。比如同事改完 Python 接口代码去 Docker Desktop 里看容器日志发现请求日志还是旧的或者页面数据还是旧值。这时候我会先开浏览器 Network 面板找到对应接口请求直接看 Response 里的 JSON 数据。如果接口返回的是旧数据说明是后端的问题要么代码修改后没重新构建镜像容器跑的还是旧镜像要么修改的代码没有被正确挂载进容器构建缓存导致旧的字节码还在。如果接口返回已经是新数据但页面上渲染还是旧内容才需要回头去看前端资源是不是走缓存了。我这里有一个经常用的判定口诀先看接口响应再看入口 HTML然后看资源版本最后查 CDN。按这个顺序排查几乎所有改了代码但线上没反应的问题都能在两轮操作内锁定根因。5. 进阶场景SPA、微前端、大屏和本地开发环境怎么配5.1 SPA 部署的缓存边界单页应用和传统多页应用的缓存策略有一点不同。SPA 的 HTML 只是壳子真正的逻辑全在异步加载的 JS/CSS 里所以入口 HTML 的更新及时性显得更重要。如果你用 history 模式的 Vue Router 或 React RouterNginx 还需要把路由路径try_files回退到index.htmllocation / { try_files $uri $uri/ /index.html; } location /index.html { add_header Cache-Control no-cache, no-transform; }这里要特别小心location /本身也是一个可以配置缓存的地方。如果你把location /设置成静态资源长缓存index.html也会被这个规则命中。所以入口文件一定要用更精确的location /index.html来单独覆盖否则就是给自己埋坑。5.2 qiankun 微前端的子应用缓存微前端场景比普通 SPA 复杂一点。qiankun 这类微前端框架在加载子应用时主应用会动态请求子应用的入口 HTML然后根据入口 HTML 里的 script 标签去加载对应的 JS 资源。这里有两层缓存需要同时处理好子应用入口 HTML必须设置no-cache否则子应用发版后主应用仍然加载旧入口。子应用的静态资源同样需要带 hash 并设置长缓存这一层跟普通 SPA 没有区别。很多微前端项目的子应用更新不生效问题都是出在子应用入口 HTML 被location /的强缓存规则命中了。另外如果你的主应用路由切换时会重新拉取子应用 entry最好确认这一步请求没有走后端服务端渲染的缓存或者 CDN 缓存。实在拿不准的时候可以在子应用 entry URL 上临时加一个版本号参数做验证。5.3 大屏 / 内网项目干脆禁用缓存大屏展示项目和我上面讲的公共 Web 项目很不一样。大屏一般观众固定、访问量小、生命周期短但需求改动特别频繁——上午刚上线下午就要换背景图、改文案。这种情况下再纠结 hash 缓存收益不大反而可能因为缓存问题在领导面前翻车。我的建议是如果项目部署在局域网环境且以展示为主直接在服务端给所有页面资源设置Cache-Control: no-store每次刷新都从服务器重新拉取保证永远最新。代价是性能差一点刷新稍慢但对大屏这类低频访问场景完全可接受。如果担心某些老旧内网浏览器对Cache-Control的支持不完整可以同时加上Pragma: no-cache和Expires: 0做兼容兜底。5.4 开发环境与 mock 数据缓存本地开发时也有类似问题。现在很多项目用 msw 这类工具做前端 mock它是通过 Service Worker 拦截请求的。有时候你修改了 mock 代码页面上的数据却纹丝不动这不是浏览器 HTTP 缓存的问题而是 Service Worker 缓存没更新。排查方法是在 DevTools 的 Application 面板里找到 Service Workers点击 Update 或者干脆 Unregister再清一次 Cache Storage。浏览器开发者工具里的 Disable cache 选项只对普通 HTTP 请求起作用对 Service Worker 并不完全生效。所以建议在开发阶段就养成看网络面板 看响应头的习惯不要一出问题就依赖强刷强刷掩盖了问题的真正来源。6. 容易忽略的几个坑和收尾建议6.1 我踩过的坑清单这些坑我基本都在真实项目里踩过列出来给你避避雷Nginx 里expires和add_header同时用expires 365d会自动生成Cache-Control: max-age31536000如果你再写一条add_header Cache-Control public, immutable因为响应头里已经有 Cache-Control 了add_header 不会追加最终可能拿到的是没有immutable的结果。想精确控制就只用add_header别混用。只配了静态资源规则忘了给入口单独设置SPA 部署时index.html一旦落入通用缓存规则整站更新延迟就是必然结果。部署完后一定要用 curl 检查入口响应头。发布后立刻删旧资源前面说过了这会造成已缓存旧 HTML 的用户直接白屏。标记一下旧文件至少保留两个版本周期。把no-cache当成不缓存来用这会导致你排查时判断失误。完全不缓存的语义是no-storeno-cache只是不复用本地副本必须经过验证。用户本机时间错误导致缓存失效异常强缓存的过期判断依赖浏览器本地时间有用户电脑时间调错了就会提前到期导致刷一次就发一次完整请求。这个问题少见但真遇到了会让人摸不着头脑。改一个文件导致所有 hash 都变webpack 里如果moduleIds没设置成deterministic每次构建的模块 ID 会变化牵连很多 chunk 的 hash 全变长缓存策略的效果大打折扣。6.2 我的最终建议如果让我用几句话总结这套缓存方案我会说入口 HTML 永远走协商缓存静态资源永远带内容 hash 并配长缓存部署时保留旧文件、发布后刷新 CDN排查时按接口 → HTML → 静态资源 → CDN的顺序来。这套思路不仅适用于传统多页应用也适用于 Vue/React SPA稍微调整一下同样能用在 qiankun 微前端和本地大屏项目上。我个人在带团队的时候通常会把最基础的响应头检查做成发布规范的一部分任何人发布完前端项目第一件事就是用 curl 看一眼index.html的响应头。这一步花不了十秒钟却能挡住大部分线上怎么没更新的翻车现场。缓存机制本身并不是什么高深理论真正考验人的是你在实战里有没有形成一套固定的判断路径。希望这篇文章能帮你把这条路径走顺。
