ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

前端发版缓存治理:资源指纹、HTML入口不缓存与版本检测

前端发版缓存治理:资源指纹、HTML入口不缓存与版本检测 1. 前端发版之后用户看到的还是旧页面到底卡在哪一层做过几年前端的人大概率都经历过这种场面测试同学在群里吼一句「你发的这个版本我看不到新按钮」你打开自己的浏览器一看新功能明明好好的。你让对面按Ctrl F5他按了好了。第二天又有一批用户反馈「页面没变化」你不可能让几十万人一起按强制刷新。这就是前端发版后浏览器缓存问题最典型的形态也是我这些年被问过最多的一类线上问题。先把结论摆在前面绝大多数所谓的「发版不生效」都不是服务器没更新而是用户的浏览器或者中间的某一层缓存把你上一次的产物留在了本地。前端发版和传统后端发版有一个本质区别——后端改的是服务端逻辑发完就立刻对所有人生效前端发的是一堆静态文件这些文件的宿主是用户的浏览器。你控制的只是「文件被放在哪」控制不了「用户什么时候愿意去拿新的」。这篇文章想解决的就是这件事怎么让前端项目在发版之后用户能及时拉取到最新版本代码并且这件事要自动、可靠、不打扰用户。我会把内容拆成几块来讲缓存分几层、每层怎么治理、构建产物怎么做指纹、端上怎么主动感知新版本、出了白屏怎么兜底、以及一堆我踩过的坑。适合已经上线过至少一个前端项目、被缓存问题折磨过的同学也适合还没上线、想提前把坑填掉的团队。1.1 一次典型的线上事故复盘先说个真实案例。几年前我在一个后台管理项目里做了一次「看起来很安全」的发版只改了一个按钮的文案和一个请求参数。构建完成传到服务器覆盖旧文件完事。半小时后客服转来一个截图用户点按钮报 400 错误因为前端还带着旧的参数名。我打开自己的浏览器正常换无痕窗口正常换手机正常。最后让运维查访问日志才发现那台服务器上index.html的响应头里挂着Cache-Control: max-age3600。这一小时里凡是刚刚访问过页面的用户浏览器里都躺着一张旧的 HTML。这张旧 HTML 里引用的是旧的 JS 文件名app.9f3c1a.js而这个文件已经在服务器上被覆盖成了新内容——注意文件名没变内容变了。于是浏览器用旧 HTML 去加载同名的新 JS参数自然是错的。这就是最经典的一种「版本撕裂」HTML 是旧的JS 是新的两边对不上。如果当时产物文件名带了内容指纹情况会好很多新 HTML 引用app.7b2e9d.js旧 HTML 引用app.9f3c1a.js只要旧文件还在服务器上旧 HTML 依然能跑起来用户最多是「多看了几分钟老版本」而不是「直接报错」。1.2 缓存其实分好几层别只盯着浏览器很多人一提到缓存就只想到浏览器实际上从你的构建机到用户屏幕之间中间站着至少五道门层级典型表现谁在控制浏览器内存缓存同一次会话内前进后退很快浏览器自动无法干预浏览器磁盘缓存关闭标签再打开仍然命中HTTP 响应头 浏览器策略Service Worker 缓存断网还能打开页面你自己写的 SW 代码CDN 边缘节点全国用户拿到不同版本缓存规则 刷新任务企业网关代理只有某个公司内网用户异常对方 IT 配置你控制不了我遇到过最离谱的一次是某客户公司内网部署了一台正向代理对所有静态资源无差别强缓存 7 天。我们换了文件名、加了时间戳、刷新了 CDN全都没用。最后只能和对方 IT 沟通。这件事给我一个很深的教训你能做的只有把自己的策略做对剩下的交给监控和兜底别指望 100% 覆盖。所以后面的所有方案我都会强调「正确策略 主动检测 失败兜底」三件套而不是幻想有一个开关能一劳永逸。1.3 让用户手动清缓存是伪方案「你按一下 Ctrl F5」「你清一下浏览器缓存试试」——这两句话在我的团队里是被禁掉的。原因很简单用户不会用户清了缓存会丢掉登录态以外的很多东西体验极差移动端根本没法指导而且这会掩盖真正的问题。正确的思路是让浏览器自愿去拿新文件而不是靠用户手动干预。要做到「自愿」核心就两件事给静态资源做内容指纹并长期缓存给 HTML 关闭强缓存。这两句话听着简单但每一句背后都有坑下一节开始逐个拆。2. 缓存治理的核心思路资源带指纹入口不缓存缓存治理这件事如果只能记一句话那就是带指纹的资源使劲缓存不带指纹的资源一律别缓存。听起来像句废话但线上 90% 的缓存事故都是因为违反了这条规则——要么 HTML 被缓存了要么 JS/CSS 没带指纹却设了长期缓存。2.1 为什么带 hash 的资源可以设置一年先解释「指纹」到底解决了什么。假设你的入口 HTML 里写的是script src/assets/index.a3f91c2.js/script这个a3f91c2是从文件内容算出来的哈希。文件内容只要有一个字节变化哈希就变文件名就变URL 也就变了。对于浏览器来说index.a3f91c2.js和index.7b2e9d1.js是两个完全不同的资源不存在「拿旧版本」的问题。既然 URL 和内容一一对应那这个 URL 就可以放心地设成超长缓存Cache-Control: public, max-age31536000, immutablemax-age31536000是一年。immutable是给支持它的浏览器一个额外承诺这个资源在有效期内绝对不会变用户按刷新按钮时也不用回服务器验证。这个immutable很关键没有它的话用户在地址栏回车或者点刷新浏览器依然会发一个带If-None-Match的请求去问服务器「你变了没」虽然拿到的是 304但多了一次往返弱网下就是几百毫秒的差别。注意immutable只对带指纹的资源使用对 HTML 用它是灾难性的会让用户按刷新都拿不到新页面。2.2 HTML 为什么必须走协商缓存HTML 是整个应用的入口它的名字是固定的/index.html不能带指纹除非你愿意改造路由和服务器。既然名字固定它每天都会变那它就必须每次都去服务器确认一下。这里有几个策略选择我列个表对比一下响应头组合行为我的评价max-age0, must-revalidate每次校验可复用本地副本推荐省流量no-cache同上语义更明确推荐最常用no-store完全不缓存每次都完整下载最保险弱网略亏max-age3600一小时内不校验事故源头禁用no-cache这个名字有迷惑性它不是「不缓存」而是「每次使用前必须去服务器验证」。配合ETag或Last-Modified服务器返回 304 的时候只传输几百字节的响应头比重新下载整个 HTML 便宜得多。我自己项目里统一用的是no-cache, no-store, must-revalidate三连把老浏览器对还有 IE 时代的代理都照顾到代价是 HTML 每次完整下载。一个 gzip 后两三 KB 的 HTML这个代价我认为完全可以接受。2.3 Nginx 配置的实操细节和那个「继承陷阱」配置本身不难难在add_header的继承规则。先看一份可以直接抄的配置server { listen 80; root /usr/share/nginx/html; index index.html; # 带指纹的静态资源长期强缓存 location ~* \.(?:js|css|png|jpe?g|gif|svg|webp|woff2?|ttf|eot|ico)$ { expires 1y; add_header Cache-Control public, max-age31536000, immutable always; access_log off; } # 版本文件绝对不能缓存 location /version.json { add_header Cache-Control no-store, no-cache, must-revalidate always; expires -1; } # 入口 HTML每次协商 location /index.html { add_header Cache-Control no-cache, no-store, must-revalidate always; add_header Pragma no-cache always; expires -1; } location / { try_files $uri $uri/ /index.html; } }这里有个大坑必须讲Nginx 里add_header不会和上级叠加只要当前层级出现了add_header父层级的全部add_header都会失效always参数只影响是否在错误响应中也添加不影响继承。我见过很多项目在server块里配了一堆安全头X-Frame-Options、Content-Security-Policy结果某个location里加了个Cache-Control这些安全头就悄无声息地全没了。排查方式是直接curl -I看响应头别信配置文件。另外一个细节是expires -1它会生成Expires头为一个过去的时间。这个头是 HTTP/1.0 时代的产物现在看Cache-Control就够了但有些老代理只认Expires所以顺手加上没坏处。2.4 CDN 和网关这一层往往才是真凶配置完源站别急着庆祝如果你的资源走 CDN那么用户拿到的响应头是 CDN 节点返回的不是源站返回的。这里有两个必须做的动作一是确认 CDN 的缓存规则没有覆盖你的源站头二是在 CDN 控制台配置好缓存键和刷新策略。常见的坑是CDN 默认按文件后缀匹配缓存策略.html也会被缓存而且默认 10 分钟到几小时。这时候你在源站怎么配都没用因为请求压根没到源站。解决办法是给 HTML 单独配一条「不缓存」规则或者在发版流程里自动调 CDN 的刷新接口。如果你不想依赖 CDN 刷新因为刷新任务有延迟全网点生效可能要几分钟有一个更稳的办法让 HTML 也带上版本号比如/index.html?v20260518-1。代价是路由和静态资源的相对路径要处理干净收益是彻底绕开 CDN 的 HTML 缓存。这个方案我在几个小项目里用过效果很干净但大项目里改造成本不低用之前要评估。3. 构建产物怎么配才能让指纹真的「随内容变化」策略定完了接下来是构建工具层面。这一步的目标是内容变了文件名必须变内容没变文件名最好别变。前半句保证用户拿到新代码后半句保证老用户的缓存不白白失效。3.1 Webpack 里的 contenthash 与确定性 IDWebpack 项目里output.filename和output.chunkFilename要用contenthash不要用hash或chunkhash。这三者的区别我见过太多人搞混hash是整次构建的哈希任何一个文件改动都会让所有文件改名chunkhash是 chunk 级别的同一个 chunk 里的模块变了它才变但样式抽离之后经常出现「只改了 JSCSS 也跟着改名」的情况contenthash是按文件实际内容算的最精确。// webpack.config.js 节选 module.exports { output: { filename: assets/js/[name].[contenthash:8].js, chunkFilename: assets/js/[name].[contenthash:8].chunk.js, assetModuleFilename: assets/media/[name].[contenthash:8][ext] }, optimization: { moduleIds: deterministic, chunkIds: deterministic, runtimeChunk: single, splitChunks: { cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: vendors, chunks: all } } } } }四个配置项各自解决什么问题值得说清楚。moduleIds: deterministic是为了避免新增一个模块导致后面所有模块 ID 重排——在默认的natural模式下你在文件中间插一行 import后面所有模块的 ID 都往后挪一位结果就是全量 hash 变化用户缓存全部失效。runtimeChunk: single是把 webpack 的运行时代码单独抽出来否则运行时代码会混在业务 chunk 里导致你只改了一个字运行时里的模块映射表变了整个 chunk 的 hash 也跟着变。3.2 Vite 项目的默认行为与常见误配Vite 在生产构建时默认就会给产物加 hash文件名形如index-DiwrgTda.js这一点比 Webpack 省心。但有两个地方容易出问题。第一个是把build.rollupOptions.output手写覆盖了却没带 hash// vite.config.js —— 这是错误示范 export default { build: { rollupOptions: { output: { entryFileNames: assets/[name].js // 少了 [hash]灾难 } } } }第二个是build.assetsInlineLimit设置过大把图片、字体全部内联进 JS 或 CSS导致一个图标改动就让整个 bundle 的 hash 变化。默认 4096 字节是合理的别随手改成几十 KB。还有一个 Vite 特有的细节如果你需要自己控制 HTML 里资源的注入顺序或者要把 HTML 交给后端模板渲染记得打开build.manifest: true它会生成一份manifest.json里面记录了源文件到产物文件的映射关系。这份文件在生产环境一般不直接给浏览器用但它是你做 SSR、做资源预加载、做版本对比的好帮手。3.3 为什么我改了代码hash 却没变这是被问得最多的问题之一。我整理了一个排查顺序基本能覆盖九成情况现象大概率原因处理方式改了 JSJS 文件名没变用了[hash]且改动没影响构建换成[contenthash]只改了 CSSJS 也改名样式没抽离混在 JS chunk 里用 MiniCssExtractPlugin加了一行 import全量改名moduleIds不是 deterministic改为deterministic两次构建产物 hash 不同产物里混入了时间戳、随机数检查 DefinePlugin、注释两次构建产物 hash 相同构建缓存命中产物没重新生成清缓存重试「两次构建产物不一致」这个坑特别阴。有一次我们的 CI 每次构建出来的vendors.jshash 都不一样导致每次发版所有用户都要重新下载 1.5MB 的依赖包。查了半天发现是某个同事在代码里用了new Date()生成构建时间然后通过DefinePlugin注入到了业务代码里。时间一变代码就变hash 自然变。类似的情况还有webpack.optimize.ModuleConcatenationPlugin的顺序问题、lodash的按需引入顺序不稳定等等。我的建议是把「两次构建产物是否一致」做成 CI 里的一个检查连续构建两次比对dist目录下所有文件的哈希清单不一致就报警。这个检查能帮你提前发现很多隐蔽问题。3.4 代码分割怎么切才能让长期缓存更有效长期缓存的价值取决于「用户需要重新下载多少东西」。如果你把所有业务代码打进一个app.js那每次发版用户都得重新下载全部代码。合理的切法是按「变更频率」分层vendors层放node_modules里的第三方库变更频率最低可以缓存很久common层放多个页面共用的业务组件变更频率中等页面级 chunk 放各自页面独有的逻辑变更频率最高但体积小。这样一次普通发版用户可能只需要重新下载几十 KB 的页面 chunk其他都命中本地缓存页面加载几乎是瞬时的。这个优化的收益在弱网环境下特别明显我在海外项目上测过把 1.2MB 的单一 bundle 拆成 400KB vendor 若干小 chunk 之后二次访问的首屏时间从 3.4 秒降到 1.1 秒左右。4. 端上主动感知新版本别等用户自己发现前面几节解决的是「用户下次访问时能不能拿到新代码」。但还有一个场景没覆盖用户一直开着页面不刷新。后台管理系统、大屏看板、客服工作台这类产品用户可能开着页面一整天。这种情况下哪怕你的缓存策略完美无缺他看到的依然是打开页面那一刻的代码。所以还需要一套「端上主动检测」的机制。4.1 用 version.json 做版本哨兵思路很简单每次构建时生成一个version.json里面存这次构建的版本标识前端在运行时定期去拉这个文件发现版本和自己不一致就提示用户刷新。生成脚本可以直接用 Node 写// scripts/gen-version.mjs import { writeFileSync, mkdirSync } from node:fs import { execSync } from node:child_process const safeExec (cmd, fallback) { try { return execSync(cmd).toString().trim() } catch { return fallback } } const versionInfo { version: process.env.BUILD_VERSION || ${Date.now()}, commit: safeExec(git rev-parse --short HEAD, unknown), branch: safeExec(git rev-parse --abbrev-ref HEAD, unknown), buildTime: new Date().toISOString() } mkdirSync(dist, { recursive: true }) writeFileSync(dist/version.json, JSON.stringify(versionInfo, null, 2)) console.log(version.json 已生成, versionInfo)关键点在于version这个字段必须每次发版都不同。我一般直接用 CI 的流水线编号或者用构建时间戳简单可靠。千万别用package.json里的版本号因为很多团队根本不改那个版本号导致检测永远失效。这个文件要和 HTML 一样设置成不缓存否则检测到的是缓存里的老版本白忙一场location /version.json { add_header Cache-Control no-store, no-cache, must-revalidate always; expires -1; }4.2 前端怎么检测才不打扰用户检测代码要处理三件事什么时候查、查到怎么办、查不到怎么办。// version-check.js const CURRENT_VERSION __BUILD_VERSION__ // 构建时注入 const CHECK_INTERVAL 5 * 60 * 1000 let timer null let notifying false async function fetchRemoteVersion() { const res await fetch(/version.json?_t${Date.now()}, { cache: no-store, headers: { Cache-Control: no-cache } }) if (!res.ok) throw new Error(HTTP ${res.status}) return res.json() } async function checkVersion() { if (notifying) return try { const remote await fetchRemoteVersion() if (remote.version remote.version ! CURRENT_VERSION) { notifying true showUpdateNotice(remote) } } catch (err) { // 检测失败绝不能影响主流程静默忽略 console.debug([version-check] 检测失败, err) } } function startVersionCheck() { checkVersion() timer setInterval(checkVersion, CHECK_INTERVAL) document.addEventListener(visibilitychange, () { if (document.visibilityState visible) checkVersion() }) }几个设计上的取舍说明一下。查询间隔我选了 5 分钟这是一个经验值太短会给服务器增加无谓的请求量太长用户会明显感觉到延迟。visibilitychange这个监听很重要用户切回标签页时立刻查一次体验上「刚回来就提示更新」比傻等定时器好得多。cache: no-store加上 URL 上的时间戳是双保险。有些中间代理不看Cache-Control头但 URL 变了它就必须回源这个技巧在处理顽固代理时非常管用。4.3 更新提示的交互设计比技术实现更重要技术实现半小时就能写完难的是「怎么提示用户」。我见过两种极端做法都不好。一种是location.reload()直接暴力刷新。用户正在填一个长表单或者正在看一个已经加载好的数据表格你一个刷新全给他干没了。这种做法上线一次就会被投诉。另一种是弹一个模态框必须点「更新」才能关掉。这个也烦人用户可能只是想先把手头的活干完。我现在的做法是页面顶部滑出一条不遮挡内容的提示条文案是「有新版本点击刷新」带一个关闭按钮。用户可以立刻点也可以先放着。等他手头事情做完自然会点。如果用户长时间不点比如超过 30 分钟再次操作页面关键入口时再提示一次。function showUpdateNotice(remote) { const bar document.createElement(div) bar.className app-update-bar bar.innerHTML span检测到新版本${remote.commit}是否立即刷新/span button>registerMicroApps([ { name: sub-app, entry: /sub-app/index.html?v${SUB_APP_VERSION}, container: #sub-container, activeRule: /sub-app } ])SUB_APP_VERSION可以从前面的version.json体系里拿也可以在 qiankun 的loadMicroApp场景下每次动态拼一个时间戳。代价是每次加载都要重新拉 HTML但子应用 HTML 通常很小换来的是版本一致性我认为很值。另外子应用切换时要小心「样式残留」。qiankun 的样式隔离在某些场景下不彻底旧版本子应用的样式表可能还挂在页面上导致新版本样式错乱。我一般会在子应用卸载时手动清理它注入的style和link标签虽然有点暴力但很有效。5. Service Worker 和 PWA 带来的额外一层缓存如果你的项目用了 PWA那么缓存问题会复杂一个量级因为 Service Worker 的缓存优先级高于浏览器 HTTP 缓存。换句话说HTTP 层配得再对SW 不更新用户看到的还是老页面。这也是 PWA 项目「发版不生效」的头号原因。5.1 理解 SW 的更新时机Service Worker 的更新判断很简单浏览器在导航时会去请求sw.js本身如果这个文件的内容字节有任何变化就认为有更新触发install新版本。关键在于——很多人构建后sw.js内容没变因为 Workbox 生成的 precache manifest 清单如果没变整个文件就是一模一样的。所以第一个要点确保 Workbox 的 precache manifest 包含了带 hash 的产物文件。这样只要有一个 JS 变更manifest 就变sw.js就变SW 就会触发更新。第二个要点是skipWaiting和clientsClaim这两个开关。默认情况下新 SW 会进入waiting状态等所有旧页面关闭后才激活。对于单页应用来说用户可能永远不会「关闭所有页面」于是新 SW 就一直等不到激活。// 方案 A立即激活激进可能造成版本撕裂 workbox.core.skipWaiting() workbox.core.clientsClaim() // 方案 B等用户确认后再激活推荐 self.addEventListener(message, (event) { if (event.data event.data.type SKIP_WAITING) { self.skipWaiting() } })我推荐方案 B。配合workbox-window监听waiting事件在页面上弹和前面一样的更新提示条用户点击时发消息给 SW 让它skipWaiting然后再刷新页面。import { Workbox } from workbox-window if (serviceWorker in navigator) { const wb new Workbox(/sw.js) wb.addEventListener(waiting, () { showUpdateNotice({ commit: sw-update }, () { wb.addEventListener(controlling, () window.location.reload()) wb.messageSkipWaiting() }) }) wb.register() }5.2 SW 缓存清理与「白屏」的关联还有一个必须处理的点旧的 precache 缓存要清理掉。Workbox 默认会清理不属于当前 revision 的旧缓存这个行为没问题。但如果你手写了caches.open并且自己cache.addAll就很容易忘记清理导致 Cache Storage 越堆越大。排查 SW 相关的问题Chrome DevTools 的 Application 面板是主战场。看Service Workers一栏的Update on reload选项调试时勾上发版前记得告诉测试同学别勾看Cache Storage里有哪些缓存、分别多大、最后更新时间。我遇到过几次「用户看到的是三周前的页面」最后都是在这里发现某个老缓存没被清掉。5.3 不想用 SW 了怎么干净地卸载有些团队早期上了 PWA后来发现维护成本高想下线。这时候不能只删掉注册代码因为已经安装的 SW 依然在用户浏览器里运行。正确的下线方式是发一版「自杀式 SW」让它自己注销自己并清空缓存。// 下线用的 sw.js self.addEventListener(install, () self.skipWaiting()) self.addEventListener(activate, async () { const keys await caches.keys() await Promise.all(keys.map((k) caches.delete(k))) await self.registration.unregister() const clients await self.clients.matchAll({ type: window }) clients.forEach((client) client.navigate(client.url)) })这段代码的作用是清空所有缓存、注销自己、并让所有打开的页面重新加载。等这版 SW 被浏览器拉取并激活之后用户就彻底摆脱 SW 了。这个过程可能需要一两天才能覆盖全部活跃用户要有耐心。6. 常见问题速查表与排查手法前面讲的是「怎么设计」这一节讲「出问题了怎么查」。我把这些年遇到过的缓存相关故障整理成了一张表基本上照着症状对号入座就能定位。6.1 症状、根因与处置对照表症状最可能的原因处置方式用户说页面没变化你本地正常HTML 被强缓存检查index.html响应头页面白屏控制台ChunkLoadError旧 HTML 引用已被删除的 JS指纹命名 保留旧产物 错误重载只有部分用户异常CDN 边缘节点未刷新提交刷新任务或改用版本化 HTML样式错乱、布局崩了CSS 与 JS 版本不一致统一构建产物禁止手工引 CDN 版本无痕模式正常普通模式异常本地缓存或扩展干扰对比响应头与 SW 状态移动端正常PC 端异常企业网关代理缓存抓包确认联系对方 IT发版后立即大量 404旧产物被清理旧 HTML 还在产物保留至少两个版本每次访问都重新下载全部资源缓存头缺失或 CDN 覆盖检查响应头与 CDN 规则「保留旧产物」这条我特别想强调。我的发版流程里dist目录下的文件名带 hash所以新旧文件天然不冲突。发版时是「增量上传」而不是「清空再上传」服务器上保留最近三到五个版本的静态资源。这样即使有用户的 HTML 是旧的他引用的 JS 文件依然存在页面只是功能旧一点不会直接崩掉。等服务器磁盘用到 80% 再清理最老的一批。6.2 几条我常用的排查命令排查缓存问题最快的办法是直接看响应头别猜。# 看 HTML 的缓存头 curl -sI https://your-domain.com/index.html \ | grep -i -E cache-control|etag|last-modified|expires|age|via|x-cache # 看静态资源的缓存头 curl -sI https://your-domain.com/assets/index.a3f91c2.js \ | grep -i -E cache-control|etag|age|x-cache # 看 version.json 是不是拿到了最新内容 curl -s https://your-domain.com/version.json第二条命令里的age和x-cache头特别有用。age表示这个响应在 CDN 缓存里躺了多久x-cache: HIT表示命中了边缘节点缓存。如果age是个很大的数说明 CDN 还在返回老内容这时候就要去刷新 CDN 了。浏览器侧DevTools 的 Network 面板有几个容易被忽略的细节Size列显示(memory cache)或(disk cache)就是命中本地缓存Disable cache勾选后只对当前 DevTools 打开期间有效别用它来判断线上行为右键请求选Copy as cURL可以直接复制到终端里复现。6.3 我在实际项目里踩过的几个坑第一个坑测试环境用localhost测试一切正常。原因很简单localhost经常被浏览器特殊对待缓存行为和生产环境不一样。我现在的规矩是所有缓存相关的验证必须在带域名的测试环境做最好再套一层 CDN。第二个坑构建工具的开发服务器和线上服务器行为不同。Vite 的 dev server 默认对 HTML 不缓存对资源加了 no-cache所以开发时永远看不到缓存问题。想看真实效果必须跑一次vite build vite preview或者直接部署到测试环境。第三个坑Cache-Control写了多个值。Nginx 的expires指令和add_header Cache-Control同时存在时可能会生成两个Cache-Control头浏览器取哪个不确定。我的做法是二选一要么只用expires要么只用add_header别混着写。第四个坑用?v1.0.0这种查询参数做版本控制。很多老项目这么干问题是有些 CDN 和代理在计算缓存键时会忽略查询参数导致你以为换了 URL实际上还是命中同一个缓存。带 hash 的文件名路径变化比查询参数可靠得多。第五个坑忘记处理favicon.ico。这个文件浏览器会自动请求有些浏览器会给它超长缓存。换了图标之后用户看不到变化会以为是你发版出了问题。我的做法是给它也加上内容指纹在 HTML 里用link relicon href/favicon.a1b2c3.ico显式指定。7. 白屏兜底和灰度发布里的版本一致性缓存策略做得再好也总会有一部分用户处在「新旧交界」的状态。这时候能不能优雅地兜住就看兜底机制做得够不够扎实。7.1 ChunkLoadError 的自动恢复最常见的崩法就是动态 import 失败。用户拿着旧 HTML点击一个按钮触发懒加载浏览器去请求chunk-abc123.js结果这个文件已经被新版本覆盖删除了返回 404 或者 HTML因为 Nginx 的try_files会兜到 index.html解析失败就抛ChunkLoadError。处理逻辑是捕获这个错误判断是不是第一次发生如果是就自动刷新一次页面。刷新之后用户拿到新 HTML问题自然解决。const RELOAD_KEY __chunk_reload_at__ function handleChunkError() { const last Number(sessionStorage.getItem(RELOAD_KEY) || 0) // 10 秒内只允许自动刷新一次防止死循环 if (Date.now() - last 10000) return sessionStorage.setItem(RELOAD_KEY, String(Date.now())) window.location.reload() } window.addEventListener(error, (event) { const msg event?.message || event?.error?.message || if (/Loading chunk [\d\w-] failed|ChunkLoadError|Loading CSS chunk/i.test(msg)) { handleChunkError() } }, true) window.addEventListener(unhandledrejection, (event) { const msg String(event?.reason?.message || event?.reason || ) if (/Loading chunk|ChunkLoadError|Failed to fetch dynamically imported module/i.test(msg)) { handleChunkError() } })这里的「10 秒防抖」很关键。如果服务器真的挂了或者 JS 文件被删了但 HTML 还是新的不加防护就会陷入「刷新—报错—再刷新」的死循环用户浏览器直接卡死。加了之后最多刷一次然后老老实实把错误抛出来至少页面还能显示。用 vue-router 或 react-router 的项目还可以在路由层面加一层router.onError((error) { if (/Loading chunk|dynamically imported module/i.test(error.message)) { handleChunkError() } })7.2 把版本号显示在页面上这个建议听起来很土但极其有用在页面的某个角落比如页脚或者设置弹窗里显示当前版本号和构建时间。用户报障的时候问一句「你页面右下角显示的是什么版本」你立刻就知道他拿的是老代码还是新代码省掉大量扯皮。footer classapp-footer span版本{{ buildVersion }}/span span构建时间{{ buildTime }}/span /footerbuildVersion通过构建时注入和version.json里的值保持一致。我还会在控制台打一行日志方便远程指导用户在 F12 里看一眼console.log( %c BUILD ${__BUILD_VERSION__} | ${__BUILD_TIME__} , background:#2b6cb0;color:#fff;padding:2px 6px;border-radius:3px )7.3 灰度发布时版本一致性比覆盖率更重要做灰度发布的时候最容易出的问题是新旧版本混用。比如你在 Nginx 里按 cookie 分流70% 流量走旧版本30% 走新版本。但如果 HTML 和静态资源的缓存策略没配好用户可能在旧版本页面上加载到新版本的 JS或者反过来出现各种玄学 bug。我的原则是灰度分流的边界必须在 HTML 这一层。同一个用户的 HTML 和它的静态资源必须来自同一个版本。实现方式有两种一是静态资源目录带版本前缀比如/v20260518/assets/...分流规则同时控制 HTML 和资源路径二是用不同的域名或子路径比如gray.example.com和www.example.com两边完全隔离。后者虽然有点粗暴但实现成本最低回滚也最干净——直接把入口切回去就行。我在几个核心业务上用的都是这套方案。另外提醒一句灰度期间千万别忘了version.json。如果灰度和正式环境共用同一个文件路径检测逻辑会出现误判灰度用户会一直被提示「有新版本」。解决办法是让版本检测的 URL 跟着环境走或者干脆在灰度环境关掉自动检测。7.4 一个可以放进 CI 的缓存自检脚本最后分享一个我在 CI 里加的小检查用来防止有人手滑改了缓存配置。思路是发版完成后用脚本请求线上几个关键 URL校验响应头是否符合预期不符合就报警。#!/usr/bin/env bash set -euo pipefail HOST${1:?用法: ./check-cache.sh https://your-domain.com} check_not_cached() { local url$1 local header header$(curl -sI $url | tr -d \r | grep -i ^cache-control || true) echo [$url] - $header if echo $header | grep -qiE max-age[1-9][0-9]; then echo !! 警告仍存在强缓存 return 1 fi } check_long_cached() { local url$1 local header header$(curl -sI $url | tr -d \r | grep -i ^cache-control || true) echo [$url] - $header if ! echo $header | grep -qi max-age31536000; then echo !! 警告带指纹资源没有长缓存 return 1 fi } check_not_cached $HOST/index.html check_not_cached $HOST/version.json ASSET$(curl -s $HOST/version.json /dev/null curl -s $HOST/index.html \ | grep -oE /assets/[^]\.js | head -n 1 || true) if [ -n $ASSET ]; then check_long_cached $HOST$ASSET fi echo 缓存策略检查完成这段脚本的逻辑是HTML 和version.json不允许出现非零的max-age从 HTML 里抽出一个 JS 地址它必须带一年强缓存。跑通之后以后再有人改配置改出问题CI 会先拦下来。8. 写在实际项目里的几点体会这套方案我在不同规模的项目里都用过从只有几个页面的官网到上百个路由的中台系统核心逻辑没变过只是细节配置有差异。真正决定成败的往往不是技术方案本身有多先进而是有没有把这些细节坚持下来静态资源一律走构建产物、HTML 一律不缓存、旧版本产物保留一段时间、端上有一个安静的版本检测、出错能自动恢复一次。有个反直觉的体会是缓存问题的成本主要不来自技术而来自沟通。用户说「你发的功能我看不到」你需要快速判断他是缓存问题还是真有 bug这个判断能力比写多少配置都值钱。所以我一直坚持在页面上显示版本号、在控制台打印构建信息、在群里同步每次发版的版本标识。这些看起来跟技术无关的小动作实际省下的排查时间是最多的。还有一个坑是团队协作层面的。缓存配置散落在 Nginx 配置、CDN 控制台、构建脚本、SW 代码四个地方任何一处被人改动都可能前功尽弃。我的做法是写一份简短的「缓存策略约定」放在仓库根目录把四处的规则都列清楚改之前必须同步更新这份文档。听着有点重但真的能避免很多低级问题。如果你现在正准备给项目做一次缓存治理我的建议是先别一次性改完。第一步只做「静态资源指纹化 HTML 不缓存」这两件事观察一周线上表现第二步再加上版本检测和提示 UI第三步处理 Service Worker 和微前端这些特殊情况。一次改太多出问题时你分不清是哪一处引起的。最后分享一个小技巧在本地验证缓存策略时用curl -I加上-H Cache-Control: no-cache头去模拟强制校验可以看到服务器返回 304 还是 200这比在浏览器里反复清缓存高效得多。另外 Chrome DevTools 的 Network 面板里把Size列调出来看到(disk cache)和(memory cache)的时候你就能很直观地感受到自己的缓存策略到底有没有生效。
返回列表