
1. 页面秒开与旧版本并存为什么前端必须懂缓存管理我做了将近十年的前端真正让我从“写页面”变成“搞性能”的转折点不是框架升级不是工程化重构而是一个再普通不过的周五晚上。版本发布完不到半小时客服群就炸了用户打开页面还是老样子按钮点不动样式错乱甚至有人截图说页面看起来像被“腰斩”了一半。我当时第一反应和大多数前端一样是不是用户没清缓存让他们清一下就好了。但冷静下来才知道这句话等于把问题甩给了用户而用户根本不欠你什么。真正的问题出在我自己身上项目上线这么多年我们从来没有认真设计过缓存管理方案。HTML被浏览器强缓存了用户拿到的还是旧入口里面的JS、CSS引用也全是旧版本页面自然还是老的。后来我花了整整两周时间把整个缓存链路重新梳理了一遍才真正明白一件事前端缓存管理不是“加个版本号”那么简单它既是让页面秒开的核心手段也是发版后用户能不能第一时间拿到新功能的关键关卡。这篇内容我打算把我踩过的坑、改过的配置、反复验证过的方法全部摊开来讲。适合正在做Web应用、被“用户永远在旧版本”困扰、或者想优化首屏加载速度的前端开发者参考。我不会只给你一段“加个hash”的结论而是把浏览器缓存是怎么工作的、为什么HTML不能长缓存、静态资源要怎么配、发版后怎么让用户无感更新一条条讲明白。看完之后你可以直接对着自己项目去改。2. 先把缓存这口锅分清楚强缓存、协商缓存、Service Worker2.1 浏览器缓存的两层验证机制聊缓存管理之前一定要把浏览器最基础的两种缓存机制理顺否则后面配置的时候只会越调越乱。第一层是强缓存。服务器在响应里带上Cache-Control: max-age3600浏览器收到后就会把这个资源存进本地在3600秒内再次请求同一个URL时根本不会发出网络请求直接拿本地副本Network面板里显示200 (from disk cache)或者from memory cache。这个机制的特点就是快快到可以忽略不计但它有个致命问题在这3600秒内如果服务器上的文件已经变了浏览器也不知道。第二层是协商缓存。服务器返回资源的同时带上ETag或者Last-Modified浏览器下次请求时会把标记带上去问服务器“资源变了吗”服务器若说没变会返回一个体积极小的304 Not Modified浏览器继续用本地副本如果变了就直接返回新的200。它比强缓存多一次网络往返但胜在“新鲜度”有保障。你可以把这两层机制理解成图书馆借书强缓存相当于你借了本书回家规定一个月内不用再回图书馆协商缓存则是你每天去图书馆翻一下借阅记录确认书没有新版如果有新版就换一本。网页资源该用哪种取决于它是“永远不会变”还是“经常可能变”。2.2 HTML、静态资源、接口的缓存定位完全不同很多人做缓存管理容易犯一个错对所有资源一视同仁。实际上前端项目里至少有三类资源它们的缓存策略完全不同。HTML文件是整个页面的入口它内部引用了JS、CSS、图片的地址。HTML一旦被长缓存用户就永远看不到新引用的静态资源。所以HTML一定要走协商缓存或者干脆设置成每次回源验证确保用户每次打开页面至少能知道“有没有新版”。我见过很多项目把index.html设成了Cache-Control: max-age86400结果每次发版用户都要第二天才能看到新页面这是非常典型的反面教材。带哈希指纹的静态资源比如app.8f3a2b9e.js、chunk.6d21c4.css它们的文件名已经和内容绑定内容不变文件名就不变内容一变文件名一定变。这种资源完全可以放心地设置一年甚至更久的长缓存Cache-Control: public, max-age31536000, immutable。用户第一次下载后后续访问全部命中本地缓存秒开就靠这个。接口数据则要看业务性质。实时性要求高的接口比如用户信息、购物车数量通常不应该被浏览器缓存而一些不常变的列表数据可以配合ETag做短时间协商缓存。要注意的是浏览器对普通GET请求默认是有缓存行为的很多前端以为没设就不缓存其实请求头里可能已经被缓存命中导致页面数据迟迟不更新。后文我会给出具体配置方法。2.3 用户“清缓存”为什么永远治标不治本过去遇到“用户看到旧版本”的问题很多前端的第一反应就是让用户强制刷新或者去设置里清缓存。我后来才意识到这个做法有三个大问题用户根本分不清“强刷”和“普通刷新”的区别更不愿意为了你的发布去清理浏览器数据强制刷新只是绕过了当前页面的缓存但下一次访问如果没有配置好旧版本问题还是会复现最关键的是清缓存会把用户辛辛苦苦积累的本地资源全部清掉下次打开会变得非常慢反而制造了新的体验问题。缓存管理的目标应该是让所有用户在不做任何操作的情况下既能享受秒开又能在发版后最短时间内拿到新版本。把这句话记牢后面所有的方案都是围绕它展开的。3. 从加版本号到contenthash静态资源缓存的关键一跃3.1 还在用app.js?v1.2这个方案有三个坑早期很多前端项目解决缓存问题的方式是在静态资源后面拼一个 query 参数例如script srcapp.js?v1.2.0。这种方式确实能让浏览器在版本号变化后重新下载文件我也用过很长一段时间但它有三个很实际的坑。第一版本号经常忘记改。代码改了构建完忘了更新版本号导致用户继续用旧文件线上bug无法修复。第二粒度太粗。只要任何代码变了整个app.js的版本号就要变所有用户都会重新下载这个动辄几百KB甚至几MB的JS文件其他没改的代码也被迫重新下载浪费带宽也拖慢速度。第三query参数在部分代理和CDN上的表现不可靠。某些中间缓存节点可能忽略query参数无论版本号怎么变它都返回旧的缓存副本这就出现了你明明改了版本号用户还是老页面的诡异现象。3.2 webpack/Vite构建产物的hash配置正确的做法是让构建工具在文件名里直接生成内容哈希也就是 contenthash。文件内容不变文件名就不变内容一旦变化哈希也会跟着变。这样一个新版本发布后老的哈希文件还在浏览器缓存里继续用只有真正变化的文件才需要重新下载。如果你的项目还在用 webpack可以在 output 里这样配置// webpack.config.js module.exports { output: { filename: js/[name].[contenthash:8].js, chunkFilename: js/[name].[contenthash:8].chunk.js, path: path.resolve(__dirname, dist) }, optimization: { moduleIds: hashed, runtimeChunk: single } }这里有两个容易被忽略的点moduleIds要设置成hashed避免因为模块引入顺序调整导致所有文件哈希变化runtimeChunk单独提取运行时代码避免业务代码的变化导致整个启动加载文件也跟着变化。如果你用的是 Vite它默认就会生成带哈希的文件名比如assets/index-1a2b3c4d.js一般不需要额外修改。关键是确认构建产物里HTML引用的都是这种带哈希的文件。3.3 HTML必须走协商缓存资源才能放心长缓存静态资源设置了一年长缓存之后紧接着就要回答一个问题用户怎么知道有新的资源呢答案藏在入口HTML上。浏览器先请求HTML拿到里面引用的app.8f3a2b9e.js然后才会请求这个JS。如果HTML每次都能从服务器确认“有没有新版”当新版HTML引用了新的哈希文件名时浏览器自然就会去加载新文件。所以正确的姿态是HTML设置Cache-Control: no-cache也就是每次使用前都要到服务器验证一下配合ETag使用如果服务器上的HTML没变返回304用户继续用本地HTML如果服务器上的HTML变了返回200和新的HTML用户立即拿到新版。静态资源则设置成一年长缓存两者互相配合才能达到“秒开且及时更新”。这里要特别提醒千万别把no-cache理解成“不缓存”。它实际上是“允许缓存但每次必须回源验证”验证通过返回304验证失败返回200。真正不缓存的是no-store那种只适合接口里的敏感数据。4. 让用户无感拿到新版本主动版本检测与更新提示4.1 后端响应头与Nginx配置的最终形态光靠浏览器自带的协商缓存其实已经能解决绝大多数更新延迟问题。但在实际企业项目里HTML前面往往还挂着一层CDNCDN节点的缓存策略不一定完全受你控制另外一些老旧的浏览器对于no-cache的处理也可能有差异。所以我会在前端再加一道主动防线主动检查版本号发现不一致就提示用户刷新。在这之前先把服务器端的缓存头配置好。以 Nginx 为例这是我目前比较推荐的配置形态# 入口HTML允许缓存但每次验证 location / { add_header Cache-Control no-cache always; add_header ETag W/ $etag; try_files $uri $uri/ /index.html; } # 带哈希的静态资源长缓存 location /assets/ { expires 1y; add_header Cache-Control public, max-age31536000, immutable always; } # 实时性要求高的接口不缓存 location /api/ { add_header Cache-Control no-store always; }注意add_header后面那个always参数非常关键。默认情况下Nginx 只在响应码为200/201/204/206的时候才会输出add_header指定的头如果接口返回304或302头就会被吞掉。加上always之后所有响应码都会带上这个头避免出现“部分请求没生效”的怪问题。4.2 构建时写入版本号前端轮询比对为了让前端能主动发现新版本我会在构建时生成一个version.json内容大致是{ version: 1.4.2, buildTime: 2025-06-18 14:30:00 }这个文件可以放在public/目录下构建时自动写入。比如用 Vite 的define或者 Node 脚本读取package.json的版本号再生成。然后在应用入口处写一个轮询逻辑每隔一段时间请求一次version.json为了防止这个文件本身被缓存请求时要加上cache: no-store或者直接拼一个随机时间戳参数async function checkVersion() { const res await fetch(/version.json?t${Date.now()}, { cache: no-store }); const data await res.json(); const current window.__APP_VERSION__; if (current data.version ! current) { // 弹窗提示用户刷新 showUpdateTip(data.version); } } setInterval(checkVersion, 5 * 60 * 1000); window.addEventListener(focus, checkVersion);这里window.__APP_VERSION__是构建时注入进来的当前版本号可以通过环境变量方式写入。轮询间隔我一般控制在3到5分钟太频繁会白白增加服务器压力太久则失去“及时提示”的意义。监听focus事件的效果也很好用户从其他App切回浏览器时立刻检查一次体验上最自然。弹窗文案也要用心不要写“系统维护”而是写“发现新版本为了获得更好体验请点击刷新”这样用户配合度会高很多。这个方法的好处是即使CDN节点的缓存没有及时刷新用户只是晚几分钟看到新版本提示而不是一直停留在旧版本里。它不能替代响应头配置但作为兜底方案极其有效。4.3 Service Worker的新鲜度控制再进阶一点如果你用了 Service Worker 做离线缓存那缓存管理会更复杂。Service Worker 通常会在install阶段预缓存静态资源在fetch阶段拦截请求并使用缓存。它最大的坑是即使用户拿到了新HTMLService Worker 也可能用旧版本拦截请求导致页面还是旧的。我的实践经验是在install事件里判断当前缓存标识与构建版本是否一致如果发现版本变了就主动清理旧缓存然后调用skipWaiting()并在activate阶段用clients.claim()抢占所有客户端。核心代码大概长这样self.addEventListener(install, (event) { event.waitUntil( caches.open(CACHE_PREFIX version).then((cache) { return cache.addAll(STATIC_ASSETS); }).then(() self.skipWaiting()) ); }); self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((keys) { return Promise.all( keys.filter((key) !key.includes(version)) .map((key) caches.delete(key)) ); }).then(() self.clients.claim()) ); });Service Worker 能不能更新取决于浏览器是否拿到了新的 SW 文件。为了让它尽快发现变化服务器上对sw.js本身千万不要设置长缓存最好也走no-cache。如果你对离线体验没有强需求我建议初期不要碰 Service Worker先把手动的版本检测做好等静态资源缓存稳定了再引入否则调试的时候调试得想砸电脑。5. 真实项目缓存治理实操记录5.1 现状体检从Network面板看缓存命中的真实状态我接手那个“用户永远看旧版本”的项目时第一件事不是改代码而是先体检。打开 Chrome DevTools 的 Network 面板把Disable cache选项关掉然后连续刷新两次页面观察每条资源的加载状态。结果让我很意外HTML返回200状态码下面没有from memory cache而是真实网络请求说明HTML的强缓存可能没生效但JS文件全部返回200 (from disk cache)有些甚至显示206。再往下看JS文件名没有哈希全是app.js、vendor.js。服务器那边也没配缓存头Nginx 默认expires -1按理说不会缓存但浏览器看到这些 URL 没变化加上本地缓存策略激进依然会直接命中之前的副本。这就是为什么发版后用户还是旧页面的真正原因HTML和JS都漏水缓存完全不可控。我顺手用 Lighthouse 跑了一遍性能首屏加载时间大概是4.6秒其中光是下载那些未压缩且没有哈希的JS和CSS就花掉了近2秒。用户投诉“打开太慢”的根因也在这每个资源都要全部重新下载完全没有利用缓存。5.2 改造步骤从构建配置到服务器头一步步落地体检完之后我按下面这个顺序做了改造每一步都有明确的验证标准。第一步改构建产物。项目原本是 webpack 4我升级到 webpack 5 之后按照前面写的配置给JS、CSS、图片都加了contenthash并单独抽了 runtime chunk。构建完检查dist/index.html看到里面引用的文件全部变成了带哈希的新名字。第二步改服务器配置。在 Nginx 的location /里对HTML设置no-cache对/assets/目录设置一年长缓存对/api/接口设置no-store。这里多说一句如果你的接口出于性能考虑确实需要缓存建议只对特定 GET 接口单独设置而不是一刀切。我在改造中把“查询用户基本信息”这类接口设成no-store把“首页推荐列表”这类数据变化不频繁的接口设成了public, max-age60配合后端的ETag使用。第三步接入主动版本检测。项目本身是单页应用我直接沿用package.json里的版本号通过构建插件生成version.json并在登录成功后的主界面里写了一个全局的版本检测逻辑五分钟轮询一次拿到新版本就弹非模态提示条。第四步把所有静态资源迁移到CDN并设置缓存规则。CDN平台上的规则我统一设成index.html不缓存带哈希的静态资源缓存一年。这一步也踩了坑CDN默认会忽略add_header必须要在CDN控制台里自己配置不能只依赖源站的响应头。5.3 上线效果与踩坑记录改造后上线我用一个干净浏览器连续访问了三天每天打开同一个页面Network面板里的JS和CSS全部命中200 (from disk cache)没有一条真实网络请求。Performance面板显示首屏DOMContentLoaded时间从4.6秒降到1.2秒Lighthouse性能分数从52分升到91分。用户的“打开慢”投诉基本消失。后来有一次发版我在发完版本后打开公司群有人在里面问“是不是发版了页面提示了更新”那一刻我才觉得这套缓存管理是真的闭环了。但上线过程中也出过状况。第一次发版后我注意到有部分用户反馈“图片裂了”。排查后发现我把旧的哈希资源直接清理掉了而一些用户还停留在一个多小时之前的旧页面上旧HTML引用的旧文件名在服务器上已经404图片自然加载不出来。解决办法是构建产物不要直接清空旧目录先保留上一到两个版本的文件等CDN和HTML缓存自然过期后再清理。从那次之后我的发布脚本里刻意增加了一个“保留上一版本产物”的步骤这个问题就再也没有出现过。6. 高频问题排查缓存相关的十个现实场景6.1 用户反馈“还是老页面”的排查顺序如果你现在也遇到这个问题先不要怀疑用户没清缓存按下面这个顺序查基本能定位到根因。第一步看HTML的响应头。打开用户反馈的那个URL在Network面板里找到index.html看Response Headers里的Cache-Control。如果是max-age86400之类的强缓存问题就出在这改成no-cache就能解决。第二步看静态资源的文件名是否带哈希。如果还是app.js这种固定名字不管你怎么设置响应头浏览器都可能在某个时点用旧缓存必须改成哈希命名。第三步看CDN缓存设置。如果HTML在CDN上被缓存了会出现一种特别迷惑的现象你自己访问是新的但用户访问是旧的。这时候要回到CDN控制台把HTML的缓存策略改为不缓存或no-cache。第四步如果是小程序或混合App还要看底层WebView的缓存策略这个每个平台都不一样最快的验证方法是让测试同学在WebView里多刷新两次并观察响应头。6.2 缓存配置生效范围引发的几个坑我整理了几个实际排查过程中比较隐蔽的问题直接以表格形式分享出来你可以对照检查。现象可能的原因解决办法明明加了Cache-Control: no-cache但还是拿到旧数据Nginxadd_header没有加always304响应时头没输出配置改为add_header Cache-Control no-cache always;HTML已设置协商缓存但发版后用户仍是旧页面CDN层缓存了HTML源站响应头没生效在CDN平台单独设置HTML规则或让CDN回源时携带校验头静态资源文件名带hash但刷新后重新下载了所有文件可能是每次构建时生成新的无意义hash例如时间戳、随机值确保使用contenthash且固定moduleIdsService Worker导致页面永远更新不了SW缓存了旧HTML和旧资源且没有做版本控制清理旧缓存skipWaitingclients.claim接口数据一直不刷新请求被代理/CDN缓存或浏览器默认缓存了GET请求在请求里加cache: no-store或给URL加时间戳部分用户更新了部分没有用户访问的是不同的CDN节点节点缓存刷新时间不一致提高HTML回源频率主动版本检测兜底加了长缓存后修改一个小Bug但文件名没变构建配置有问题源文件没参与hash计算检查optimization.moduleIds和output配置使用immutable后强制刷新都拿不到新资源这是immutable的正常行为它告诉浏览器缓存期间无需验证如果要求可强刷更新建议不要用immutable只用max-age页面秒开但新功能提示怎么都不出现版本检测请求本身被缓存了每次都拿到旧的version.json给版本检测请求设置cache: no-store或加时间戳参数没有配置任何缓存但页面还是从缓存加载现代浏览器对子资源有默认的启发式缓存策略主动设置Cache-Control不要依赖默认行为这些坑基本都是我在不同项目里踩过的尤其是 CDN 缓存那两条很多团队只改了源站配置忽略了CDN层最后永远是“我这边好好的用户那边坏的”。6.3 我的三条缓存管理经验最后分享三条我总结出来的经验它们比我上面写的所有配置都重要。第一缓存管理要从第一个页面就开始设计而不是等项目大了再补。后来我在新项目起步时都会先约定三个规矩HTML必须走协商缓存静态资源必须带哈希接口默认不缓存确实需要缓存的单独声明。这三条写进开发规范之后后面基本没再出现过“版本更新不了”的问题。第二不要相信任何“看起来已经缓存生效了”的结论。验证缓存策略是否正确一定要用无痕窗口测一次关闭Disable cache之后再刷新测一次然后再清掉缓存测一次。三次的结果分别对应首次访问、二次访问、强制刷新三种场景只有这三种场景都符合预期配置才算真正完成。我还习惯在Network面板里逐个资源看响应头确认Cache-Control和ETag都出现在Response Headers里。第三每个项目都应该有一个“版本回退预案”。缓存管理做得越好版本回退的难度反而越大。因为静态资源被长缓存之后一旦新版本有严重bug你回退了服务器代码但用户可能已经下载了新版本资源。所以我会在构建时保留上一版本的产物如果遇到紧急回退只需要把服务器入口HTML回退到上一版用户在下一次访问时就能自动走回旧资源。这个预案看起来简单却能在最关键的时候帮你保住线上稳定性。做前端这些年我现在开发一个新页面第一件考虑的事情已经不是能不能跑通而是这个页面的缓存链路通不通。缓存管理做得好页面秒开是自然而然的事用户的投诉和深夜的性能焦虑也会一起远离你。希望这篇经验总结能让你少走几步弯路。