前端发版缓存全链路:入口 HTML 不缓存与哈希资源长缓存
发布时间:2026/9/29 4:52:53来源:尧图网络
1. 先弄明白浏览器到底把什么缓存了每次发版之后运营群里最常出现的三句话是我这还是老页面、按钮点不动了、刷新一下就好了。最后一个尤其扎心因为它说明问题不在代码而在缓存策略。前端发版后浏览器缓存问题这个事儿看着像是个运维话题实际上从构建配置、静态资源命名、服务端响应头到前端主动探测是一条完整的链路任何一环没对齐用户就会拿着旧index.html去请求一个已经不存在的 JS 文件然后白屏。我先把结论摆出来省得你看到后面才发现方向错了入口 HTML 必须每次回源校验带哈希的静态资源可以放心长期缓存两者配合才能做到发版后用户及时拉取最新版本代码。这套思路不依赖任何特定框架webpack、Vite、Rspack、Rsbuild 都能落地区别只在配置写法。适合读这篇的人大概有三类一是刚接手前端部署、被缓存问题反复折磨的同学二是团队里负责构建配置和 CI 流水线的人三是做 App 内嵌 H5、小程序 webview、多端 WebView 的同学这类场景的缓存比 PC 浏览器顽固得多。如果你只是想知道加个?v1行不行答案在第 2 章我建议先看完第 1 章的机制再决定。1.1 强缓存和协商缓存各自负责什么浏览器缓存分两层名字听着玄乎其实很好理解。强缓存是我本地有而且没过期我直接用根本不问服务器。它的判定依据是响应头里的Cache-Control的max-age或者老的Expires。命中强缓存时你在 DevTools 的 Network 面板看到的 Size 列会写(from disk cache)或者(from memory cache)状态码是 200但请求压根没发出去。协商缓存是我本地有但过期了我带着凭证去问服务器一句这东西还能用吗。凭证有两个体系Last-Modified/If-Modified-Since和ETag/If-None-Match。服务器说没变返回 304不传 body省流量服务器说变了返回 200 加新内容。304 的响应虽然也要走一次网络但传输量极小。这两层的组合决定了行为。举个具体例子我给index.html配了Cache-Control: no-cache那它每次都会走协商缓存服务器可以通过 ETag 快速判断这个 HTML 没变返回 304依然很快一旦变了立刻返回新的。而no-store更狠完全不落盘连协商都不走流量浪费一点但绝对新鲜。日常我更推荐no-cache因为 304 的代价很低。这里有个容易搞混的点no-cache不是不缓存而是缓存但每次必须校验。真正不缓存的是no-store。我第一次配 Nginx 的时候就栽在这上面把no-cache理解成不缓存结果给静态资源也配上了白白浪费了大量 304 请求。1.2 为什么发版后用户还是老页面回到那个典型故障链。假设你的项目结构是这样dist/ index.html js/app.3f2a1b.js js/vendor.8c9d0e.js css/index.1a2b3c.css用户第一次访问index.html被浏览器按默认策略缓存了很多静态服务器默认给 HTML 也是带 max-age 的或者被 CDN 缓存了。他同时下载了app.3f2a1b.js并缓存。你发版了app的内容变了文件名变成app.7e6f5d.js旧的app.3f2a1b.js从服务器上删掉了。用户第二天再来浏览器直接用缓存的index.html里面写的还是script src/js/app.3f2a1b.js。浏览器去请求这个文件服务器返回 404。页面白屏控制台一堆Failed to load resource。这就是绝大多数发版后白屏的真正原因不是代码写错了是入口文件和带哈希资源用了同一套缓存策略。理解了这一层解决方案就非常清晰了把入口 HTML 的缓存生命周期压到最短把带内容哈希的静态资源的生命周期拉到最长。1.3 缓存问题带来的实际影响分档我按严重程度把这类问题分三档方便你判断自己遇到的是哪种。档位表现根因修复优先级轻微样式/文案是旧的刷新后正常入口 HTML 缓存了高中等页面白屏控制台 404入口缓存 旧 hash 文件被清理极高严重用户提交表单后接口报错或用了新旧混合的代码多 chunk 版本错配极高第三档最隐蔽。比如你的主包更新了但某个异步 chunk 还是旧的新代码调用老接口的参数名后端不认用户就会看到莫名其妙的报错。这种问题在小流量下很难发现一旦全量就炸。我在一个后台项目里遇到过原因是 CDN 只刷新了部分文件index.html更新了但某个 vendor chunk 因为文件名没变还在走老缓存。后面第 4 章会讲怎么从 CDN 层面堵住这个口子。2. 方案选型的取舍逻辑方案这块我不打算给你列一堆方案 A/B/C 你选一个因为实践里基本只有一条主线是稳的剩下的都是补丁。先说主线再说为什么其他常见做法不够好。2.1 文件名哈希加入口不缓存是基本盘主线的核心就两条带内容哈希的静态资源配Cache-Control: public, max-age31536000, immutable一年有效期让浏览器和 CDN 都放心大胆地缓存。文件内容变了文件名就变天然不会冲突。入口 HTML 配Cache-Control: no-cache或者max-age0, must-revalidate每次回源协商保证用户拿到的永远是当前版本的资源清单。为什么敢给静态资源配一年因为文件名里的哈希就是内容的指纹。内容不变哈希不变缓存永远是有效的内容变了哈希变了就是一个全新的 URL老缓存留着也不影响。这是 HTTP 缓存设计里最优雅的一点用命名解决失效问题而不是用时间。immutable这个指令值得单独说。它的作用是告诉浏览器在这个 max-age 期间内这个资源绝对不会变用户点刷新按钮的时候不要去发协商请求。没有它用户按 F5 会触发一轮 304 校验有了它刷新直接命中本地缓存秒开。对于发布后不再修改的哈希文件这个指令非常合适。2.2 为什么 query 参数做版本号不够可靠老项目里常见这种写法script src/js/app.js?v20240501。看起来也解决了问题但坑不少。第一部分 CDN 和中间层代理在计算缓存键时会忽略 query string。也就是说app.js?v1和app.js?v2在这个 CDN 眼里是同一个资源你更新了版本号用户拿到的还是老文件。国内几家 CDN 的默认缓存键策略不一样有的包含全部 query有的只包含白名单参数排查起来非常费劲。第二即使 CDN 认了 query浏览器本地缓存也认但你的文件名没变意味着同一个 URL 路径上承载了不同内容。这在做灰度、做回滚的时候会出问题回滚到上一版query 变回旧值用户本地缓存的旧版又被命中了你根本不知道他拿到的到底是哪一版。第三某些 WebView 和微信内置浏览器对带 query 的静态资源缓存行为更保守反而更容易出现每次都重新下载或者怎么都不更新的极端情况。哈希文件名没有这些问题因为它让每一版资源都有独立的、不可变的 URL。回滚的时候旧文件名重新出现用户本地如果还有缓存反而瞬间生效这是好事。2.3 几种常见策略的横向对比策略静态资源缓存效率发版及时性回滚友好度主要风险全部不缓存差好一般服务器压力大首屏慢全部长缓存好差差白屏、版本错配query 版本号中中差CDN 忽略 query 导致失效哈希文件名 HTML 不缓存好好好需要构建和部署配合目录级版本/v1.2.3/好好极好每次发版占一份磁盘空间最后那个目录级版本方案在很多团队里是作为补充存在的。做法是每次发版把产物上传到一个独立目录比如/release/1.2.3/然后让网关或者 Nginx 把/的请求 rewrite 到当前版本目录。好处是回滚只需要改一行 rewrite 规则产物不用动坏处是磁盘占用随版本线性增长需要配合定期清理策略。如果你的团队已经有成熟的对象存储和 CDN这个方案其实非常舒服。3. 构建侧怎么配才不会有漏网之鱼服务端配置再正确如果构建产物文件名里没有哈希一切白搭。这一章讲构建配置我会把容易忽略的几个点单独拎出来。3.1 webpack 的 output 与 runtimeChunkwebpack 5 的基础配置大概是这样module.exports { output: { filename: js/[name].[contenthash:8].js, chunkFilename: js/[name].[contenthash:8].chunk.js, assetModuleFilename: assets/[name].[contenthash:8][ext], clean: true }, optimization: { moduleIds: deterministic, runtimeChunk: single, splitChunks: { cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: vendors, chunks: all } } } } }这里每个配置都有它的理由我一个一个说。contenthash而不是chunkhash。chunkhash是基于 chunk 计算的一个 chunk 里只要有一个模块变了整个 hash 就变。contenthash是基于文件内容算的粒度更细。实践中混用会导致大量的无辜文件跟着改名字缓存全部失效。moduleIds: deterministic这个太关键了。默认情况下webpack 给模块分配的数字 ID 是按引入顺序来的你新加一个文件后面所有模块的 ID 都可能平移导致所有 chunk 的哈希全变。用deterministic之后模块 ID 基于相对路径生成新增文件不会影响已有模块。我第一次没配这个发版后所有文件名都变了用户等于全量重新下载缓存形同虚设。runtimeChunk: single的作用是把 webpack 的运行时代码单独抽出来。运行时代码里记录了哪个模块 ID 对应哪个文件的映射表如果不抽出来它会被打进主 chunk主 chunk 一更新映射表就变所有懒加载 chunk 的引用关系也跟着乱。抽出来之后运行时代码文件小且独立其他 chunk 的哈希更加稳定。3.2 Vite 项目的配置差异Vite 在 production 模式下默认就给静态资源加哈希所以大部分时候你不用改。但有两个地方值得注意。第一build.rollupOptions.output里可以显式指定命名规则方便和 CDN 的回源规则对齐export default { build: { rollupOptions: { output: { entryFileNames: js/[name].[hash:8].js, chunkFileNames: js/[name].[hash:8].js, assetFileNames: assets/[name].[hash:8][extname] } } } }第二Vite 默认会把index.html也当作构建产物输出到 dist 根目录它是入口身份。这一点很重要因为很多团队在配 Nginx 的时候是按location ~* \.(html)$来匹配 HTML 的结果发现根路径/请求返回的其实是/index.html规则没命中。稳妥做法是单独给location /index.html和location /都配上不缓存别用后缀去猜。还有一个坑Vite 会把小于 4KB 的资源转成 base64 内联进 JS 或者 CSS。如果你有大量小图标它们的内容变化会让宿主文件的哈希一起变。这本身不违反原则但会导致缓存粒度变粗。如果你对缓存命中率有极致要求可以把assetsInlineLimit调小甚至设为 0。3.3 静态资源和 HTML 要分目录管理有个细节很多人不注意不同资源放在同一个目录下会让 Nginx 的 location 匹配变得很难写。我建议的产物结构是这样dist/ index.html - 不缓存 version.json - 不缓存用于前端探测 js/ css/ assets/把带哈希的资源都收进js、css、assets子目录index.html和version.json放在根。这样 Nginx 的规则可以写成根目录下的具体文件不缓存子目录下的资源长缓存逻辑非常干净不用靠正则去猜后缀也不会误伤。3.4 拆包策略会直接影响缓存命中率拆包不只是为了首屏体积它和缓存命中率强相关。举个反例如果你把element-plus、echarts、业务代码全打进一个app.js那每次改一行业务代码整个app.js的哈希都变用户要重新下载一个几 MB 的文件。合理的拆法是把几乎不变和频繁变的分开第三方库React、Vue、echarts 等按体积和使用频率拆成vendor那么一两个大包这些包可能几个月才变一次用户长时间命中缓存。业务代码按路由懒加载每个路由一个 chunk改动只影响自己的 chunk。公共业务组件单独成一个commonchunk。我在一个中台项目里做过对比优化前后一次常规发版用户的资源下载量从大约 3.2MB 降到 180KB 左右差距就是这么来的。这不是构建技巧的炫技是实实在在影响用户等待时间的。4. 服务端响应头是真正的最后一道闸门构建产物再规范也得靠响应头告诉浏览器该怎么缓存。这一章给完整的 Nginx 配置并逐行解释为什么这么写。4.1 完整的 Nginx 配置与逐行说明server { listen 80; server_name example.com; root /usr/share/nginx/html; index index.html; # 入口 HTML 和版本文件每次协商校验 location /index.html { add_header Cache-Control no-cache, no-store, must-revalidate always; add_header Pragma no-cache always; add_header Expires 0 always; try_files $uri 404; } location /version.json { add_header Cache-Control no-store always; } # 带哈希的静态资源一年长缓存 location ~* ^/(js|css|assets)/ { expires 1y; add_header Cache-Control public, max-age31536000, immutable always; access_log off; } # 前端路由兜底 location / { try_files $uri $uri/ /index.html; add_header Cache-Control no-cache always; } }几个关键点。location /index.html里的等号表示精确匹配优先级最高不会被后面的正则或者前缀规则抢走。try_files $uri 404的意思是文件不存在就返回 404不要 fallback因为对于入口文件来说找不到就是部署出了问题让它明确报错比返回一个奇怪的页面要好排查。always参数是加在add_header后面的。默认情况下add_header只在响应码为 200、204、301、302、304 这些正常状态码时才生效。加了always之后404、500 也会带上这些头避免错误响应被缓存这种更麻烦的情况。我踩过一次坑某个资源 404 了因为没有always没带 Cache-Control结果被 CDN 按默认策略缓存了半天修好之后用户还是一直拿 404只能手动刷 CDN。expires 1y和add_header Cache-Control同时写会不会冲突其实expires指令内部就是在生成Expires和Cache-Control: max-age当你在同一 location 里显式写了add_header Cache-Control两者都会出现在响应里。现代浏览器优先看Cache-Control所以行为是正确的。但为了清晰我建议只保留add_header一种把expires去掉避免出现两个 max-age 让人误判。关于try_files $uri $uri/ /index.html这是给 history 路由模式用的。如果你的应用用的是 hash 路由可以省掉这一段。但要注意这个 fallback 会让所有不存在的路径都返回index.html和 200 状态码搜索引擎和监控系统可能会误判。如果在意这个可以在网关层做区分把已知的 API 前缀排除掉。4.2 CDN 层的规则必须和源站对齐Nginx 配好了不代表万事大吉因为用户请求的第一个对象往往是 CDN 节点。CDN 的缓存规则如果和源站不一致会出现源站说不缓存CDN 说缓存 7 天的尴尬局面。要点有三条。第一在 CDN 控制台把index.html和version.json加入不缓存名单或者把缓存时间设为 0。有些 CDN 支持遵循源站模式勾选之后它会按源站的Cache-Control来处理这是最省心的做法。如果你的 CDN 不支持就手动加规则。第二静态资源目录的缓存时间和源站保持一致。如果你源站写的是 1 年CDN 只给 7 天那每 7 天就会回源一次虽然不影响正确性但增加了回源压力。第三发版流程里必须包含刷新 CDN 的步骤且只刷 HTML 和 version.json。哈希资源的文件名变了新的 URL 天然没有缓存不需要刷。全量刷新一次很慢而且会打掉所有用户的本地缓存得不偿失。我见过有团队每次发版全量刷 CDN结果高峰期回源流量暴涨页面变慢这属于用力过猛。4.3 多环境和灰度场景下的目录隔离如果你的发版流程里有灰度环节光靠上面这套还不够。灰度期间一部分用户要走新版本一部分走老版本如果都用同一个index.html就没办法区分。主流做法是目录级隔离加网关路由。每次发版产物上传到/release/{version}/网关根据用户标识把/的请求 rewrite 到对应版本目录。这样不同版本的文件物理隔离互不干扰。回滚只需要把 rewrite 目标改回上一个版本目录秒级生效。灰度和正式环境用的是同一套产物不存在灰度上测过的和正式上的不一样这种问题。代价是磁盘占用。我的做法是保留最近 5 个版本更早的定时清理。配合对象存储使用时可以只保留索引实际文件走对象存储的版本化能力成本更低。5. 让前端自己知道有新版本了做到前面这些用户刷新页面拿到的一定是最新版。但用户不一定刷新。对于后台管理系统、长时间挂着不关的页面我们可以主动探测并提示用户。5.1 构建时产出一个版本标记文件在构建脚本的最后追加一步生成version.json// scripts/gen-version.js const fs require(fs); const path require(path); const pkg require(../package.json); const version { version: pkg.version, buildTime: new Date().toISOString(), commit: process.env.GIT_COMMIT || unknown }; fs.writeFileSync( path.resolve(__dirname, ../dist/version.json), JSON.stringify(version, null, 2) ); console.log(version.json 已生成:, version.version);在package.json里把它串到构建流程里{ scripts: { build: vite build node scripts/gen-version.js } }注意顺序一定要在构建完成之后再写否则dist目录会被清空掉。用 webpack 的话也可以用write-file-webpack-plugin或者直接在 CI 脚本里加一步。这个文件的内容一定要每次发版都不一样所以带上构建时间或者 commit hash 是最稳妥的。只写package.json的版本号不够因为经常会有版本号没改就发版的情况。5.2 前端轮询探测与提示刷新的实现在应用入口处加一段轻量的探测逻辑let currentVersion null; async function fetchVersion() { const res await fetch(/version.json?t${Date.now()}, { cache: no-store }); if (!res.ok) throw new Error(fetch version failed); return res.json(); } async function checkUpdate() { try { const data await fetchVersion(); if (currentVersion null) { currentVersion data.version; return; } if (data.version ! currentVersion) { showUpdateTip(); } } catch (e) { // 探测失败静默处理不要打扰用户 } } // 定时探测 setInterval(checkUpdate, 5 * 60 * 1000); // 页面重新可见时也探测一次 document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { checkUpdate(); } }); checkUpdate();几个细节值得说。?t${Date.now()}和cache: no-store是双保险。前者绕过一些中间层基于 URL 的缓存后者让浏览器 fetch API 明确不使用缓存。生产环境里两者都加比较稳。轮询间隔我一般设 5 分钟。太短会给服务器带来不必要的请求太长用户会长时间用老版本。对于后台系统5 分钟是个比较舒服的平衡点。配合visibilitychange事件用户切回来的时候立刻探测一次体验更好。探测失败要静默处理。网络抖动、接口临时不可用都是正常的不能弹一堆错误提示。这段逻辑的目标是锦上添花不是核心功能。5.3 Service Worker 和离线包的额外一层坑如果你用了 PWA 或者 Service Worker事情会复杂一层。SW 有自己的缓存空间和更新机制它会拦截 fetch 事件浏览器返回的内容可能是 SW 从 Cache Storage 里给的跟 HTTP 缓存规则没关系了。常见的两个问题。第一新的 SW 装好了但不生效。默认情况下新 SW 处于 waiting 状态要等所有标签页关闭才会激活。解决办法是在 SW 里调用self.skipWaiting()并在主线程里用registration.waiting.postMessage({ type: SKIP_WAITING })触发。但要注意强制跳过等待有风险如果新版本有 breaking change正在使用旧页面的用户会被突然切到新逻辑。我的建议是配合前面那个提示逻辑让用户确认后再刷新。第二SW 本身被缓存。SW 文件通常放在根路径比如/sw.js。浏览器在检查 SW 更新时对缓存比较敏感如果 HTTP 层给它配了长缓存新版本可能很久都不被发现。稳妥做法是给 SW 文件也配上Cache-Control: no-cache让浏览器每次校验。离线包比如 Hybrid App 里的预置包是另一回事它走的是 App 自己的下载和解压逻辑通常由客户端同学控制前端能做的有限。这类场景下最重要的是建立版本号协商机制让服务端能告诉客户端你现在这个版本的离线包太老了需要更新而不是依赖客户端自己的判断。6. 排查实录几个我真实踩过的坑前面讲的都是正确姿势但真实项目里出问题的时候往往不是配置写错了而是某个环节没对齐。这一章我整理几个印象深刻的案例和一张速查表。6.1 常见问题速查表现象可能原因排查动作发版后白屏控制台 404入口 HTML 被缓存引用了旧 hash 文件curl -I看 index.html 响应头用 CtrlF5 后正常普通刷新不行入口 HTML 缓存时间过长检查是否配了 no-cache部分用户正常部分用户是旧版CDN 节点缓存不一致检查 CDN 刷新记录和缓存键策略文件名没变但内容变了构建没配 contenthash 或配错检查 output.filename每次发版所有文件都改名moduleIds 不是 deterministic检查 webpack optimization 配置只有某个异步 chunk 报错chunk 哈希不稳定或 CDN 只刷了部分检查 runtimeChunk 拆分配置微信里怎么都不更新微信内置浏览器缓存顽固检查 X5 内核缓存考虑 URL 目录版本化接口数据是旧的接口被 GET 缓存检查 API 响应头加 no-store这张表我建议直接贴到团队 wiki 里出问题的时候按行对照能省很多时间。6.2 三个让我印象最深的真实案例案例一CDN 忽略 query 参数导致的薛定谔的更新。有个项目用的是main.js?v时间戳的写法测试环境一切正常生产环境部分用户永远拿不到新代码。折腾了半天才定位到 CDN 的缓存键默认只取路径不取 query。改成哈希文件名之后彻底解决。这个教训让我彻底放弃了 query 版本号这条路。案例二moduleIds没配导致的缓存全量失效。项目上线初期前端每次发版用户都要重新下载全部资源首屏时间从 1.2 秒涨到 3 秒多。一开始以为是资源没压缩后来对比两次构建的产物发现只要新增任意一个文件几乎所有 chunk 的哈希都变了。加上moduleIds: deterministic之后常规发版的变更文件从 20 多个降到 3 个。案例三404 被缓存。有个资源文件因为构建配置问题没被打包进去浏览器请求返回 404。当时 Nginx 没加always404 响应没带 Cache-Control被 CDN 按默认策略缓存了几个小时。等我们发现并修好的时候已经有一部分用户的浏览器和 CDN 都存着 404 了只能等缓存过期。从那以后我给所有add_header都加上always。这三个案例的共同点是问题都不在配置写错了而在某个环节的行为和预期不一致。所以排查这类问题的核心方法是沿着构建产物文件名 → 服务器响应头 → CDN 缓存规则 → 浏览器缓存状态这条链逐段验证别猜。6.3 验证缓存是否生效的三个具体方法配置完不是结束得验证。我常用的三个方法。方法一curl 看响应头。curl -I https://example.com/index.html curl -I https://example.com/js/app.3f2a1b.js第一个应该看到Cache-Control: no-cache第二个应该看到max-age31536000, immutable。如果 CDN 挡在前面返回的可能是 CDN 节点的响应头可以在 URL 后面加随机参数绕过 CDN 直接打源站或者在 curl 里指定 Host 头。方法二浏览器 DevTools 看 Size 列。打开 Network 面板普通刷新页面不要用 CtrlF5看资源列表的 Size 列。显示(disk cache)或(memory cache)说明命中了本地缓存显示具体字节数说明走了网络164 字节左右通常是 304 空响应。方法三模拟旧缓存。想在本地复现用户拿着旧 HTML的情况可以在 DevTools 的 Network 面板勾选Disable cache的反面操作——先正常加载一次让缓存生效然后在 Sources 面板里手动改一下index.html的内容模拟旧版本再触发一次资源加载看是否 404。这个方法比较土但很直观。提示验证的时候一定要用普通刷新和硬刷新各测一遍。这两种行为在不同缓存头下的表现差别很大只测一种很容易漏掉问题。6.4 部署流程里应该固化下来的检查项最后说一个很多团队忽略的点把缓存检查固化到发布流程里。我现在的做法是在 CI 的最后一步加一个自动化检查用脚本请求线上地址验证响应头是否符合预期不符合就直接让流水线失败。#!/bin/bash HTML_HEADER$(curl -sI https://example.com/index.html | grep -i cache-control) if [[ $HTML_HEADER ! *no-cache* ]]; then echo index.html 缓存头异常: $HTML_HEADER exit 1 fi echo 缓存头检查通过这个脚本很简陋但它的价值在于把人记得检查变成系统强制检查。缓存问题最麻烦的地方不是难解决而是容易在忙碌中被忘记验证等用户反馈的时候已经过去好几个小时了。自动检查加上前面那套探测和提示逻辑基本可以覆盖从构建、部署、CDN 到用户端的完整链路。这套东西搭好之后前端发版后的缓存问题在我们团队基本就不再是每次发版都要群里喊一声让用户强制刷新的常态了。写到这里我个人在实际落地这套方案时的体会是缓存策略本质是一次契约的重申。你和浏览器之间约定入口文件我会每次校验静态资源你可以放心缓存这个约定写在响应头里也写在文件命名里。契约清晰了浏览器就不会自作主张用户也不会拿着半新半旧的代码在你的页面上乱点。真正难的部分从来不是技术方案而是让构建配置、服务端配置、CDN 配置和部署流程这四个环节对同一个约定保持一致只要有一个环节理解不同问题就会以最意外的形式冒出来。
网站建设高端定制企业官网