前端发布后偶发白屏?旧 HTML + 新静态资源如何制造“版本错位”:从 Vite/Webpack Hash、Nginx、CDN 到 Service Worker
前端“发布后只有部分用户白屏、刷新又偶尔恢复”的根因,很多时候不是 Vue、React 或业务代码本身,而是同一次页面运行中混入了不同发布版本的入口 HTML、JavaScript Runtime 和静态 Chunk。Vite 官方甚至单独把这一类问题称为“版本偏差”:旧 HTML 或旧页面运行时仍引用上一版本的 Chunk,而新部署已经删除了这些文件,于是动态 import() 最终请求一个不存在的 Hash URL。
解决它不能只靠一句“清浏览器缓存”,而应该把发布模型改成:HTML 是可变指针,必须及时重验证;Hash 静态资源是不可变对象,允许长期缓存且旧版本暂时不能删除;发布必须先资源、后入口;SPA fallback 不能吞掉静态资源 404;CDN 与 Service Worker 也必须遵守同一套版本一致性规则。
问题场景与典型复现
先看一个非常典型的生产事故。
假设系统使用 Vue + Vite,第一次发布版本 v1:
dist/
├── index.html
└── assets/
├── index-a81f3.js
├── index-b117c.css
├── UserPage-7f31a.js
└── ReportPage-d91e2.js
此时 index.html 大致引用:
<!doctype html>
<html>
<head>
<script type="module" crossorigin src="/assets/index-a81f3.js"></script>
<link rel="stylesheet" href="/assets/index-b117c.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
其中 UserPage-7f31a.js、ReportPage-d91e2.js 可能来自路由懒加载:
const UserPage = () => import('./views/UserPage.vue')
const ReportPage = () => import('./views/ReportPage.vue')
Vite 会把参与构建的资源纳入构建图,并为生产资源生成带散列的文件名;Webpack 的 [contenthash] 也是同一思路,即资源内容发生变化时,其输出文件名随内容 Hash 改变。
现在发布 v2:
dist/
├── index.html
└── assets/
├── index-c72e8.js
├── index-f884a.css
├── UserPage-19bd2.js
└── ReportPage-50ea6.js
如果部署脚本是:
rm -rf /var/www/app/*
cp -r dist/* /var/www/app/
那么 v1 的这些文件:
/assets/index-a81f3.js
/assets/UserPage-7f31a.js
已经从服务器消失。
问题来了。
第一种故障:旧 HTML + 新服务器。
某个用户浏览器、公司代理或者 CDN 仍然拿着旧的 index.html:
<script type="module" src="/assets/index-a81f3.js"></script>
浏览器随后发起:
GET /assets/index-a81f3.js HTTP/1.1
Host: app.example.com
源站已经只有 v2:
HTTP/1.1 404 Not Found
Content-Type: text/html
于是应用连入口 JS 都没加载成功,最典型的表现就是:
页面打开后一片空白
Vite 官方排错文档对这个过程描述得非常直接:用户缓存旧应用,新部署因代码变化生成不同 Chunk 名称,缓存的 HTML 随后去请求已经不存在的 Chunk。
第二种故障其实更隐蔽:旧运行时 + 新服务器。
用户上午 9 点打开系统,当时 v1 正常:
index.html v1
↓
index-a81f3.js v1
主 JS 已经进入浏览器内存。
中午 12 点系统发布 v2,旧 Chunk 被删除。
下午 3 点,用户一直没刷新页面,这时第一次点击“报表管理”。旧的 v1 Runtime 才执行:
import('./views/ReportPage.vue')
它请求的仍然是旧版本构建时记录的:
GET /assets/ReportPage-d91e2.js
服务器回答:
HTTP/2 404
控制台可能出现:
TypeError: Failed to fetch dynamically imported module:
https://app.example.com/assets/ReportPage-d91e2.js
Webpack 项目则常见:
ChunkLoadError: Loading chunk 847 failed.
Webpack 官方明确说明,动态 import() 或 code splitting 在运行期无法加载 Chunk 时可能产生 ChunkLoadError;Vite 官方也专门记录了新部署删除旧资源后,旧页面运行时再动态导入旧 Chunk 的情况。
这也是为什么线上经常出现一种很诡异的反馈:
“首页没问题,一点某个菜单就白了。”
因为这个路由对应的 Chunk 直到点击菜单才第一次请求。
更关键的是:
即使
index.html已经正确配置no-cache,它也解决不了已经打开几个小时的旧 Tab。
因为这个 Tab 根本没有重新请求 HTML,它的旧 Runtime 已经在内存里运行。Vite 因此除了建议 HTML no-cache,还明确建议暂时保留旧 Chunk,并给动态导入增加优雅回退。
还有第三类问题:发布过程本身不是原子的。
假设部署顺序不正确:
10:00:00 上传新 index.html
10:00:01 上传 index-c72e8.js
10:00:02 上传 vendor-e391a.js
10:00:05 上传 UserPage-19bd2.js
恰好有用户在 10:00:00.5 访问。
他拿到的是:
index.html v2
但它引用的:
/assets/index-c72e8.js
此刻可能还没上传完成。
这时发生的就不是:
旧 HTML + 新 assets
而是:
新 HTML + 尚未完整就绪的新 assets
本质仍然是入口文档和资源集合不是同一个一致版本。这是从 Vite 所描述的版本偏差机制进一步推导出的发布一致性问题。
整个事故链可以用一张时序图表示:
sequenceDiagram
participant U as 用户页面
participant BC as 浏览器 HTTP 缓存
participant SW as Service Worker
participant CDN as CDN
participant O as Nginx / Origin
Note over O: v1 已发布<br/>index.html → index-a81f3.js<br/>动态路由 → ReportPage-d91e2.js
U->>BC: GET /index.html
BC-->>U: index.html v1
U->>SW: 请求 /assets/index-a81f3.js
SW->>CDN: 未命中则继续请求
CDN->>O: GET index-a81f3.js
O-->>CDN: 200 JS v1
CDN-->>SW: 200 JS v1
SW-->>U: 页面运行 v1
Note over O: 发布 v2<br/>index.html → index-c72e8.js<br/>旧 v1 assets 被删除
Note over U: 用户旧 Tab 一直没有刷新
U->>SW: import("/assets/ReportPage-d91e2.js")
SW->>CDN: GET 旧 Chunk
CDN->>O: 缓存未命中 / 回源
O-->>CDN: 404 Not Found
CDN-->>SW: 404
SW-->>U: 动态 import 失败
Note over U: Failed to fetch dynamically imported module<br/>或 ChunkLoadError<br/>页面/路由白屏
这里最值得记住的一句话是:
Hash 解决的是“同一个 URL 如何避免缓存旧内容”,但它没有自动解决“旧页面是否还能访问旧 URL”。
这两个问题经常被混为一谈。
核心原理解析
Hash 文件实际上建立了一份“版本依赖图”。
Webpack 官方推荐 [contenthash],它根据资源内容生成 Hash;内容变化,文件名变化,例如:
main.7e2c49a6.js
↓
main.205199ab.js
Vite 的生产资源同样会形成带 Hash 的输出路径。这样做的巨大好处是:浏览器看到新 URL,就天然知道这是一个新资源,不需要把原来的 app.js 猜测成“到底是不是旧版本”。
因此理想模型是:
index.html
│
├── /assets/index-A.js
├── /assets/vendor-B.js
└── /assets/style-C.css
下一次发布:
index.html
│
├── /assets/index-D.js
├── /assets/vendor-B.js
└── /assets/style-E.css
没有变化的资源可以继续复用,变化的资源获得新地址。
所以,从缓存策略上看,HTML 和 Hash Assets 天生就应该是两种完全不同的资源。
HTML 更像:
“当前版本的指针。”
Hash JS/CSS 更像:
“一旦发布后就不应该再改变的不可变对象。”
MDN 对这种模型给出的典型策略正是:带版本号或 Hash 的子资源使用很长的 max-age 和 immutable;主 HTML 因为 URL 本身通常不能通过 Hash 更新,适合使用 no-cache,并配合 ETag 或 Last-Modified 做条件请求。
正确响应大致应该长这样:
HTTP/2 200
Content-Type: text/html
Cache-Control: no-cache
ETag: "index-v20260827"
Last-Modified: Thu, 27 Aug 2026 02:30:00 GMT
而 Hash 资源:
HTTP/2 200
Content-Type: text/javascript
Cache-Control: public, max-age=31536000, immutable
这里必须纠正一个极其常见的误解。
no-cache 并不是“不缓存”。
MDN 的定义是:响应仍然可以被存储,但在再次使用缓存副本之前必须重新验证;真正禁止存储的是:
Cache-Control: no-store
因此对于普通非个性化 SPA 的 index.html,通常更适合:
Cache-Control: no-cache
而不是为了“保险”无脑:
Cache-Control: no-store
这样浏览器仍可以发送:
If-None-Match: "index-v20260827"
如果内容没变化,服务端只需要返回:
HTTP/2 304 Not Modified
无需重新传输 HTML Body。MDN 对主 HTML 的推荐正是 no-cache 配合 ETag / Last-Modified;304 则用于条件 GET/HEAD 表示已有缓存副本仍然有效。
Expires 又是什么?
Expires 使用一个绝对日期表示资源什么时候过期,例如:
Expires: Fri, 27 Aug 2027 02:30:00 GMT
而:
Cache-Control: max-age=31536000
表示从当前响应开始可以新鲜一年。
现代项目通常以 Cache-Control 为主。如果响应中已经有 max-age 或 s-maxage,MDN 明确指出 Expires 会被忽略。
所以没必要在一个现代 SPA 中堆出:
Expires: ...
Cache-Control: ...
Pragma: ...
然后自己都不知道最终哪一条生效。
ETag 和 Last-Modified 也不是“缓存时间”。
它们解决的是:
“我手上的缓存副本和服务器当前版本是不是同一个?”
例如:
ETag: "abc123"
浏览器下一次请求:
If-None-Match: "abc123"
未变化:
304 Not Modified
变化:
200 OK
ETag: "def456"
MDN 将 ETag 定义为特定资源版本的标识符;Last-Modified 和 ETag 则都可以配合 HTML 的条件请求使用。
换句话说:
Cache-Control
↓
决定什么时候可以直接复用、什么时候需要验证
ETag / Last-Modified
↓
决定验证之后能不能继续复用原来的 Body
这两组概念不要混在一起。
CDN 会让缓存问题多一层。
真实生产链路通常不是:
Browser → Nginx
而是:
Browser
↓
Browser Cache
↓
CDN Edge
↓
Nginx / Object Storage
于是你在服务器上执行:
curl http://127.0.0.1/index.html
看到:
Cache-Control: no-cache
并不等于公网用户实际获得的就是相同策略。
Cloudflare 官方说明,Cache Rules 可以增强或覆盖源站 Cache-Control;Edge Cache TTL 和 Browser Cache TTL 也可能影响实际缓存行为。Cloudflare 还特别指出,清除 CDN Edge 缓存不会清除访问者浏览器里已经缓存的资源。
所以:
“我刚刚 Purge CDN 了,为什么客户电脑还不行?”
完全可能发生。
反过来,也有另一种情况:
浏览器每次都重新验证 index.html
↓
CDN 却错误地长期持有旧 index.html
↓
用户仍然拿到旧入口
这意味着 index.html 的 Edge 策略和 Browser 策略必须一起检查,而不能只盯 Nginx。Cloudflare 官方明确说明 Edge Cache TTL 规则可以覆盖源站的一部分缓存控制;这是诊断“源站明明 no-cache,公网还是旧 HTML”时必须重点检查的地方。
对于经常更新的静态文件,CloudFront 官方则明确推荐优先使用版本化文件名而不是反复做 Invalidation,因为版本化 URL 能处理用户本地缓存、代理缓存,同时更方便发布和回滚。
这正是 Hash Assets 的意义。
动态 import() 为什么比普通 JS 更容易中招?
普通入口:
<script type="module" src="/assets/index-a81f3.js"></script>
页面打开时立即请求。
而:
const Settings = () => import('./Settings.vue')
可能几个小时后才请求。
Webpack 官方说明,动态 import() 会形成按需异步 Chunk;运行时 Chunk 无法访问或无法正确执行时,就可能报 ChunkLoadError。官方同时提醒,这种错误不一定只有版本问题,也要检查网络可达性和 publicPath。
因此实际排查时:
ChunkLoadError
不能直接等价成:
“一定是浏览器缓存”
它还可能来自:
CDN 节点异常
publicPath/base 配错
静态服务器路径错
网络失败
浏览器扩展拦截
静态文件确实没上传
版本错位
Vite 官方同样列出了版本偏差、网络状况、浏览器扩展等多个可能原因。
真正具有高度指向性的证据是:
旧 Hash Chunk URL
+
404 / 403 / 错误 MIME
+
新版本服务器上确实不存在这个 Hash
这时才可以把矛头指向版本发布。
SPA fallback 又是怎么把 404 变得更难查的?
Vue Router / React Router history 模式常见:
location / {
try_files $uri $uri/ /index.html;
}
这本来是为了让:
/user/profile
/report/list
这种前端路由在直接刷新时返回 index.html。
但问题是,如果所有请求都进入这里,那么:
/assets/ReportPage-d91e2.js
已经不存在时,try_files 继续尝试最终 URI:
/index.html
Nginx 官方文档说明,try_files 会按顺序检查文件,找不到时会转向最后指定的 URI;这也是 SPA fallback 能工作的底层机制。
最终你可能得到一个非常离谱的响应:
GET /assets/ReportPage-d91e2.js
HTTP/2 200
Content-Type: text/html
<!doctype html>
<html>
...
浏览器以为自己在下载 JS,服务器却给了一篇 HTML。
于是控制台不一定告诉你:
404
而可能出现:
Failed to load module script:
Expected a JavaScript module script but the server responded
with a MIME type of "text/html".
或者经典脚本环境中看到类似:
Uncaught SyntaxError: Unexpected token '<'
如果启用了:
X-Content-Type-Options: nosniff
浏览器还会严格阻止 MIME 类型不正确的脚本和样式资源。MDN 明确要求脚本和样式应以正确 MIME 类型提供,并说明 nosniff 会阻止错误 MIME 的脚本加载。
所以线上看到:
Unexpected token '<'
第一反应不应该永远是:
“谁写错 JS 语法了?”
而应该立刻看 Network:
这个 .js 的 Response 到底是不是 <!doctype html>?
Service Worker 是这个问题里最容易被漏掉的第三套缓存。
一旦项目启用了 PWA:
Browser HTTP Cache
CDN Cache
Service Worker Cache Storage
是三件不同的东西。
Service Worker 还能直接拦截页面 Fetch,因此服务器明明已经修好,SW 仍可能返回自己的旧缓存。
更复杂的是 Service Worker 有自己的生命周期。Chrome Workbox 官方文档指出,新 SW 默认会等待旧 SW 控制的页面全部卸载后再激活;如果强制 skipWaiting(),新 Worker 可能开始控制一个由旧页面版本加载出来的页面,当前页面引用的某些资源此时甚至可能已经不在缓存或服务器上。
所以这种代码:
self.skipWaiting()
self.addEventListener('activate', () => {
self.clients.claim()
})
并不是天然“更先进”。
对于有版本耦合的应用,它可能把:
旧页面 Runtime
+
新 Service Worker
硬生生拼到一起。
web.dev 官方明确警告:skipWaiting() 可能让新 Worker 控制由旧版本加载的页面,如果这种混合会破坏应用,就不应该这么做。
排查流程与诊断命令
这类问题最忌讳的是:
白屏
↓
让客户 Ctrl+F5
↓
好了
↓
结案
因为 Ctrl+F5 只证明:
缓存/版本链路值得怀疑。
它没有告诉你错误到底发生在 Browser、Service Worker、CDN、Nginx,还是发布目录。
MDN 对普通重新加载和强制重新加载的缓存行为有专门说明,强刷可能绕过本地缓存,因此它适合用于定位,但不应该成为线上发布方案。
我实际会按照下面的顺序排。
先在 DevTools Network 抓到“第一条失败的静态资源”。
打开 Chrome DevTools:
Network
→ Preserve log
→ 刷新或复现操作
→ 筛选 JS / CSS
重点不要先看 Console,而是找:
红色请求
记录至少这些信息:
Request URL
Status Code
Content-Type
Cache-Control
ETag
Last-Modified
Age
Via
CF-Cache-Status / X-Cache 等 CDN Header
Initiator
Response Body
尤其看 Request URL:
/assets/UserPage-7f31a.js
然后对照当前生产目录:
find /var/www/app -name 'UserPage-7f31a.js'
如果当前服务器只有:
UserPage-19bd2.js
那已经非常接近结论了。
如果 DevTools 显示:
from memory cache
from disk cache
说明 HTTP 浏览器缓存参与了链路。
如果响应来自 Service Worker,则进一步打开:
Application
→ Service Workers
→ Cache Storage
检查当前 Worker、Waiting Worker 和 Cache Storage,而不是只清普通 HTTP Cache。Workbox 官方文档说明,新旧 Worker 可以同时处在不同生命周期状态,甚至两个标签页运行不同版本,这正是复杂线上更新问题的重要来源。
然后用 curl 脱离浏览器验证。
检查入口 HTML:
curl -sS -D - -o /dev/null \
https://app.example.com/index.html
理想结果:
HTTP/2 200
content-type: text/html
cache-control: no-cache
etag: "..."
last-modified: ...
检查当前 Hash 资源:
curl -sS -D - -o /dev/null \
https://app.example.com/assets/index-c72e8.js
理想结果类似:
HTTP/2 200
content-type: text/javascript
cache-control: public, max-age=31536000, immutable
MDN 推荐的缓存模型与此一致:HTML 主资源 no-cache,可指纹化的静态子资源则可以通过 Hash URL 配合长期缓存。
接着检查一个明确不存在的静态文件:
curl -sS \
-D /tmp/headers.txt \
-o /tmp/body.txt \
https://app.example.com/assets/definitely-not-exist-123456.js
cat /tmp/headers.txt
head -c 200 /tmp/body.txt
正确:
HTTP/2 404
危险:
HTTP/2 200
Content-Type: text/html
Body:
<!doctype html>
<html>
...
一旦出现这个结果,基本可以确认:
静态文件不存在
↓
被 SPA fallback 吞掉
↓
返回 index.html
此时不要继续研究 Vue。
研究 Nginx。
再验证 no-cache + ETag 是否真的产生协商缓存。
第一次:
curl -sS -D - -o /dev/null \
https://app.example.com/index.html
记录:
ETag: "abc123"
第二次:
curl -sS -D - -o /dev/null \
-H 'If-None-Match: "abc123"' \
https://app.example.com/index.html
未变化时理想返回:
HTTP/2 304
MDN 对 ETag 和 304 Not Modified 的定义就是这一用途:条件请求确认资源未变化后,可以继续使用本地副本而不重新传输完整内容。
还可以主动要求重新验证:
curl -sS -D - -o /dev/null \
-H 'Cache-Control: no-cache' \
https://app.example.com/index.html
如果用了 CDN,一定比较“CDN 响应”和“Origin 响应”。
公网:
curl -sS -D - -o /dev/null \
https://app.example.com/index.html
假设源站公网 IP 是用于示例的:
203.0.113.10
可以保留域名 Host 与 TLS SNI、直接打 Origin:
curl --resolve app.example.com:443:203.0.113.10 \
-sS -D - -o /dev/null \
https://app.example.com/index.html
如果 CDN 返回:
Cache-Control: max-age=14400
Age: 9320
而 Origin 返回:
Cache-Control: no-cache
那就已经证明:
问题不在 Nginx 本身,而在 CDN Cache Rule / Edge TTL / Browser TTL 之类的策略层。
不同 CDN 的规则不同。以 Cloudflare 为例,官方明确说明其 Cache Rules、Edge Cache TTL 和 Browser Cache TTL 可以改变源站缓存行为,因此应以实际 CDN 配置和公网响应为准。
openssl 适合确认 TLS/SNI 链路,并能直接看原始 HTTPS 响应。
例如:
printf 'GET /index.html HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n' |
openssl s_client \
-quiet \
-connect app.example.com:443 \
-servername app.example.com \
2>/dev/null |
sed -n '1,30p'
检查证书/SNI:
openssl s_client \
-connect app.example.com:443 \
-servername app.example.com \
-showcerts </dev/null
它不是专门的缓存诊断工具,但在多域名反代、CDN 回源或某些客户端走到错误虚拟主机时,可以帮助确认实际命中的 TLS 站点。
Nginx 侧首先把最终生效配置拿出来。
不要只盯:
/etc/nginx/conf.d/app.conf
因为可能还有 include。
执行:
nginx -t
nginx -T
Nginx 官方说明,-t 会检查配置语法及引用文件,-T 在此基础上把完整配置输出出来。
可以快速筛:
nginx -T 2>&1 |
grep -nE 'server_name|root|alias|try_files|expires|Cache-Control|assets|index.html'
重点搜索:
try_files $uri $uri/ /index.html;
以及是否存在:
location /assets/
日志最好直接把 Content-Type 和 Cache-Control 打进去。
例如:
log_format frontend
'$time_iso8601 '
'$remote_addr '
'"$request" '
'status=$status '
'type="$sent_http_content_type" '
'cache="$sent_http_cache_control" '
'etag="$sent_http_etag" '
'referer="$http_referer" '
'ua="$http_user_agent"';
access_log /var/log/nginx/frontend_access.log frontend;
Nginx 的日志模块支持自定义 log_format;官方还特别说明,请求最终记录在处理结束所在的 location 上,而内部重定向可能改变这个 location,这一点恰好对分析 try_files → /index.html 非常重要。
查旧 Chunk:
grep 'ReportPage-d91e2.js' \
/var/log/nginx/frontend_access.log
你可能看到:
2026-08-27T14:32:18+09:00
203.0.113.25
"GET /assets/ReportPage-d91e2.js HTTP/2.0"
status=404
type="text/html"
或者错误 fallback:
status=200
type="text/html"
同时检查 error log:
grep 'ReportPage-d91e2.js' \
/var/log/nginx/error.log
典型文件不存在日志可能类似:
open() "/srv/www/app/assets/ReportPage-d91e2.js" failed
(2: No such file or directory)
到这里,基本就可以把一个模糊的“偶发白屏”收敛成:
哪一个版本
哪一个 Chunk
谁引用了它
哪一层返回了什么
为什么这个文件不存在
这才叫定位完成。
可落地解决方案
真正稳定的方案不是某一条 Nginx Header,而是一套完整的版本发布协议。
我会把它概括成:
HTML 短
Assets 长
资源先发
入口后发
旧资源不急删
404 就是真 404
SW 版本要一致
CDN 只刷新可变入口
先把 Nginx 配正确。
下面是一份可以直接改造的 SPA 配置思路:
server {
listen 443 ssl http2;
server_name app.example.com;
root /srv/www/app/current;
include /etc/nginx/mime.types;
default_type application/octet-stream;
# --------------------------------------------------
# 1. HTML:URL 不变,是“当前版本指针”
# 可以存储,但每次使用前必须重新验证
# --------------------------------------------------
location = /index.html {
try_files $uri =404;
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
}
# --------------------------------------------------
# 2. Vite / Webpack Hash Assets:
# URL 随内容变化,可长期缓存
#
# 注意这里 Cache-Control 不使用 always:
# 避免 404 的旧 chunk 也被误加一年长期缓存
# --------------------------------------------------
location ^~ /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Content-Type-Options "nosniff" always;
}
# --------------------------------------------------
# 3. 如果存在不在 /assets/ 下的静态文件,
# 缺失时应真正返回 404,而不是进入 SPA fallback
# --------------------------------------------------
location ~* \.(?:js|mjs|css|map|json|wasm|png|jpe?g|gif|svg|webp|ico|woff2?|ttf)$ {
try_files $uri =404;
add_header X-Content-Type-Options "nosniff" always;
}
# --------------------------------------------------
# 4. Service Worker 本身是非 Hash URL
# 应及时检查更新
# --------------------------------------------------
location = /sw.js {
try_files $uri =404;
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
}
# PWA manifest 同样通常是非 Hash 入口
location = /manifest.webmanifest {
try_files $uri =404;
add_header Cache-Control "no-cache" always;
}
# --------------------------------------------------
# 5. 最后才是 SPA Router fallback
# --------------------------------------------------
location / {
try_files $uri $uri/ /index.html;
}
}
这里最关键的不是配置长什么样,而是 location 的责任边界:
真实静态资源请求
↓
文件不存在
↓
404
而不是:
真实静态资源请求
↓
文件不存在
↓
index.html
↓
HTTP 200 text/html
Nginx 官方的 try_files 语义决定了 fallback 行为;MDN 对脚本 MIME 校验的要求决定了“HTML 冒充 JS”最终会在浏览器端失败。
另一个容易忽视的细节是:
add_header Cache-Control "public, max-age=31536000, immutable" always;
不要不加思考地用在 /assets/。
Nginx 官方说明,不带 always 的 add_header 只会应用于指定的一组成功/重定向状态码,而 always 会把 Header 加到其他状态上。对于 Hash Assets,这意味着你可能不小心让一个:
404 /assets/old-chunk.js
也带上:
Cache-Control: public, max-age=31536000, immutable
这显然不是你想要的。
然后修改部署策略:不要再 rm -rf dist 式发布。
最理想的静态资源目录是:
append-only
也就是:
旧 Hash 文件保留
新 Hash 文件新增
假设:
v1:
/assets/index-a81f3.js
/assets/UserPage-7f31a.js
v2:
/assets/index-c72e8.js
/assets/UserPage-19bd2.js
发布 v2 后一段时间应该是:
/assets/index-a81f3.js
/assets/UserPage-7f31a.js
/assets/index-c72e8.js
/assets/UserPage-19bd2.js
而不是只剩:
/assets/index-c72e8.js
/assets/UserPage-19bd2.js
Vite 官方对于版本偏差直接建议“暂时保留旧 chunks”;CloudFront 对静态内容同样推荐使用版本化文件名,因为这种方式天然适合版本切换与回滚。
一个更加稳妥的文件结构可以是:
/srv/www/app/
├── current -> releases/20260827-140000
├── releases/
│ ├── 20260827-100000/
│ │ └── index.html
│ └── 20260827-140000/
│ └── index.html
└── shared/
└── assets/
├── index-a81f3.js
├── UserPage-7f31a.js
├── index-c72e8.js
└── UserPage-19bd2.js
这里要特别强调:
“发布目录原子切换”和“保留旧 Assets”是两个不同问题。
原子切换解决的是:
新版本发布到一半被访问
保留旧 Assets 解决的是:
旧客户端还在请求上一版本 Hash
只做前者不做后者,旧 Tab 的动态 Import 仍然会炸。
这也是很多所谓“我们都蓝绿发布了怎么还有 ChunkLoadError”的根源。
正确发布顺序应当是“Assets 先、HTML 后”。
可以设计为:
构建 v2
↓
上传所有 v2 Hash Assets
↓
逐一确认资源可访问
↓
预热 CDN / 做 smoke test
↓
最后发布 v2 index.html
↓
刷新 / 失效 index.html 的 CDN 缓存
↓
监控旧 Hash 404
↓
经过安全窗口后再 GC 老资源
这样一来,一旦有用户获得:
index.html v2
它引用的资源在此前已经全部存在。
反过来发布:
index.html
↓
assets
就会制造一个天然的不一致窗口。
这属于从 Hash 依赖图和 Vite 官方版本偏差机制直接推导出的发布原则。
如果是对象存储 + CDN,更适合:
Hash Assets:只新增,不覆盖
index.html:最后覆盖
而 CDN 一般只需要主动处理这些非版本化入口:
/
/index.html
/manifest.webmanifest
Service Worker 则按自己的更新策略处理。
Hash Assets:
/assets/index-c72e8.js
通常根本不需要 Purge,因为新版本本来就是一个新 URL。CloudFront 官方正是因为这一原因推荐版本化文件名;Cloudflare 也推荐在需要 Purge 时优先按具体 URL 清除,而不是一上来 Purge Everything。
蓝绿发布也不是魔法。
假设:
Blue = v1
Green = v2
切流以后,一个早已打开的 v1 页面请求:
/assets/UserPage-v1hash.js
如果这个请求被负载均衡到了只保存 v2 文件的 Green:
404
照样炸。
因此蓝绿架构里,静态 Hash Assets 最理想的方式是:
Blue ─┐
├──→ 共享/版本化 Asset Store
Green ┘
或者确保两个环境在兼容窗口内都能访问旧版本资源。
否则所谓蓝绿只保证:
服务器应用版本可切换
却没有保证:
浏览器旧版本资源仍然可解析
回滚策略也应该建立在“旧资源仍然存在”上。
真正可回滚:
current index → v2
↓ 故障
current index → v1
并且:
v1 Hash Assets 仍然存在
那么回滚只是切入口。
伪回滚:
index.html 回 v1
但是:
/assets/index-a81f3.js
已经删除。
结果:
回滚后的 HTML
+
已经不存在的旧资源
=
继续白屏
所以:
删除旧 Assets,实际上等于删除了真正的前端回滚能力。
这也是版本化资源相较于覆盖同名资源更适合发布/回滚的重要原因。
Service Worker 发布策略则应该明确“谁和谁必须同版本”。
对于普通业务系统,我更推荐:
发现新 SW
↓
进入 waiting
↓
提示“发现新版本”
↓
用户确认 / 在安全时机升级
↓
skipWaiting
↓
新 SW controlling
↓
刷新页面
↓
HTML + Runtime + SW 切到新版本
而不是:
新 SW 一下载
↓
立即 skipWaiting
↓
clients.claim
↓
让旧页面继续运行
Workbox 官方明确指出,新 Service Worker 开始控制当前页面意味着“控制页面的 Worker 版本”和“页面加载时的版本”可能已经不同;如果当前页面引用的资源已经从缓存和服务器消失,就可能发生故障。
如果项目确实需要离线能力,Service Worker 预缓存 Hash Assets 反而可以成为解决问题的一部分。Vite 官方也把“通过 Service Worker 预取并缓存所有静态资源”列为版本偏差的处理方式之一。
但前提是缓存升级过程保持一致,而不是:
activate:
删除全部旧 Cache
skipWaiting()
clients.claim()
然后让一个旧 Tab 在下一秒再动态请求 v1 Chunk。
这种缓存清理策略很容易亲手制造版本错位。
最后再加一层前端兜底。
Vite 官方提供了:
vite:preloadError
事件,在动态导入加载失败时触发。官方示例本身就允许刷新页面恢复。
生产环境可以做得稍微稳一点,避免无限刷新:
const RELOAD_KEY = '__chunk_reload_at__'
const RELOAD_COOLDOWN = 10_000
window.addEventListener('vite:preloadError', (event) => {
// 阻止 Vite 继续把这个错误作为未处理错误抛出
event.preventDefault()
const now = Date.now()
const lastReloadAt = Number(
sessionStorage.getItem(RELOAD_KEY) || 0
)
// 防止 CDN / HTML 仍然异常时形成刷新死循环
if (now - lastReloadAt < RELOAD_COOLDOWN) {
console.error(
'[vite:preloadError] reload suppressed:',
event.payload
)
// 这里可以上报 Sentry / 自研监控,并展示错误页面
return
}
sessionStorage.setItem(RELOAD_KEY, String(now))
window.location.reload()
})
但必须明确:
自动刷新是 UX 降级方案,不是版本一致性解决方案。
如果:
CDN 一直返回旧 index.html
那么刷新之后还是:
旧 index
→ 旧 Chunk
→ 404
甚至:
reload
→ error
→ reload
→ error
所以一定要有防重入。
Webpack 或其他 Bundler 可以做一个更通用的兜底,例如监听未处理 Promise Rejection:
const CHUNK_RELOAD_KEY = '__chunk_reload_once__'
function isChunkLoadError(error: unknown): boolean {
const message =
error instanceof Error
? `${
error.name}: ${
error.message}`
: String(error)
return [
/ChunkLoadError/i,
/Loading chunk .* failed/i,
/Failed to fetch dynamically imported module/i,
/Importing a module script failed/i,
].some((pattern) => pattern.test(message))
}
window.addEventListener('unhandledrejection', (event) => {
if (!isChunkLoadError(event.reason)) {
return
}
// 不同浏览器、Bundler 的错误文本不同,
// 这里只能作为最终兜底,不能替代 Network 诊断。
if (sessionStorage.getItem(CHUNK_RELOAD_KEY)) {
console.error('Chunk still unavailable after reload', event.reason)
return
}
sessionStorage.setItem(CHUNK_RELOAD_KEY, '1')
window.location.reload()
})
Webpack 官方确认动态 import() / code splitting 的 Chunk 运行期加载失败会产生 ChunkLoadError,但错误也可能来自网络或 publicPath 等其他原因,所以不能把上述正则当成“发现缓存故障”的绝对判断。
我还建议每个构建都写入一个 Build ID,例如:
Git Commit:
8d71a94
Build:
20260827-143022
在代码中:
console.info('APP_BUILD_ID:', import.meta.env.VITE_BUILD_ID)
同时上报错误:
{
"buildId": "20260827-143022",
"page": "/reports",
"chunkUrl": "/assets/ReportPage-d91e2.js",
"error": "Failed to fetch dynamically imported module"
}
这样第二天你看到日志时,不再是:
“有人昨天白屏了。”
而是:
“运行 v1 的用户在 v2 发布后 17 分钟,
请求了 v1 的 ReportPage Chunk,
CDN 回源后得到 404。”
这两种排障效率完全不是一个量级。
最佳实践清单
一套我认为比较稳的生产发布规则,可以压缩成下面这张检查表:
- 入口 HTML:
Cache-Control: no-cache,保留ETag/Last-Modified条件验证能力;如果 HTML 包含用户级个性化内容,还要考虑private。MDN 对主 HTML 正是推荐这一模型。 - Hash JS/CSS/字体/图片:使用
public, max-age=31536000, immutable;前提是内容变化一定换 URL,并且同一个 Hash URL 永远不覆盖成其他内容。 - 发布顺序:先上传并验证全部 Hash Assets,最后再切换
index.html;部署期间不要让用户拿到引用尚未就绪资源的新入口。 - 旧资源保留:不要每次发布立即删除上一版本 Chunk;Vite 官方明确建议版本偏差场景暂时保留旧 Chunk。
- SPA fallback:业务路由可以 fallback 到
/index.html,但.js/.css/.wasm/.json等真实静态资源缺失必须返回真实404;Nginxtry_files的最终 URI 转向机制决定了这一点。 - CDN:Hash Assets 优先依赖 URL 版本化,而不是频繁 Purge;部署时主要刷新或重新验证
/、/index.html等非版本化入口。CDN Edge TTL 与 Browser TTL 要单独核对。 - 回滚:入口回滚之前必须确认该版本所有 Hash Assets 仍然存在;否则“index 回滚”只是表面回滚。
- Service Worker:不要盲目
skipWaiting + clients.claim;对于 breaking update,优先让用户在合适时机完成页面与 Worker 的整体升级。 - 监控:单独统计
/assets/*的404、动态 Import Error、ChunkLoadError、错误 MIME,并给日志增加 Build ID。 - 清理旧版本:不要用“新版本发布成功 5 分钟”作为删除条件,而应该结合最长会话时间、PWA/SW 生命周期、活跃旧版本请求和业务回滚窗口决定 GC 时间。Vite 官方提出保留旧 Chunk 的本质,就是为仍在运行的旧客户端留下兼容窗口。
发布前甚至可以做一个简单的自动校验。
假设 index.html 已经生成:
grep -oE '/assets/[^"]+\.(js|css)' dist/index.html
得到:
/assets/index-c72e8.js
/assets/index-f884a.css
发布流水线逐个确认:
curl -fSs \
https://app.example.com/assets/index-c72e8.js \
-o /dev/null
curl -fSs \
https://app.example.com/assets/index-f884a.css \
-o /dev/null
全部可访问以后再开放新入口。
对复杂项目,还应该从 Bundler Manifest 或构建清单递归校验动态 Chunk,而不仅仅校验 index.html 里的入口文件。Vite 和 Webpack 的构建都存在入口资源、拆分 Chunk 和运行期异步 Chunk 的关系,因此只验证第一层 <script> 并不能证明所有懒加载路径都完整。
上线后再做一条“反向 Smoke Test”:
curl -sS -D - \
https://app.example.com/assets/this-file-must-not-exist.js \
-o /tmp/not-found-body
断言:
Status == 404
Content-Type != JavaScript
Body != index.html
这一条非常便宜,却能直接发现大量错误 SPA fallback 配置。
常见误区与问答
问:index.html 直接配置 no-store 不是更保险吗?
不一定。no-cache 允许缓存响应,但每次复用之前必须重新验证;no-store 则要求完全不存储。对于普通、非敏感的 SPA 主 HTML,MDN明确把 no-cache + ETag/Last-Modified 作为合适方案,因为内容未变化时还能通过 304 节约传输。涉及敏感数据、明确不允许存储的响应,再考虑 no-store。
问:Assets 配一年缓存,用户岂不是一年都拿不到新代码?
不会,前提是它们是真正的内容 Hash URL。
旧版:
/assets/index-a81f3.js
新版:
/assets/index-c72e8.js
浏览器看到的是完全不同的 URL。Webpack [contenthash] 会随内容变化改变文件名,Vite 也会为构建资源生成带 Hash 的路径;长期缓存恰恰是这一模式的目标。
真正危险的是:
/app.js
缓存一年,然后每次发布都覆盖:
/app.js
那才是在和浏览器缓存正面对抗。
问:我已经把 index.html 配成 no-cache,为什么还会有 ChunkLoadError?
因为已经打开的页面根本不需要再次加载 index.html。
例如:
09:00 用户加载 v1
12:00 发布 v2
16:00 用户在旧 Tab 第一次打开懒加载菜单
16 点执行 import() 的仍然是上午加载进内存的 v1 Runtime。如果旧 Chunk 中午已经被删除,它依然会 404。Vite 官方专门描述了这种新部署删除旧资源、旧运行实例再动态导入旧 Chunk 的情况。
因此:
HTML no-cache
是必要条件,但不是充分条件。
还要:
保留旧 Assets
+
动态 Import 错误兜底
问:用户 Ctrl+F5 以后好了,是不是就不用管了?
不是。
这只能说明新的页面加载绕开或重新验证了某些缓存,使入口与当前资源重新对齐。MDN 说明普通 Reload 和强制 Reload 会采用不同缓存请求行为,因此强刷很适合作为排障实验,但它无法成为生产解决方案。
真正的问题仍然存在:
其他用户怎么办?
一直不刷新的 Tab 怎么办?
CDN 上旧入口怎么办?
Service Worker 缓存怎么办?
下一次发布怎么办?
如果答案是:
“让用户再 Ctrl+F5。”
那不是修复,只是把发布系统的责任转给用户。
问:CDN Purge Everything 一次不就彻底解决了吗?
也不是。
Cloudflare 官方明确指出,Purge CDN Cache 并不会清除访问者浏览器中已经保存的缓存资源。CloudFront 则建议对于频繁更新的文件优先使用版本化文件名,因为 CDN Invalidation 无法解决所有客户端和代理缓存中的旧版本。
而且 Hash Assets 本来就没有必要反复 Purge:
旧:
/assets/app-A.js
新:
/assets/app-B.js
它们是两个不同对象。
真正需要重点处理的通常是:
/
/index.html
这类不换 URL 的入口。
问:为什么我的旧 JS 明明不存在,Network 却是 200?
先看:
Content-Type
再看 Response Body。
如果是:
HTTP/2 200
Content-Type: text/html
Body:
<!doctype html>
极大概率是:
try_files $uri $uri/ /index.html;
把静态文件缺失也 fallback 到 SPA 入口。
Nginx 官方说明 try_files 找不到前面的候选文件时会转到最后的 URI;而浏览器要求脚本使用正确 MIME,nosniff 下错误 MIME 会直接被阻止。
正确规则应当是:
页面路由不存在
→ index.html
静态资源不存在
→ 404
这两类请求千万不能混。
问:ChunkLoadError 就一定是版本缓存吗?
不是。
Webpack 官方建议同时检查:
Chunk 是否可通过网络访问
publicPath 是否正确
浏览器 Console / Network 是否有底层错误
Vite 也列出了网络条件、浏览器扩展以及版本偏差等多种可能原因。
所以更可靠的判断链应该是:
ChunkLoadError
↓
找到具体 Chunk URL
↓
检查 HTTP Status
↓
检查 Response Content-Type / Body
↓
确认 URL 属于哪个 Build
↓
确认当前 Server/CDN 是否还存在这个资源
而不是:
ChunkLoadError
↓
清缓存
问:原子发布以后是不是就不会再白屏了?
只能解决一部分。
原子发布主要消除:
index v2 已经出现
但 v2 Assets 只复制了一半
这种“半成品版本”。
它无法解决:
旧 Tab v1
↓
发布切到 v2
↓
旧 Tab 请求 v1 Lazy Chunk
如果 v1 Assets 不再可访问,旧用户依然会失败。
因此真正完整的方案是:
原子发布
+
旧 Hash Assets 兼容窗口
两者缺一不可。这是从 Vite 官方所描述的旧客户端引用已删除 Chunk 的版本偏差问题直接得出的工程结论。
问:蓝绿发布不是已经保留旧版本服务器了吗?
要看 Asset 请求到底被路由到哪里。
如果:
Blue v1
Green v2
但切流以后 /assets/* 全部进入 Green,而 Green 只有 v2 Assets,那么早先打开的 Blue/v1 页面依然可能请求:
/assets/v1-old-hash.js
最终在 Green 上 404。
因此更稳的做法是让 Hash Assets 进入:
共享、不可变、可同时容纳多个版本的资源层
而不是让静态文件生命周期完全绑定单个应用实例。版本化 URL 能让不同发布版本并存,也正是 CloudFront 官方推荐其用于发布、缓存与回滚控制的原因之一。
问:用了 Service Worker,是不是反而可以不用管 HTTP Cache?
恰恰相反,你多了一套需要治理的状态。
Service Worker 可以拦截请求并提供自己的缓存资源;新 Worker 默认还可能处在 waiting 状态。如果强制 skipWaiting(),它甚至可能开始控制由旧 Worker/旧页面版本加载出来的页面。Workbox 和 web.dev 都明确提示了新旧 Service Worker 与页面版本混合时可能出现资源不兼容的问题。
所以 PWA 项目排查白屏时至少要同时问:
浏览器 HTTP Cache 是什么版本?
CDN 是什么版本?
index.html 是什么版本?
JS Runtime 是什么版本?
Active Service Worker 是什么版本?
Waiting Service Worker 是什么版本?
Cache Storage 里有什么版本?
服务器还保留哪些 Hash Assets?
少查一层,都可能被误导。
问:为什么这个问题往往“只有几个用户有”,开发和测试都复现不出来?
因为版本偏差高度依赖客户端时序。
刚打开页面的用户可能是:
HTML v2 + Runtime v2 + Assets v2
完全正常。
上午一直没关页面的人可能是:
Runtime v1 + Server v2
只有进入某个 Lazy Route 才出问题。
某个 CDN Edge 的用户可能是:
HTML v1 + Server Assets v2
入口就白屏。
装了旧 Service Worker 的少数用户可能又是:
HTML v2 + SW v1
Workbox 官方甚至专门讨论了两个 Tab 同时运行不同网站版本,以及页面版本与 Service Worker 版本不一致的场景。
所以所谓“偶发”,很多时候并不随机。
它只是取决于:
用户什么时候打开
多久没有刷新
访问哪个 CDN 节点
是否命中浏览器缓存
是否安装 Service Worker
什么时候第一次进入懒加载路由
发布时是否删除旧资源
一旦把这些变量记录下来,所谓玄学白屏通常都会变成一个非常确定的版本一致性问题。
最终,前端静态发布最理想的模型其实可以压缩成一句话:
index.html 是可变指针,
Hash Asset 是不可变对象;
发布新指针之前先让新对象全部存在,
切换指针之后也不要立刻销毁旧对象。
Vite 官方对“版本偏差”的排错建议、MDN 对 HTML 与指纹资源的缓存模型,以及 CDN 官方对版本化 URL 的建议,本质上都指向了同一件事:前端发布不是简单地把 dist 覆盖到服务器,而是在维护浏览器、CDN、Service Worker 与源站之间的一份跨时间版本依赖关系。