
你有没有遇到过这种场景前端刚上线一个版本自己也拿手机开飞行模式再关掉清了好几次缓存结果用户群里还是有人截图说“页面怎么还是老样子”“新功能去哪了”。或者你自己在公司电脑上验证一切正常回家一打开还是旧界面那种感觉就像代码根本没发布一样。这其实就是浏览器缓存机制在“捣鬼”。虽然每个前端工程师都知道浏览器会缓存但真正能把缓存机制讲透、能在发布新版本时确保用户立即看到新内容的人说实话不多。这篇文章我打算把自己这些年处理缓存问题的完整思路和实际操作经验写出来从HTTP缓存原理、强缓存和协商缓存怎么判断到构建产物怎么打指纹、nginx怎么配响应头再到发布后用户还是旧页面时怎么排查一条线全部盘清楚。无论你是刚入门的前端新人还是正在被线上缓存问题折磨的中级工程师这篇文章都能给你一套可以直接抄作业的答案。1. 浏览器缓存到底是怎么运作的先搞清楚三个关键角色1.1 强缓存与协商缓存两个层级的判断顺序浏览器缓存不是上来就直接读本地副本它会经历一套相当严谨的判断流程核心就是先问“这个资源还用不用得着重新请求”判断依据分为强缓存和协商缓存两个层级。强缓存的意思是在缓存有效期内浏览器根本不发请求直接把本地缓存的资源拿出来用。你在DevTools的Network面板里会看到状态码是200 (from memory cache)或200 (from disk cache)这代表了一次网络请求都没发出去。强缓存由Cache-Control这个响应头控制最常见的是max-age3600这种写法表示一小时内直接用缓存。如果强缓存命不中比如max-age已经过期了浏览器就会进入协商缓存阶段带着之前的缓存标识去问服务器“我这个缓存还能用吗”。服务器收到请求后会比对标识如果资源没变就返回304 Not Modified响应体是空的浏览器继续用本地缓存只是这次确实是发了一次网络请求的。这里有个特别容易混淆的点很多人以为304和200 from cache是一回事其实完全不是。304是真实请求过服务器的只是服务器说“你没变接着用吧”传输的数据量极小200 from cache是压根没请求直接读的本地缓存速度更快。理解这个区别后面排查问题会有大用。1.2 Cache-Control、ETag、Last-Modified每个响应头各管什么Cache-Control是现在最主流的缓存控制头同时也是最灵活的一个。它常用的指令有no-store完全不缓存、no-cache可以缓存但每次必须验证、max-age秒数多少秒内走强缓存、s-maxage针对CDN等共享缓存优先级高于max-age、public/private是否允许代理服务器缓存、immutable资源永不变刷新页面时也不重新验证。Expires是HTTP/1.0时代的产物指定一个绝对的过期时间点比如Expires: Wed, 21 Oct 2026 07:28:00 GMT。它的问题是服务器时间和浏览器时间可能不同步所以现在基本被Cache-Control取代了。如果响应头里两个都有Cache-Control的优先级更高。协商缓存这边有Last-Modified/If-Modified-Since和ETag/If-None-Match。Last-Modified是服务器返回资源的最后修改时间浏览器下次请求时带上If-Modified-Since问服务器“这个时间之后有改动吗”。ETag则是一个更精确的版本标识符通常是文件内容的哈希值请求时通过If-None-Match带回去。两者相比ETag更精确因为文件内容变了但修改时间可能没变比如改了又马上改回去不过ETag在某些环境下计算成本更高所以很多服务器是两个一起返回让浏览器优先用ETag。1.3 容易被忽视的启发式缓存服务端没给响应头时的默认行为很多人不知道就算服务器一个缓存相关的响应头都不给浏览器也可能会缓存资源这叫做启发式缓存。HTTP规范里有一个规则如果响应头里有Last-Modified但没有Cache-Control和Expires浏览器会根据Date响应时间和Last-Modified的时间差自己推导出一个缓存时间通常是两者差值的10%。举个例子服务器返回一个资源Date是现在Last-Modified是10天前那浏览器可能自动缓存1天。这个默认行为非常坑因为你以为没配缓存策略浏览器就不会缓存实际上它还是给你缓存了而且这个缓存时间是浏览器自己算出来的不同浏览器可能还不完全一样。我在实际项目里就踩过这个坑某个接口返回的JSON没有设置缓存头我觉得“反正没配就是不缓存”结果测试同学反馈改了配置后页面还是显示老数据。查了半天才发现就是启发式缓存惹的祸。所以一个清晰的原则是前端所有需要控制缓存的资源都应该显式地设置Cache-Control绝对不能依赖浏览器的“自觉”。2. 为什么代码更新了页面还是旧的根因分析比瞎清缓存更重要2.1 index.html默认不带指纹一切缓存问题的源头现在前端开发基本都是用构建工具打包产出的是经过压缩、混淆的JavaScript和CSS文件。为了配合缓存策略构建工具会在文件名里加上内容哈希比如app.8f3k2d9.js文件内容一旦变化哈希就变浏览器就会把它当成一个全新的资源去请求。但有一个文件是例外那就是index.html。这个入口文件在绝大多数项目里是不带哈希的因为服务器部署时不能改入口文件名改了用户访问根路径就找不到页面了。问题就出在这里如果index.html本身被强缓存了浏览器连里面的script标签都不重新解析你就算把app.8f3k2d9.js的哈希换成了app.9x2m4q7.js也没用因为浏览器拿到的还是那个缓存的旧index.html它根本不知道有新文件。这就像你点了一份外卖送餐地址写的是你公司楼下前台。你不换地址外卖员永远把餐放前台你换了手机号也没用前台还是按老联系方式找你。index.html就是那个前台其它带哈希的文件就是外卖地址不变的话一切更新都白搭。2.2 构建配置不对文件名压根没带哈希怎么办有些项目的构建配置没有正确开启哈希命名或者用的是固定的文件名比如直接输出main.js而不是main.xxxxx.js。这种情况下就算你每次发布代码内容都不一样浏览器里的文件名始终是同一个强缓存期内直接拿旧文件用你大概率的解决手段就是让用户清缓存。如果你用的是webpack需要在output里把filename配置为带[contenthash]的形式像这样output: { filename: js/[name].[contenthash:8].js, chunkFilename: js/[name].[contenthash:8].chunk.js, path: path.resolve(__dirname, dist) }:8表示哈希取前8位足够区分文件版本文件名也不会太长。优化思路是业务代码变化了contenthash会变但是如果你引入了第三方库最好把依赖单独抽出来打包否则任何一个业务文件改动都会导致整个vendor包的哈希也变用户就得重新下载所有第三方库浪费流量和加载时间这就是常说的“长效缓存”。Vite项目默认就会给打包产物加上哈希所以这块问题相对少但有些手动改过构建配置的项目就不一定了。总之你用构建工具还是手动配置原则就一条静态资源文件名必须和内容强关联内容变了文件名必须变这是解决缓存问题的第一根地基。2.3 服务器缓存策略没跟上nginx和CDN都在“帮倒忙”代码和构建都做对了还有一种很常见的情况是服务器层面的缓存策略不对。很多团队前端项目用nginx托管静态文件nginx默认行为是如果文件存在就直接返回不主动添加Cache-Control头。于是你发布新代码后用户请求index.htmlnginx返回了这个文件却没有告诉浏览器“不要缓存我”浏览器根据前面说的启发式缓存规则可能就把它缓存了一段时间。CDN的问题是另一类。如果你用了CDN加速CDN节点也会缓存你的内容而且CDN的缓存策略通常看源站返回的响应头。源站没配好CDN就会按照自己的默认规则缓存有时候还会缓存超长时间。发布新版本后你本地强制刷新能看到新页面但CDN节点上还是旧资源这时候就得去CDN控制台手动刷新或者提前配置好刷新规则。所以完整的缓存策略是前后端协作的前端构建保证文件名带哈希nginx或者服务端保证入口index.html不被缓存、静态资源可以放心长缓存CDN保证源站配置正确、发布后能及时刷新。任何一环漏掉都会复现“代码更新了页面还是旧的”这个经典问题。3. 完整解决方案构建产物打指纹 入口文件不缓存双管齐下3.1 资源分级策略先把需要缓存的资源分个类要彻底解决缓存问题第一步不是动手配置而是想清楚你手上的资源分成几类每类应该用什么样的缓存策略。我一般把项目资源分成四类HTML入口文件index.html必须走协商缓存或完全禁止缓存保证发布后能立刻拿到最新入口。带哈希的静态资源JS、CSS、图片、字体文件名与内容关联设置长期强缓存比如max-age31536000一年内可以直接用本地缓存速度最快。接口请求看业务需求需要实时数据的绝对不能缓存可以用no-cache或者干脆no-store。其它可能被页面动态引用的资源比如通过接口动态返回的图片地址、临时生成的配置文件这类资源无法保证文件名变更就不适合长缓存建议使用协商缓存。这样分级的原因是缓存不是“越少越好”也不是“越多越好”而是“该缓存的缓存不该缓存的绝不缓存”。带哈希的静态资源天然适合长缓存因为只要内容没变文件名就不会变缓存多久都不会出错而一旦内容变了文件名变了浏览器自然会去拿新文件不会产生冲突。index.html之所以要禁止缓存是因为它是指挥文件里面写着浏览器该加载哪些哈希资源它必须每次都能拿到最新的。3.2 nginx配置示例一套可以直接复制用的配置下面是一份我实际项目里在用的nginx静态站点配置针对SPA单页应用做了缓存处理你可以直接参考改改就能用server { listen 80; server_name your-domain.com; root /usr/share/nginx/html; index index.html; # HTML文件禁用缓存保证每次都能拿到最新入口 location / { try_files $uri $uri/ /index.html; } location /index.html { add_header Cache-Control no-cache, no-store, must-revalidate; expires -1; } # 静态资源带哈希的文件走一年长缓存 location /assets/ { add_header Cache-Control public, max-age31536000, immutable; access_log off; } # 对于不带哈希的静态资源使用协商缓存 location ~* \.(?:css|js|jpg|jpeg|gif|png|svg|ico|woff2?)$ { add_header Cache-Control no-cache; etag on; if_modified_since before; } }这里面有几个细节值得展开说。location /index.html的精确匹配优先级比location /高所以访问根路径时一定会走这个禁用缓存的规则。expires -1配合must-revalidate的意思是告诉浏览器这个文件立即过期每次访问都必须重新向服务器验证但验证之后如果服务器说内容没变理论上可以用304空响应来确保数据量最小而不是每次都全量下载。不过你如果觉得直接no-store更省心也可以换成no-store代价是每次进入页面都会完整下载一次index.html其实也没多大开销因为入口文件本身通常只有几KB。immutable这个指令值得单独说一下它是配合哈希文件名使用的利器。有了它即使用户点击了浏览器的刷新按钮浏览器也不会对这个资源重新发起验证请求直接使用本地缓存。没有它的话用户刷新页面时浏览器可能会对静态资源发送条件请求验证虽然一般会命中304但总会多一次网络交互。大厂优化的思路就是带哈希的资源加immutable刷新也不浪费请求。3.3 构建工具的配套设置确保文件名变更和内容强关联nginx做好之后还得确保构建产物真的产出了带哈希的文件名。除了webpack的output.filename配置外还要注意HtmlWebpackPlugin默认会引用最新的构建产物这个一般不用额外操心但有一些踩坑点第一contenthash和hash和chunkhash之间的区别。hash是整个构建过程算出来的只要任何一个文件变了所有文件的hash都会变这会导致无关文件的缓存也失效chunkhash按chunk计算但同一个chunk内如果既有业务代码又有第三方库改动业务代码会让整个chunk的哈希都变contenthash是根据文件内容算的精确到具体文件。所以前端构建命名用contenthash是最合理的。第二图片和字体等静态资源也要注意它们的引用方式。如果CSS里引用的图片名没有被哈希化而是固定名那你改了图片内容后浏览器可能还用旧图。webpack的asset modules或file-loader会处理这层引用Vite的assetsInclude也一样但要注意构建输出的assets目录里文件名是否真的带了内容哈希。第三如果你的项目里用了动态import()做路由懒加载那么异步加载的chunk文件名也应该带哈希。webpack在output.chunkFilename里配置Vite默认会处理。预期效果是用户首次进入页面只需要下载当前路由需要的chunk其它路由的代码等访问到了再加载而且这些chunk全部都带哈希和长缓存。3.4 发布流程配合从开发到上线的完整缓存闭环有了正确的构建产物和nginx配置你还得在发布流程中做一些配合才能形成完整的缓存闭环。我推荐的做法是每次发版前先确认构建产出目录里的index.html引用的确实是新哈希的JS/CSS文件。发布时先传静态资源文件assets目录再传index.html。顺序不能反否则会出现index.html已经更新但新哈希的JS还没传完的窗口期用户访问时会加载到不存在的文件。如果用了CDN发布后有条件的话在CDN控制台刷新一下index.html的缓存或者测一下CDN边缘节点是否已经拿到新入口。发完版后用无痕窗口访问一次线上地址确认返回的HTML里引用的哈希资源号是最新构建生成的。这步看似多余却能避免大部分低级失误。这套流程配合前面说的“入口不缓存、静态资源长缓存”策略基本就能在用户不做任何操作的情况下让他们在下一次访问时自动获得最新版本页面。唯一还有点问题的是那些打开了页面一直不关、长期停留在旧标签页里的用户但这种情况只要他们刷新一次页面就会更新已经是可接受范围了。4. 从实战出发线上缓存问题的排查思路与工具技巧4.1 用DevTools还原缓存命中的完整链路遇到用户反馈“页面是旧的”首先要分清楚是用户本地问题、CDN问题还是源站配置问题。第一步就是自己先复现用Chrome DevTools的Network面板看关键请求的状态和响应头。打开Network面板找到index.html这个请求点开看Headers重点看这三点Status Code是不是200Response Headers里的Cache-Control是不是no-cache有没有走from disk cache或from memory cache。如果当前页面显示的是旧内容但是Network里index.html明明返回了200而且是新内容的响应... 这种情况基本可以断定是JS/CSS资源的缓存问题继续看具体的JS文件请求。在Network面板里按JS类型过滤看每个JS请求的状态码。带哈希的JS文件显示200 (from disk cache)是很正常的因为它本来就应该缓存但如果入口index.html是新的、引用的JS明明是新的文件名却加载的还是旧代码逻辑那大概率是浏览器本地缓存了旧的index.html也就是说缓存策略还是没完全生效。排查时有个非常有用的技巧点击请求然后选Preview或Response看返回头和内容。配合curl -I命令在服务器上直接看响应头是最靠谱的因为能完全绕过浏览器缓存curl -I https://your-domain.com/返回结果里能看到cache-control、etag、last-modified这些关键信息。如果服务器返回的头和预期不一致先修服务器配置如果头是正常的那就怀疑CDN层如果CDN也正常那就是浏览器端的历史缓存问题一般让用户强制刷新解决。4.2 常见问题速查三大主因对应不同解法我把自己遇到过的线上缓存问题总结成了一个速查表基本覆盖了绝大多数“页面没更新”的场景。现象可能原因排查方向解决方法用户看到旧页面DevTools里index.html是from cache入口HTML被强缓存看index.html响应头Cache-Control配置no-cache/no-storeindex.html是新的但JS/CSS加载的是旧文件静态资源文件名没带哈希或哈希没变化看构建产物文件名构建配置用contenthash页面一半新一半旧部分资源走了长缓存部分没缓存逐个看资源响应头统一分类缓存策略自己测试是新的用户说旧CDN节点缓存未刷新检查CDN缓存命中CDN控制台手工刷新手机是旧的电脑是新的手机浏览器命令行缓存无让用户下拉刷新或清站点数据其实这些问题根因都一样就是“资源在某个缓存层级被错误地缓存了”。区别只在于缓存发生在哪一层浏览器、CDN还是服务器。找到正确的层解决起来就顺手多了。4.3 日常开发调试时如何绕开缓存干扰做开发调试的时候缓存往往会干扰你的判断所以学会绕开缓存也很重要。DevTools的Network面板里有个Disable cache勾选框勾上之后只要DevTools是打开状态所有请求都不会走强缓存但是注意它只在DevTools开启时生效关掉DevTools一切恢复正常。还有一个更精细的方法直接在请求上右键选择Clear browser cache或Clear site data前者清掉浏览器针对这个站点的缓存文件后者更彻底会连Cookie、LocalStorage、Service Worker这些一起清掉。如果你想在不打开DevTools的情况下测试缓存策略可以给URL加个查询参数比如https://your-domain.com/?v123。但这个手段只能用于临时测试因为项目里的静态资源请求一般不会带这个参数加了它其实是在用不同的URL绕过缓存测的并不是实际的线上路径。所以更推荐的做法是写代码测试时直接改文件内容并重新构建然后看产物的哈希有没有变化这个验证比手动加参数靠谱得多。Service Worker是另一个比较特殊的缓存机制它比普通浏览器缓存更强力可以在离线状态下直接拦截请求并返回缓存内容。如果你在项目中注册了Service Worker那问题排查就要多考虑一层即使HTTP缓存策略全部正确Service Worker还是可能把旧的HTML或JS返回给页面。排查方式是去DevTools的Application面板里看Service Workers然后点击Unregister并刷新页面看问题是否消失。4.4 发布新版本的最佳实践降低缓存问题影响的灰度手段缓存问题不能等上线后再解决发布过程中就应该有预判。我现在带项目时一般会要求团队做好这几件事第一所有页面跳转和路由切换时不依赖浏览器刷新而是通过前端路由控制。SPA应用里用户只要不刷新页面就不会触发整站资源重新加载也就不存在缓存问题这种体验其实是最好的。第二对于可能长时间停留在页面的用户可以在前端代码里做一个版本检查。比如发布时往index.html里埋一个meta nameversion contentv2.3.0前端启动后用fetch带cache: no-store去请求version.json这个JSON文件设置成永远不缓存比对版本号如果发现和服务端不一致弹一个“检测到新版本点击刷新”的提示。这种方式比让用户盲目清缓存体验好得多也给了无法强制刷新场景下的兜底方案。第三CDN缓存的时间不要设置得太长特别是如果你经常有更新频繁的小版本发布CDN缓存尽量配合源站的Cache-Control。你可以在源站把index.html设为no-cacheCDN上对HTML做刷新规则比如每5分钟回源检查一次。这样即使忘记手动刷新CDN最坏情况也只是延迟几分钟看到新版本而不会出现发布三天后用户还访问旧页面的极端情况。4.5 一个经典案例的完整复盘最后分享一个我之前遇到的真实案例可以帮你把这些知识点串起来。当时项目是Vue全家桶加nginx部署上线流程是Jenkins构建后直接把dist目录传到服务器。有次发版后我和同事在各自电脑上验证都是新页面但第二天客户那边反馈页面还是几个版本之前的而且一直持续。排查过程分为三步。第一步查源站。我在服务器上执行了curl -I发现index.html的响应头是Cache-Control: public, max-age0虽然没有缓存但也没有no-cache或must-revalidate这意味着浏览器会用启发式缓存。于是我把nginx里index.html的配置改成了no-cache, no-store, must-revalidate确保源头没有硬伤。第二步查CDN。客户那边访问域名走了CDN我发现CDN节点上的index.html还有旧版本因为旧响应头里没加no-cacheCDN按默认策略缓存了。这解释了为什么我们自己电脑访问是新的客户却是旧的他们走的CDN节点缓存的是旧入口。在CDN控制台刷新了入口文件的缓存后大部分客户恢复了。第三步查残留问题。有个别客户反馈刷新后还是旧页面让他们微信里打开链接也不生效。后来发现他们用的是App内置浏览器那个内置浏览器会缓存整个页面。最后给出的解决方案是让用户关闭这个页面重新进入同时把前端静态资源全部改成带哈希的长缓存这样即使入口被缓存了下次进入也能通过新入口加载到最新资源。这个案例其实把缓存问题的几个层面完整呈现了一遍源站配置、CDN缓存、客户端内置浏览器缓存。每一层都可能出问题排查时要从源站往用户方向一层层查不要只盯着浏览器端。我在实际写配置的时候还有一个习惯想分享给你把所有缓存策略集中放在一个配置文件里维护不要散落在一堆nginx配置片段里。我习惯在nginx.conf里把缓存相关规则独立成段并写上注释说明为什么这么配比如“index.html不能缓存因为要保证每次都能拿到新入口”“assets目录全部走hash长缓存”。这样哪怕三个月后你回头看这套配置也能一眼明白当时的决策原因不会觉得这些规则是一堆莫名其妙的魔数。