阅读视图

发现新文章,点击刷新页面。

只有前端 Leader 才会告诉你:那些年踩过的模块加载失败的坑

前端部署后模块加载 404:从崩溃到自动恢复的解决方案

测试时发现的诡异问题

最近在开发一个新项目,用的 Vite + React 技术栈,开发体验挺不错。测试阶段却发现了一个诡异的问题。

测试同学反馈:点击某个功能模块时,页面直接白屏了。打开控制台一看:

Failed to fetch dynamically imported module: /assets/chat-abc123.js
GET /assets/chat-abc123.js 404 Not Found

image-20251029200535769.png

用户刷新页面就又好了。但问题是——用户不该看到这个错误页面。

几个疑问立刻冒出来:

  • 为啥好端端的 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 文件已经是新的了

这问题只在三个条件同时满足时才会出现:

  1. 用户长时间不刷新页面(保持旧版 HTML)
  2. 后端部署了新版本(旧 chunk 被替换)
  3. 用户触发懒加载(动态 import 新模块)

如果项目没用代码分割,所有 JS 都在首次加载,反而不会有这问题。但为了性能做了懒加载,结果踩了这个坑。

解决思路:自动刷新 + 兜底提示

想了几种方案:

方案 优点 缺点
保留旧版本文件 彻底避免 404 需要改造部署流程,清理策略复杂
版本检测轮询 可以主动通知用户 增加服务器压力,体验一般
捕获错误自动刷新 实现简单,用户无感知 需要防止无限刷新

最后选了第三种——简单有效,改动最小。

核心逻辑很简单:

  1. 检测到模块加载失败 → 自动刷新页面
  2. 刷新后还失败 → 显示友好错误提示
  3. 用 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 拦截请求(最简单)

这个方法不用改代码,直接在浏览器里模拟:

  1. 打开 DevTools,切到 Network 标签
  2. 右上角三个点 → More tools → Request blocking
  3. 添加拦截规则:*page/chat**chunk*
  4. 切换路由触发懒加载
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 });
}

修改效果

现在有了双层防护

  1. ErrorBoundary(第一道防线) - 捕获 React 组件错误,快速响应
  2. window.error(兜底) - 捕获其他未处理的错误

无论错误从哪里来,都能被正确处理并自动刷新。


参考资源

  1. Vite 文档 - 构建生产版本 - 关于 chunk 分割的配置
  2. MDN - Dynamic import() - 动态导入的原理
  3. React Error Boundaries - 错误边界的使用
❌