一次前端生产白屏复盘:旧 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,越要重视静态资源版本、缓存和发布兼容。

相关推荐
咩咩啃树皮1 小时前
第63篇【终极整合巨作】从零手写原生中后台管理系统|整合62篇全部前端知识点|从头到尾完整架构+全功能逻辑
前端·架构
Highcharts2 小时前
用Highcharts如何动态向一个序列添加点-示列讲解
前端
weixin_431600443 小时前
做 Agent 会用到的 Node API(4):取消与 AbortController
前端·学习·ai·agent·ai编程
JiaLin_Denny3 小时前
Vue3中ref和reactive用法与双向数据绑定
前端·javascript·vue.js
用户2181697049303 小时前
Flutter(十)Container Center Align
前端
无人生还3 小时前
从 Vue3 到 React · 快速上手系列第 12 篇:并发特性与性能优化
前端·vue.js·react.js
小p3 小时前
nextjs学习9: Next.js 渲染与缓存
前端·后端
乘风gg3 小时前
AI 时代,你的编程能力在第几层?我敢说,大多数人卡在第一层
前端·ai编程·claude
东风破_3 小时前
React Router v7 实战:用路由配置、懒加载与嵌套路由搭建一个完整的 SPA
前端