发版五分钟后,监控开始报错,客服收到零星反馈:页面白屏。刷新一下就好了,但过一会儿又有人中招。
控制台只有一行 Uncaught ChunkLoadError: Loading chunk 37 failed。更麻烦的是,错误监控里还原出来的源码位置在一段毫不相干的代码上——因为 Source Map 和线上 JS 不是同一次构建。三件事叠在一起,排查绕了远路。
现象:白屏的四种报错长相#
| 报错 | 常见原因 |
|---|---|
ChunkLoadError: Loading chunk N failed |
chunk 文件 404,或 CDN 尚未同步 |
Cannot read properties of undefined (reading 'call') |
新旧 chunk 混用,模块 ID 对不上 |
Unexpected token '<' |
请求 JS 拿到了 HTML(404 页面) |
| 完全空白且无报错 | 入口 JS 未执行、CSP 拦截、或渲染时抛错被吞 |
Unexpected token '<' 这条特别有指示性:说明请求的路径返回了 index.html,通常是路由回退把所有 404 都兜成了首页。
白屏的表现也不止一种:整页空白、只剩导航壳、某个区块一直转圈。整页空白多半是入口 chunk 失败,连框架都没起来;只剩壳说明主包加载成功,失败的是路由对应的异步 chunk。先分清是哪一种,能直接把范围缩到一个文件。
定位:先把 HTML 与静态资源的缓存策略分开看#
# 首页 HTML 的缓存头
curl -sI https://example.com/ | grep -iE 'cache-control|etag|age|last-modified'
# 带 hash 的静态资源
curl -sI https://example.com/assets/index.a1b2c3.js | grep -iE 'cache-control|etag'
# 旧版本 chunk 还在不在
curl -so /dev/null -w '%{http_code}\n' https://example.com/assets/old-hash.js
如果 HTML 带 max-age=600 且中间有 CDN,那么发版后十分钟内,一部分浏览器仍在用旧 HTML,里面引用的 chunk 名字已经被新版本删掉了。这就是「刷新就好、过一会儿又坏」的来源——不同边缘节点的缓存状态不一致。
判断响应到底是 JS 还是 HTML,看头比看内容快:URL 以 .js 结尾、响应却是 content-type: text/html,基本可以确认路由回退把 404 兜成了首页。再对照状态码,200 的 HTML 比 404 更麻烦,因为浏览器和 CDN 都会把它当正常资源缓存下来。
再确认 Source Map 是否与线上产物同源:
# 线上 JS 末尾一般有 sourceMappingURL 注释,指到哪个 hash 就是哪次构建
curl -s https://example.com/assets/index.a1b2c3.js | tail -c 200
# 本地构建产物的 map 文件名必须与线上 hash 完全一致
ls dist/assets/*.map | head
两者对不上,说明你手里的 map 是另一次构建的。用它还原堆栈,行列号会指向错误的源码位置,越查越偏。
| 检查项 | 期望 | 不期望 |
|---|---|---|
HTML Cache-Control |
no-cache 或 max-age=0, must-revalidate |
max-age=600 且无 CDN 刷新 |
静态资源 Cache-Control |
public, max-age=31536000, immutable |
no-cache(失去缓存意义) |
| 旧 chunk 保留 | 至少保留 N-1 个版本 | 发版即删 |
.map 公开访问 |
404 | 200(源码泄漏) |
一个低成本验证:直接让浏览器打开旧 HTML 里引用的 chunk URL。打不开就是 404,打得开但内容是一段 HTML,就是回退规则写错了。这两种情况的修法完全不同。
根因:三个版本同时在线上#
第一层:HTML 被缓存。 浏览器或 CDN 存了旧 index.html,它引用的 chunk 名属于上一版。新版本部署后旧 chunk 被删除,浏览器请求 404,动态导入失败,ChunkLoadError 抛出,页面停在白屏。
第二层:发布没有保留历史产物。 每次构建清空 dist 再上传,CDN 又做了目录同步删除。任何一个还持有旧 HTML 的客户端都会踩空。灰度期间两个版本并存时,这个问题会被放大。
第三层:Source Map 与 release 没有绑定。 构建产物的 hash 每次变,但 map 是按「最新一次构建」上传的。错误上报里只带了 release 字符串,没有带构建号;还原时用的是最新 map,位置自然对不上。
再加一个常见放大器:Service Worker 缓存了旧 index.html,且没有 skipWaiting。用户不关掉所有标签页,就永远拿不到新版本。
发布顺序也会制造这个窗口:先传静态资源、再刷 CDN、最后更新 HTML。顺序反了,就会出现新 HTML 指向尚未上传的 chunk,症状与缓存问题一模一样。
还有一种隐蔽情况:响应头里根本没写 Cache-Control。这时浏览器会做启发式缓存,用 Last-Modified 推算一个缓存时长,看起来「什么都没配」,实际 HTML 被缓存了几分钟到几小时不等。不配置不等于不缓存。
修复:让 HTML 不缓存,让产物可追溯#
缓存策略按资源类型分开设置,这是最有效的一刀:
# 入口 HTML:每次都回源校验
location = /index.html {
add_header Cache-Control "no-cache, must-revalidate";
}
# 带 contenthash 的静态资源:永久缓存
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404; # 找不到就 404,别回退到 index.html
}
try_files ... =404 这一行值得单独说:SPA 的路由回退只应该作用于页面导航,不应该作用于 /assets/ 下的 JS。否则 chunk 缺失时浏览器会拿到 HTML,报出更难懂的 Unexpected token '<'。
发布侧保留最近两到三个版本的 chunk,或者让文件名带构建号、旧目录延迟清理。同时给动态导入加一次兜底刷新:
// chunk 加载失败时自动刷新一次,用时间戳防死循环
window.addEventListener('error', (e) => {
const msg = String(e.message || '');
if (!/ChunkLoadError|Loading chunk/.test(msg)) return;
const key = 'chunk-reload-at';
const last = Number(sessionStorage.getItem(key) || 0);
if (Date.now() - last < 60_000) return; // 一分钟内不重复刷新
sessionStorage.setItem(key, String(Date.now()));
location.reload();
}, true);
Source Map 侧用 hidden-source-map,构建后上传到错误监控平台并带上构建号,不放进公开目录。每次发布同时归档一份「构建号 — 产物 hash — map」的清单,出问题时按 release 精确匹配。
Service Worker 的更新要显式做三步:新版本 install 后 skipWaiting,activate 时清掉旧缓存,再 clients.claim。少做一步,用户可能几天都停在旧版本,而服务端已经找不到对应的 chunk。
防复发#
- 发布后用无头浏览器打开首页,断言关键 DOM 存在、控制台无 error,持续观察几分钟。
- 把构建号写进页面(如
<meta name="build">),错误上报时一起带上,避免「release 对、构建不对」。 - 监控按构建号分组,同一个构建号的
ChunkLoadError突增就告警。 - 固定发布顺序:静态资源 → CDN 刷新 → HTML,并写进发布脚本,而不是靠人记。
- 灰度环境演练一次「删掉一个线上 chunk」,确认兜底刷新和降级页真的生效。
- Service Worker 更新策略写进 checklist:
skipWaiting、clients.claim、旧缓存清理。 - 回滚时同样保留新版本产物,否则回滚会制造一次反向的「旧 HTML 找新 chunk」。
- 把
.map的访问权限纳入安全扫描,别让构建产物把源码带到公网。
结论#
白屏先看报错类型:ChunkLoadError 是产物缺失,Unexpected token '<' 是路由把 404 兜成了 HTML,无报错则往 CSP 和入口脚本查。
根因往往是版本错配:旧 HTML 遇到新产物。修复的关键是三条——HTML 不缓存、/assets/ 不回退到首页、旧 chunk 保留若干版本。
Source Map 必须和构建号绑定,否则它会把排查带偏。最后加一层 ChunkLoadError 的有限次自动刷新兜底,让用户不必手动按 F5。
衡量是否做对了,看一个指标就够:发版后 ChunkLoadError 在错误监控里是否还按构建号聚集。如果还聚集,说明旧 HTML 仍在流通。
完