前端发布后偶发白屏

简介: 前端“发布后只有部分用户白屏、刷新又偶尔恢复”的根因,很多时候不是 Vue、React 或业务代码本身,而是**同一次页面运行中混入了不同发布版本的入口 HTML、JavaScript Runtime 和静态 Chunk**。Vite 官方甚至单独把这一类问题称为“版本偏差”:旧 HTML 或旧页面运行时仍引用上一版本的 Chunk,而新部署已经删除了这些文件,于是动态 `import()` 最终请求一个不存在的 Hash URL。 解决它不能只靠一句“清浏览器缓存”,而应该把发布模型改成:**HTML 是可变指针,必须及时重验证;Hash 静态资源是不可变对象,允许长期缓存且旧版本暂时不

前端发布后偶发白屏?旧 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.jsReportPage-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-ageimmutable;主 HTML 因为 URL 本身通常不能通过 Hash 更新,适合使用 no-cache,并配合 ETagLast-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-Modified304 则用于条件 GET/HEAD 表示已有缓存副本仍然有效。

Expires 又是什么?

Expires 使用一个绝对日期表示资源什么时候过期,例如:

Expires: Fri, 27 Aug 2027 02:30:00 GMT

而:

Cache-Control: max-age=31536000

表示从当前响应开始可以新鲜一年。

现代项目通常以 Cache-Control 为主。如果响应中已经有 max-ages-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-ModifiedETag 则都可以配合 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 对 ETag304 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 官方说明,不带 alwaysadd_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。”

这两种排障效率完全不是一个量级。

最佳实践清单

一套我认为比较稳的生产发布规则,可以压缩成下面这张检查表:

  • 入口 HTMLCache-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;Nginx try_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 与源站之间的一份跨时间版本依赖关系。

相关文章
人工智能 缓存 前端开发
11691 58
人工智能 JavaScript 开发工具
4660 17
Web App开发 人工智能 API
1157 1
开发工具 Swift git
1881 6
人工智能 Java BI
1292 1
人工智能 JavaScript 测试技术
2121 2
人工智能 JavaScript 测试技术
1079 4
缓存 JavaScript Shell
2052 3