第一次遇到线上白屏时,我正在回家的地铁上。测试群里有人发来一张全白截图,刷新有时恢复,有时仍然空白。我的第一反应是远程打开源码查最近改动,几分钟后才意识到:用户此刻不需要我证明根因,先恢复可用性更重要。

那次发布采用带 hash 的 JavaScript 文件。新 HTML 已经指向 app.9c31.js,一部分 CDN 节点仍缓存旧资源清单;与此同时发布脚本清理了上一版 app.71aa.js。拿到旧 HTML 的用户请求一个已经不存在的 chunk,入口脚本没有运行,页面自然什么也显示不出来。

白屏事故从错误上报、版本识别、回滚到缓存恢复的时序

图 1:事故处理中“恢复”和“定位”并行,但恢复动作不能擦掉版本与请求证据。

先用最小信息判断影响面

我后来给白屏准备了四个快速问题:

  1. 所有人都失败,还是特定地区、浏览器或缓存状态失败?
  2. HTML 是否能返回,入口 JS 是否 2xx?
  3. 错误发生在哪个 buildId,用户实际加载了哪些资源版本?
  4. 回滚应用就够了,还是还要处理 CDN/Service Worker 缓存?

当时用无痕窗口、手机网络和不同地区机器复现后,发现结果不一致,更像缓存层而不是代码逻辑。Network 面板里旧 chunk 返回 404,方向就清楚了。

回滚不是重新执行一次旧构建

早期发布脚本只保存代码 tag,回滚时重新安装依赖、重新构建。即使代码相同,依赖解析和构建环境也可能不同。那次之后,我们开始保存完整静态产物,用构建编号部署:

release-20180818-01/
  index.html
  manifest.json
  assets/app.71aa.js
  assets/vendor.c031.js
  build-meta.json

回滚只是把入口指回旧产物,不重新生成。旧 hash 资源至少保留多个发布周期,HTML 使用短缓存,hash 静态资源使用长期不可变缓存。

# HTML
Cache-Control: no-cache
 
# /assets/app.71aa.js
Cache-Control: public, max-age=31536000, immutable

这组缓存语义比“发布后刷新 CDN”可靠得多。不可变资源一旦发布就不覆盖、不删除,旧 HTML 才总能找到它引用的文件。

白屏之前要有最后一道自救

如果入口脚本根本没加载,应用内 Error Boundary 帮不上忙。我们在 HTML 里保留了很小的启动超时检查:应用成功挂载后设置标记;超过限定时间仍未挂载,就展示纯 HTML 恢复提示,并上报构建版本与资源状态。

<script>
  window.__APP_MOUNTED__ = false;
  setTimeout(function () {
    if (window.__APP_MOUNTED__) return;
    document.getElementById("boot-fallback").hidden = false;
    navigator.sendBeacon("/boot-error", JSON.stringify({
      buildId: document.documentElement.dataset.buildId,
      href: location.href,
    }));
  }, 8000);
</script>

这个兜底不尝试自动无限刷新。缓存错配时,反复刷新可能继续命中同一节点,也会把用户困在闪烁循环里。

发布检查必须从用户入口开始

过去我们验证“上传命令成功”和“服务器文件存在”。事故后增加了真正的冒烟:从公网域名请求 HTML,解析里面所有本地静态资源,逐个确认状态、内容类型和版本;再用无缓存浏览器打开关键路由。

门禁 失败时动作
HTML 引用的资源全部可达 禁止切流量
新旧两版资源能同时访问 禁止清理旧产物
首页与登录页能够挂载 自动回滚入口
白屏率和 chunk 404 未升高 停止扩量

人在事故里也需要明确分工

那晚最混乱的部分不是技术,而是三个人同时操作 CDN。后来值班流程明确一个指挥者、一个执行回滚、一个保留日志并持续报指标。任何缓存清理都在群里写清目标和时间,避免相互覆盖。

第一次线上白屏之后,我对“前端只是静态文件”的理解彻底变了。HTML、CDN、构建产物和浏览器缓存共同组成运行系统。页面恢复只是结束用户影响,能够解释为什么旧入口找不到旧资源,并把这个条件写进发布门禁,才算真正结案。