SPA 发版后偶发白屏:场景、根因与治理方案

40 阅读6分钟

本文结合具体应用场景,说明我们遇到的在生产环境偶发整页空白的问题:在什么条件下出现、如何定位、项目内已采取哪些措施、以及仍依赖运维侧配合的部分。

1. 现象

  • 用户侧:页面突然白屏或长时间无内容,常发生在发版后的一段时间内,且并非所有用户同时复现。
  • 浏览器开发者工具中常见(可能同时出现多项):
    • Failed to load module script:期望加载 JavaScript 模块,响应的 Content-Type 却是 text/html
    • Failed to fetch dynamically imported module / Importing a module script failed 等动态 import() 相关错误。
    • 网络面板中:对 .../assets/*.js 的请求返回 404,或返回 200 但正文为 HTML(实为 SPA 回退页)。

另有一条独立但常一起出现在控制台的问题:413 Request Entity Too Large(附件上传超过网关或服务端请求体限制)。它不直接导致「JS 白屏」,但会干扰排障,需在文档中区分。


2. 典型触发场景

2.1 主场景:长会话跨版本

  1. 用户用版本 A 打开站点,浏览器已加载当时的 index.html、主入口脚本及已访问过的 chunk
  2. 用户未整页刷新,在单页应用内继续操作;期间线上发布版本 B,构建产物中 带内容哈希的 assets/* 文件名变化,旧文件从服务器上被替换或删除。
  3. 用户点击新菜单等操作,触发此前未加载过的懒加载 chunk,浏览器仍按版本 A 打包时写入的 URL 去请求 assets/xxx-[旧hash].js
  4. 该文件在版本 B 的部署目录中已不存在 → 请求失败;若网关对「未匹配到的静态路径」执行 SPA 回退(返回 index.html),则浏览器把 HTML 当 JS 执行 → 动态模块加载失败 → 白屏

结论:该问题在**「发版瞬间仍有用户挂在旧版文档上」时更容易出现,因此表现为偶发**。

2.2 延伸场景:入口页被强缓存

即使用户没有在「发版过程中一直开着旧页」,若 index.html 被浏览器或 CDN 长期缓存,下次「打开站点」时仍可能拿到旧版入口,其中引用的仍是已不存在的旧 hash 资源,机理与上类似。是否出现取决于 HTTP 缓存策略,与是否「长会话」无关。

2.3 一般不易出问题的场景

  • 发版后用户完整重新打开页面(新导航/硬刷新),且能拿到最新 index.html(入口未长期强缓存)时,入口中脚本 URL 与当前磁盘上的 assets 一致,通常不会因「旧 hash 被删」而白屏。

3. 根因归纳

层级说明
构建与运行方式现代打包(Vite 等)对 JS/CSS 使用内容哈希文件名单页应用在首屏后多依赖 动态 import() 按路由懒加载 chunk。
版本不一致同一用户在一次浏览中,执行中的主包(或已解析的 URL)与服务器当前实际存在的文件可能来自不同发版,导致请求旧 hash 文件失败。
网关/静态服务行为对不存在的 /assets/* 使用与前端路由相同的 try_filesindex.html 回退 时,对浏览器而言是「模块脚本 URL 返回了 HTML」,引发 MIME / 模块解析错误,与纯 404 相比更容易在控制台看到 text/html 类报错。
与 413 的关系413 表示上传体积超过 Nginx client_max_body_size 或后端 body 限制,需调网关/服务配置;不是白屏主因,但会 concurrent 出现在控制台。

4. 排查要点

  1. 时间线:是否发生在发版后?是否未整页刷新仍复现、硬刷新后恢复?
  2. 控制台:是否存在上述 动态 import / module script 关键字;413 是否与上传同一时段出现。
  3. 网络:失败的请求 URL 是否为 /…/assets/*.js;状态码 404 还是 200 + HTML;响应头 Content-Type 是否为 text/html
  4. 区分:若仅为上传接口 413,优先按体积限制处理;白屏仍以 静态资源与入口一致性 为主轴。

5. 治理方案(分层)

5.1 HTTP 缓存与静态资源(根治方向,运维 / 网关)

目标:保证用户拿到的 index.html 始终足够新,同时允许对带 hash 且文件名唯一的资源长期缓存。

资源类型建议
index.htmlCache-Control: no-cache 或极短 max-age,必要时 must-revalidate,避免长期强缓存旧入口。
/assets/ 下带 hash 的 JS/CSS可配置较长缓存(如 immutable);文件名变更即视为新资源,不会再去请求已删除的旧文件。

Nginx(子路径部署示例思路)

  • /scs/assets/(按实际前缀调整):仅映射到磁盘上真实文件,缺失则 404不要对该前缀使用与 SPA 相同的「统一回退到 index.html」,以免缺失 chunk 时仍返回 HTML。
  • /scs/api/ 等上传接口:按需增大 client_max_body_size(并与后端限制一致),缓解 413

具体片段需与现网 locationalias/root 一致,由运维在服务器配置中落地。

5.2 发布流程

  • 原子切换:一次发布对外仅暴露一整套 index.html + assets/**,避免短时间内外混用两套不一致的产物(目录替换、软链切换等方式按团队规范执行)。

5.3 前端兜底(本项目已实现)

在无法立刻改变网关策略或仍存在边缘故障时,用前端降低「纯白屏」概率并给出可操作提示:

措施文件 / 位置行为简述
懒加载失败时一次自动刷新src/lib/lazyWithRetry.ts,路由见 src/router/index.tsx判定为疑似 chunk 加载错误且本会话未刷新过时 sessionStorage 标记后 location.reload() 一次,多数情况下可拉到新入口与新资源;避免无限刷新。
错误边界src/components/ChunkLoadErrorBoundary.tsxsrc/App.tsx 包裹路由仍失败时展示说明文案,引导强制刷新或清缓存,而不是长时间无反馈白屏。
上传 413 提示src/services/api.ts 响应拦截器413 转为明确文案,避免用户误以为「页面坏了」。

说明:前端兜底不能替代index.html 缓存策略 + assets 不误返回 HTML」;首屏主脚本若整体加载失败,React 仍未启动时,错误边界也可能无法覆盖,仍以 缓存与网关配置 为主。


6. 小结

  • 场景:发版后旧会话继续操作、或 index.html 被长期缓存仍指向旧 hash 资源时,请求已删除的 assets/*.js → 动态 import 失败 → 白屏长会话跨版本是最直观的触发条件。
  • 根因入口与磁盘上的静态资源版本不一致;若网关对缺失资源返回 SPA 的 HTML,会表现为 MIME / 模块脚本类错误。
  • 方案:优先 入口短缓存 + hash 资源长缓存/assets/ 缺失即 404 不回退 HTML、发布原子切换;前端 lazyWithRetry + ChunkLoadErrorBoundary + 413 提示 作为体验兜底;413 需在网关/服务端调整上传上限。