只有前端 Leader 才会告诉你:那些年踩过的模块加载失败的坑
前端部署后模块加载 404:从崩溃到自动恢复的解决方案
测试时发现的诡异问题
最近在开发一个新项目,用的 Vite + React 技术栈,开发体验挺不错。测试阶段却发现了一个诡异的问题。
测试同学反馈:点击某个功能模块时,页面直接白屏了。打开控制台一看:
Failed to fetch dynamically imported module: /assets/chat-abc123.js
GET /assets/chat-abc123.js 404 Not Found
![]()
用户刷新页面就又好了。但问题是——用户不该看到这个错误页面。
几个疑问立刻冒出来:
- 为啥好端端的 JS 文件会 404?
- 为啥用户刷新就好了?
- 这问题是偶发还是必现?
- 怎么让用户无感知地解决这个问题?
问题复盘:为什么会 404?
捋了下时间线,问题原因就清晰了:
sequenceDiagram
participant U as 用户浏览器
participant S as 服务器
participant CI as CI/CD
U->>S: 10:00 打开页面
S->>U: 返回 index.html (引用 chat.abc123.js)
Note over U: 用户保持页面打开
CI->>S: 14:30 部署新版本
Note over S: chat.abc123.js → chat.def456.js
U->>S: 15:00 点击聊天按钮
U->>S: 请求 chat.abc123.js
S->>U: 404 Not Found ❌
Note over U: 页面崩溃
说白了就是:用户浏览器里的 index.html 是旧的,但服务器上的 JS 文件已经是新的了。
这问题只在三个条件同时满足时才会出现:
- 用户长时间不刷新页面(保持旧版 HTML)
- 后端部署了新版本(旧 chunk 被替换)
- 用户触发懒加载(动态 import 新模块)
如果项目没用代码分割,所有 JS 都在首次加载,反而不会有这问题。但为了性能做了懒加载,结果踩了这个坑。
解决思路:自动刷新 + 兜底提示
想了几种方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 保留旧版本文件 | 彻底避免 404 | 需要改造部署流程,清理策略复杂 |
| 版本检测轮询 | 可以主动通知用户 | 增加服务器压力,体验一般 |
| 捕获错误自动刷新 | 实现简单,用户无感知 | 需要防止无限刷新 |
最后选了第三种——简单有效,改动最小。
核心逻辑很简单:
- 检测到模块加载失败 → 自动刷新页面
- 刷新后还失败 → 显示友好错误提示
- 用 sessionStorage 防止无限刷新
具体实现
1. 创建错误处理工具函数
// src/utils/moduleLoadErrorHandler.ts
export const RELOAD_FLAG_KEY = 'module_error_reloaded';
// 检测是不是模块加载错误
export function isModuleLoadError(error: Error | string): boolean {
const message = typeof error === 'string' ? error : error.message || '';
return (
// 各种模块加载错误的特征
message.includes('Failed to fetch dynamically imported module') ||
message.includes('Loading chunk') ||
message.includes('ChunkLoadError')
);
}
// 尝试自动刷新(只刷一次)
export function attemptModuleErrorReload(): boolean {
// 已经刷过了?那就别再刷了
if (sessionStorage.getItem(RELOAD_FLAG_KEY)) {
console.error('❌ 模块加载持续失败,请手动强刷 (Ctrl+F5)');
sessionStorage.removeItem(RELOAD_FLAG_KEY);
return false;
}
// 标记一下,防止无限刷新
sessionStorage.setItem(RELOAD_FLAG_KEY, '1');
console.warn('⚠️ 检测到模块加载失败,自动刷新页面...');
// 稍微延迟一下,避免页面闪烁
setTimeout(() => window.location.reload(), 100);
return true;
}
// 全局监听(兜底机制)
export function setupModuleLoadErrorHandler(): void {
window.addEventListener('error', (event: ErrorEvent) => {
if (isModuleLoadError(event.message || '')) {
attemptModuleErrorReload();
}
});
// 页面正常加载完,清除标记
window.addEventListener('load', () => {
sessionStorage.removeItem(RELOAD_FLAG_KEY);
});
}
关键点:
-
sessionStorage存活期刚好是一个会话,关闭标签页就清除 - 延迟 100ms 刷新,避免用户看到闪烁
- 刷新失败后给出明确的手动操作提示
2. 在 ErrorBoundary 中处理
React 项目一般都有 ErrorBoundary,正好在这里统一处理:
// src/components/ErrorBoundary/index.tsx
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// 模块加载错误?自动刷新试试
if (isModuleLoadError(error)) {
attemptModuleErrorReload();
return; // 页面马上刷新,不显示错误界面
}
// 其他错误正常显示
this.setState({ error, errorInfo });
}
3. 应用入口初始化
// src/main.tsx
import { setupModuleLoadErrorHandler } from './utils/moduleLoadErrorHandler';
// 尽早初始化,捕获所有错误
setupModuleLoadErrorHandler();
测试验证:模拟真实场景
方法一:Chrome DevTools 拦截请求(最简单)
这个方法不用改代码,直接在浏览器里模拟:
- 打开 DevTools,切到 Network 标签
- 右上角三个点 → More tools → Request blocking
- 添加拦截规则:
*page/chat*或*chunk* - 切换路由触发懒加载
graph LR
A[添加拦截规则] --> B[触发懒加载]
B --> C{首次失败?}
C -->|是| D[自动刷新页面]
C -->|否| E[显示错误提示]
D --> F[刷新后重试]
F --> E
看到页面自动刷新就说明成功了。保持拦截规则,再触发一次,应该直接显示错误界面(不会无限刷新)。
方法二:模拟真实部署
更接近生产环境的测试:
# 1. 构建项目
pnpm build
# 2. 启动预览服务
pnpm preview
# 3. 打开页面,不要刷新
# 4. 删除某个 chunk 文件(模拟新版本部署)
rm dist/assets/chat-*.js
# 5. 在页面中点击聊天按钮
应该看到页面自动刷新,然后正常加载(如果文件还在的话)。
上线后的效果
部署这个方案一周了,效果挺好:
- 用户反馈的"页面崩溃"问题消失了
- 监控显示模块加载错误减少了 95%
- 剩下 5% 是真的网络问题,有错误提示兜底
唯一的小问题:用户正在填表单时如果触发了自动刷新,数据会丢失。不过这种情况很少,后续可以考虑加个表单数据缓存。
Nginx 配置:从根源预防问题
前端的自动刷新是兜底,更重要的是 Nginx 配置要正确。
当前的 nginx.conf 配置
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 静态资源:找不到直接返回 404,不返回 index.html
location /assets/ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA 路由:HTML 完全不缓存
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# gzip 压缩
gzip on;
gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
gzip_min_length 1024;
}
配置说明
1. /assets/ 路径的关键配置
try_files $uri =404;
- 作用:chunk 文件找不到时,直接返回 404,而不是返回 index.html
- 为什么重要:如果返回 HTML,浏览器会尝试把 HTML 当作 JavaScript 执行,导致 MIME type 错误
- 效果:前端能正确捕获到 404 错误,触发自动刷新
2. index.html 不缓存
add_header Cache-Control "no-cache, no-store, must-revalidate";
- 作用:确保用户每次访问/刷新都获取最新的 index.html
- 为什么重要:新版本 HTML 会引用新的 chunk 文件名
- 局限性:只对"刷新页面"有效,对"已打开的页面"无效(这就是为什么需要前端自动刷新)
3. 静态资源长期缓存
expires 1y;
add_header Cache-Control "public, immutable";
- 作用:带 hash 的文件名可以永久缓存
- 好处:减少带宽消耗,提升加载速度
- 安全性:文件名变了就是新文件,不会有缓存问题
为什么这样配置?
这个配置实现了双层防护:
┌─────────────────────────────────┐
│ Nginx 层(预防 60-70%) │
│ - HTML 不缓存 │
│ - 404 正确返回 │
└─────────────────────────────────┘
↓
┌─────────────────────────────────┐
│ 前端层(兜底 30-40%) │
│ - ErrorBoundary 自动刷新 │
│ - window.error 监听 │
└─────────────────────────────────┘
Nginx 能解决的场景:
- ✅ 用户刷新页面 → 获取最新 HTML
- ✅ 新用户访问 → 获取最新版本
- ✅ 正确的 404 响应 → 前端能捕获错误
Nginx 不能解决的场景:
- ❌ 用户长时间不刷新 + 触发懒加载
- ❌ 多标签页旧版本问题
这些场景就需要前端的自动刷新来兜底。
本次代码修改说明
这次修复主要解决了一个关键问题:线上环境模块加载错误没有触发自动刷新。
问题原因
之前只在全局监听了 window.error 事件:
window.addEventListener('error', (event) => {
// 处理模块加载错误
});
但 React 的 ErrorBoundary 会先捕获错误,导致错误无法冒泡到 window.error:
React.lazy() 加载失败
↓
ErrorBoundary.componentDidCatch() 捕获 ← 在这里被拦截!
↓
显示错误界面,等待用户点击
↓
❌ window.error 永远不会触发
↓
❌ 自动刷新逻辑从未执行
解决方案
1. 重构为可复用的工具函数
// 导出检测函数
export function isModuleLoadError(error: Error | string): boolean
// 导出刷新函数
export function attemptModuleErrorReload(): boolean
// 保留全局监听(兜底)
export function setupModuleLoadErrorHandler(): void
2. 在 ErrorBoundary 中集成
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
// 检测并处理模块加载错误 - 自动刷新
if (isModuleLoadError(error)) {
attemptModuleErrorReload();
return; // 不显示错误UI,页面即将刷新
}
// 其他错误正常处理
this.setState({ error, errorInfo });
}
修改效果
现在有了双层防护:
- ErrorBoundary(第一道防线) - 捕获 React 组件错误,快速响应
- window.error(兜底) - 捕获其他未处理的错误
无论错误从哪里来,都能被正确处理并自动刷新。
参考资源
- Vite 文档 - 构建生产版本 - 关于 chunk 分割的配置
- MDN - Dynamic import() - 动态导入的原理
- React Error Boundaries - 错误边界的使用