背景
最近处理了一次生产环境白屏问题。
项目是一个 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 不匹配,拒绝执行,页面白屏
这里有两个关键点:
- 旧 HTML 还在引用旧 JS。
- 缺失的 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,越要重视静态资源版本、缓存和发布兼容。