一次前端生产白屏复盘:旧 HTML、Hash 资源和 MIME type text/html

背景

最近处理了一次生产环境白屏问题。

项目是一个 React + Vite 的 SPA 应用,构建后资源文件会带 hash,例如:

html 复制代码
<script type="module" src="/assets/index-B2mqdo5A.js"></script>

上线新版本后,部分用户打开页面出现白屏,控制台报错:

text 复制代码
Failed to load module script:
Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html".
Strict MIME type checking is enforced for module scripts per HTML spec.

这类问题看起来像 JS 加载失败,但真正原因通常不在 JS 代码本身,而在静态资源发布和缓存策略。

现象

用户浏览器请求的是旧版本资源:

text 复制代码
/assets/vendor-react-Ds7iS9xc.js
/assets/index-B2mqdo5A.js
/assets/request-CmSUi7fW.js

但线上当前版本的资源已经变成了新的 hash:

text 复制代码
/assets/index-BnFJmF7B.js
/assets/vendor-react-DvSZ28Oo.js

进一步检查旧资源请求:

bash 复制代码
curl -I https://example.com/assets/vendor-react-Ds7iS9xc.js

返回结果不是 404,而是:

http 复制代码
HTTP/2 200
content-type: text/html

这就很关键了。

浏览器原本要加载的是 JS,但服务器返回的是 HTML,也就是 index.html。所以浏览器拒绝执行 module script,最终白屏。

MIME 是什么

MIME 可以简单理解成:服务器告诉浏览器"这个资源是什么类型"。

比如 JS 文件应该返回:

http 复制代码
Content-Type: application/javascript

HTML 页面则是:

http 复制代码
Content-Type: text/html

当浏览器执行:

html 复制代码
<script type="module" src="/assets/index.js"></script>

它期望拿到的是 JavaScript。如果服务器返回 text/html,浏览器会认为类型不匹配,直接拒绝执行。

所以这次报错本质是:浏览器请求 JS,结果服务器返回了 HTML。

根因链路

完整链路是这样的:

text 复制代码
用户浏览器缓存了旧 index.html
        ↓
旧 HTML 引用上一版 hash JS
        ↓
新版本部署后旧 hash JS 已不存在
        ↓
请求 /assets/*.js 时,静态服务没有返回 404
        ↓
SPA fallback 返回了 index.html
        ↓
浏览器拿 HTML 当 module script 加载
        ↓
MIME 不匹配,拒绝执行,页面白屏

这里有两个关键点:

  1. 旧 HTML 还在引用旧 JS。
  2. 缺失的 JS 被错误 fallback 成 HTML。

如果只是旧 JS 404,问题还比较明确。真正麻烦的是它返回了 200 text/html,浏览器就会报 MIME 错误。

短期止血方案

短期目标是先让线上用户恢复可用。

我做了两件事。

1. 构建后生成旧 hash 兼容文件

在构建完成后,为本次线上报错的旧 hash 文件生成兼容文件。

例如旧 HTML 还在请求:

text 复制代码
/assets/vendor-react-Ds7iS9xc.js

构建脚本会把当前版本对应的 vendor-react-xxxx.js 复制一份成旧文件名:

text 复制代码
vendor-react-Ds7iS9xc.js -> vendor-react-current.js

这样旧 HTML 即使还在,也能拿到真正的 JS,而不是拿到 index.html。

2. 在 index.html 增加更早的兜底

之前项目里已经有 vite:preloadError 兜底,但它只能捕获动态 import 的失败。

这次问题发生在入口 JS 或 module preload 阶段,新版 main.tsx 可能还没执行,所以捕不到。

因此在 index.html 里加了更早执行的错误监听:

js 复制代码
window.addEventListener(
  'error',
  function (event) {
    var target = event.target
    var src = target && target.tagName === 'SCRIPT' ? target.src : ''

    if (!src || src.indexOf('/assets/') === -1 || !/.js($|?)/.test(src)) return

    // 清理 ServiceWorker / CacheStorage 后刷新
  },
  true,
)

命中 /assets/*.js 加载失败时,清理 ServiceWorker 和 CacheStorage,然后刷新页面,尽量拉取最新 HTML 和最新资源。

长期根治方案

短期兼容只能止血,真正根治还在静态服务和发布策略。

1. index.html 不应强缓存

入口 HTML 应该走:

http 复制代码
Cache-Control: no-cache

或者至少允许重新校验,避免用户长期持有旧入口。

2. /assets/* 缺失必须返回 404

这是最关键的。

SPA fallback 应该只作用于页面路由,例如:

text 复制代码
/models
/dashboard
/settings

不应该作用于静态资源路径:

text 复制代码
/assets/*.js
/assets/*.css

如果 /assets/index-old.js 不存在,就应该返回 404,而不是返回 index.html。

3. 发布时保留上一版 assets

由于 hash 资源天然适合长缓存,发布时最好不要立即删除上一版资源。

更稳妥的策略是:

text 复制代码
保留当前版本 assets
保留上一版 assets
延迟清理更早版本

这样在线用户跨版本刷新时,不容易直接白屏。

4. ServiceWorker 更新策略要明确

如果项目使用 PWA / ServiceWorker,需要明确:

  • 什么时候检测新版本
  • 什么时候提示用户刷新
  • 旧缓存什么时候清理
  • chunk 加载失败时怎么恢复

否则 ServiceWorker 很容易成为"旧资源引用"的来源。

验证方式

修复后,我做了几类验证。

1. 验证版本

bash 复制代码
curl https://example.com/version.json

确认线上已经部署到目标 commit。

2. 验证旧 hash 资源

对用户报错里的旧 JS 逐个检查:

bash 复制代码
curl -I https://example.com/assets/vendor-react-Ds7iS9xc.js

期望结果:

http 复制代码
HTTP/2 200
content-type: application/javascript

而不是:

http 复制代码
content-type: text/html

3. 真实浏览器验证

用真实浏览器打开线上页面,确认:

  • 页面正常渲染
  • 没有白屏
  • 控制台不再出现 MIME module script 报错
  • 旧 hash JS 请求不再返回 HTML

复盘

这次问题本质不是业务代码 bug,而是构建 hash、HTML 缓存、ServiceWorker、静态服务 fallback、发布清理策略共同作用导致的线上稳定性问题。

可以总结成一句话:

用户拿着旧 HTML,请求旧 JS;旧 JS 已不存在,服务端又把缺失 JS 返回成 HTML,浏览器因为 MIME 不匹配拒绝执行,最终白屏。

短期可以通过旧 hash 兼容文件和入口兜底恢复用户访问。

长期必须治理静态服务配置和发布策略:

  • index.html 可重新校验
  • /assets/* 缺失返回 404
  • 发布保留上一版 assets
  • ServiceWorker 更新和恢复策略明确

前端生产稳定性很多时候不只是代码逻辑问题,而是整个交付链路的问题。越是现代 SPA,越要重视静态资源版本、缓存和发布兼容。

相关推荐
夏天要喝冰可乐7 小时前
Codex 写 Three.js 大屏实录:800 行代码,和一条画反的弧线
前端·aigc
欣欣之王来了7 小时前
v-bind语法糖::属性名的简写方式
前端·学习·架构·项目·vue3教程
2601_954005628 小时前
# ricon SVG 编辑器:工业图元的“军火工厂“
前端·物联网·数学建模·架构·编辑器·制造
codeniu8 小时前
一个人,5天,从想法到上线:我用 TRAE 跑通全链路开发的完整实录
前端
shaibdoio8 小时前
RPA自动化获客怎么落地?瞬维AI评论区工程实践
前端·自动化·rpa·长沙瞬维ai
计算机魔术师8 小时前
807亿亏配900%留存:ANT招股书把AI的拧巴摊在桌上了
前端
未来之窗软件服务8 小时前
web网络请求竞态问题与取消方案-东方仙盟
前端·状态模式·仙盟创梦ide·东方仙盟·网络取消
wshzd8 小时前
LLM之Agent(120)|DeepSeek-Harness(二十七)Workflow 引擎与 Ralph 循环
前端·数据库·人工智能
涛涛ing9 小时前
React Compiler 1.0 之后,状态管理库正在被重新定义
前端
乘风gg9 小时前
出事了,AI 提效后,老板让我裁员20%
前端·后端·ai编程