「页面打开是白的,但过一会儿自己好了」——这是我这两年被问得最多的一类问题,尤其在换了 CDN、加了 HTTPS、搬了机房之后集中出现。它的麻烦在于:用户明明看见了白屏,你打开却是好的;打开浏览器开发者工具再看一遍,也还是好的。
白屏不是一种原因,它是一类症状。真正排它的时候,最有效的做法不是猜,而是把「白屏」拆成几个可以分别验证的阶段,一段一段排除。这篇就讲这套拆法和它对应的具体改动。
一、先把白屏分成四种,别混在一起查
同样是「白」,成因完全不同,处理方式也完全不同:
- 网络层的白: HTML 根本没回来。表现为一直转圈最后超时,或者浏览器报 ERR_CONNECTION_TIMED_OUT / ERR_TOO_MANY_REDIRECTS。
- 服务端的白: HTML 回来了,但状态码是 5xx,或者返回了一个空的 200。用户看到的是浏览器自带的错误页或纯白。
- 渲染层的白: HTML 回来了、内容也在,但 CSS 没加载上(无样式的一堆文字)、或者整个内容区被 JS 接管后没渲染出来(白板)。
- 版本错位的白: HTML 是新的,但引用的 JS/CSS 是旧的(或反之),导致脚本报错、内容不渲染。这种最典型的表现就是「刷新一下就好了」。
区分它们只需要一个动作:打开开发者工具的 Network 面板,勾上 Disable cache,刷新。看三个数字——文档状态码、文档大小、以及第一个失败的资源是什么。这一步就定下了后面查的方向,比任何猜测都快。
顺便说一个诊断习惯:如果问题只在部分用户那里出现,第一件事是问「用的什么网络」。有些公司网关会做 HTTPS 中间人,有些国产浏览器有激进的「云加速」,它们对同一个站点的行为可以完全不同。
二、版本错位:最常见,也最容易修
这是「刷新一下就好」这个症状的头号原因。机制是这样的:你更新了站点,index.html 引用了 app.a1b2c3.js,但用户的浏览器(或者 CDN 的边缘节点)还缓存着旧版 index.html,里面引用的是 app.9f8e7d.js——那个文件已经被你删掉了。于是 HTML 是老版本、脚本 404、什么也不渲染,一片白。用户刷新一次,缓存过期,拿到新 HTML,就好了。
修法有两层,只做一层不够:
第一层:入口 HTML 不缓存。 让所有 text/html 的响应都带上「每次都要回源验证」的头:
location ~* \.html$ {
add_header Cache-Control "no-cache, must-revalidate" always;
add_header Pragma "no-cache" always;
expires -1;
}注意 no-cache 不是「不缓存」,而是「可以存,但每次用之前必须问一下服务器」。它保留了 304 协商的好处,同时保证用户不会拿到过期的入口页。千万别用 no-store,那会让每个页面都完整重传,流量翻好几倍。
第二层:静态资源带内容指纹,并长缓存。 文件名里带构建哈希(app.a1b2c3.js),就可以设成一年:
location ~* \.(js|css|woff2|png|jpg|svg)$ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
}immutable 这个关键字是给「用户按刷新」用的:加了它,浏览器在刷新时也不会去验证这个文件,直接从本地拿。因为文件名带哈希,内容不可能变,这样做是安全的,而且能省掉一大批 304 请求。
两层的配合关系是关键: 入口 HTML 每次协商,所以用户总能拿到最新的文件清单;静态资源内容不变就永远命中本地缓存。这样一来,发布新版本时用户只需要下载一个新的入口页和几个新文件,老文件不会白下载,也不会出现「HTML 新、JS 旧」的错位。
如果你用的是 CDN,还要多做一件事:发布时主动刷新 HTML 的缓存。很多 CDN 的默认规则是「HTML 也缓存几小时」,你在源站加了 no-cache 也没用,因为 CDN 不一定会遵循。发布脚本里加一步「按 URL 目录刷新」是最稳的,别指望平台自动感知。
三、JS 拆包之后的三种典型白板
现在的前端构建几乎都会做代码分割(路由懒加载、按需加载)。拆包之后白屏的形态变了,也更有欺骗性——因为网络请求全是 200,控制台却干干净净。
形态一:动态 import 的文件路径错了。 构建配置里的 publicPath / base 写成了 /,但站点部署在子目录 /blog/ 下。结果主包能加载,懒加载的 chunk 全部 404。因为 404 是「正常的网络结果」而不是脚本异常,控制台不一定报错,页面就一直空着。判据:Network 里筛选 JS,看有没有一堆 404,路径是不是以站点根开头。
形态二:chunk 加载失败但没有兜底。 网络抖动导致某个 chunk 下载失败,框架的懒加载 Promise reject,如果没有 catch,路由就永远停在「加载中」。正确做法是给路由级懒加载加一个重试,失败超过两次就整页跳转,强制拉新版本:
const lazy = (loader) => () => loader().catch(() => {
// 只重试一次,然后整页刷新,避免死循环
return new Promise((resolve) => setTimeout(resolve, 800))
.then(loader)
.catch(() => { window.location.reload(); });
});这里最容易犯的错是「无限重试」:网络彻底断了,页面就在重试里转圈,用户永远看不到任何提示。所以必须有次数上限,并且最后要给一个「网络不给力,点这里刷新」的可见提示,而不是继续白着。
形态三:首屏依赖一个慢接口,而渲染被它卡死。 很多 SPA 的写法是「等数据回来再挂载整个应用」。接口慢 5 秒,页面就白 5 秒。修法是「先挂骨架,再填数据」:应用先渲染出结构(导航、标题栏、占位块),数据回来再替换内容区。这不只是体验问题——Google 的 LCP 指标会把这个白屏时间算进去,白 5 秒意味着 LCP 5 秒,直接掉到「差」的那一档。
四、CSS 造成的白:不是没渲染,是看不见
有一类白屏,HTML 和 JS 全正常,但用户在屏幕上看不到东西。原因通常三种:
原因一:关键 CSS 加载失败。 为了首屏速度,很多人把 CSS 拆成「内联关键样式 + 异步加载完整样式」。异步那段如果失败,页面就一直停在「只有关键样式」的状态。如果关键样式里恰好有 body { color: #fff } 或者某个容器 opacity: 0(配合动画库的淡入),页面就是白的。判据: 在控制台执行 getComputedStyle(document.querySelector('h1')).color,如果是白色,问题就在 CSS 加载。
原因二:字体加载阻塞。 @font-face 如果配了 font-display: block,浏览器会在字体下载完成前把文字全部隐藏(最多 3 秒)。中文字体动辄 3-5MB,在弱网下就是白屏 3 秒。修法: 用 font-display: swap,先显示系统字体,字体到了再换。视觉上会有一次跳动,但比白屏好得多。更好的做法是中文字体做子集化,只保留下载页面用到的字符——一个页面通常只需要几百个汉字,子集化后能从 4MB 降到 30KB。
原因三:动画库的入场效果没触发。 用 IntersectionObserver 做滚动入场动画的站,如果观察器初始化失败(比如脚本报错、或者元素在视口外但容器高度算错),所有带 class="reveal" 的元素就会永远保持 opacity: 0。这类白屏的特点是「页面高度是对的,能滚动,但什么也看不到」。判据: 在 Elements 面板看某个元素的 class 里有没有 reveal 但缺 reveal-done 之类的完成标记。修法: 加一个兜底——CSS 里写成「默认可见,JS 加载成功后再加初始隐藏的 class」,而不是「默认隐藏,等 JS 来显示」。这个方向的选择,决定了 JS 挂掉时是白屏还是降级。
五、CDN 与 HTTPS 引入的白屏
这类问题在换 CDN、上 HTTPS 之后特别集中,因为它是「部分用户」的问题,你自己测不出来。
情况一:混合内容被拦。 站点上了 HTTPS,但页面里还有 http:// 的图片、脚本、字体。浏览器会拦掉它们,其中被拦的如果是主 JS,整页就白。修法是全站改成协议相对的写法已经不管用了(现代浏览器不再自动升级),必须写死 https,并且在 Nginx 上加 Content-Security-Policy: upgrade-insecure-requests,让浏览器自动把 http 资源升级成 https 请求。
情况二:CDN 缓存了一个错误页。 源站某次 502,CDN 把这个 502 页缓存了(很多 CDN 默认会缓存错误页几分钟到几小时),于是所有用户都拿到一个 502 的纯白页。这类问题的特征是「一段时间内 100% 复现,之后自己好了」。修法:在 CDN 配置里排除 5xx 的缓存,或者把错误页的缓存时间设成 0。
情况三:重定向循环。 上 HTTPS 时如果 Nginx 的 X-Forwarded-Proto 没配好(CDN 已经终止了 TLS,回源是 http),应用会认为当前是 http,于是跳转到 https;CDN 转发到源站又看到 http,再次跳转……浏览器在 20 次跳转后放弃,显示 ERR_TOO_MANY_REDIRECTS。修法是在 Nginx 上信任 CDN 的转发头:
set_real_ip_from 10.0.0.0/8;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# 判断真实协议,供 PHP 使用
map $http_x_forwarded_proto $real_scheme {
default $scheme;
https https;
}
fastcgi_param HTTPS $real_scheme if_not_empty;情况四:HSTS 把你锁死了。 一旦给域名下发了带长 max-age 的 HSTS 头,所有用户在未来几个月内都会被强制走 https,即使你回退到 http 也没用。这意味着换证书出错的时候,你连「临时退回 http」这条退路都没有。所以 my 建议是:第一次上 HSTS 时用 max-age=300 跑两周,确认一切稳定再逐步加长。别一上来就 max-age=63072000; includeSubDomains; preload——那是往 preload 列表里提交之后才有意义的东西,提交了就很难撤回。
六、把「白屏」变成可观测的数字
前面都是修,最后一节讲怎么让它以后更容易被发现。因为白屏最坑的地方不是修不完,而是你根本不知道它在发生。
我在每个页面的 <head> 里放了一小段脚本,它做三件事:记录开始时间、监听全局 JS 错误、在 DOMContentLoaded 之后检查内容区是否有实质文本。如果两秒后内容区仍然是空的,就发一条记录到统计接口:
<script>
(function () {
var t0 = Date.now(), sent = false;
function report(reason) {
if (sent) return; sent = true;
var u = '/stat/blank?r=' + encodeURIComponent(reason)
+ '&t=' + (Date.now() - t0) + '&u=' + encodeURIComponent(location.pathname);
if (navigator.sendBeacon) navigator.sendBeacon(u); else new Image().src = u;
}
window.addEventListener('error', function (e) {
if (e && e.target && e.target.tagName) report('res:' + e.target.tagName);
else report('js');
}, true);
setTimeout(function () {
var el = document.getElementById('main') || document.body;
var txt = (el.innerText || '').trim();
if (txt.length < 20) report('empty');
}, 2000);
})();
</script>几个实现要点:用 sendBeacon 而不是 fetch,因为它在页面卸载时也能可靠发出;加上 sent 标志,避免一次页面触发几十条上报;错误监听要用捕获阶段(第三个参数 true),否则资源加载错误(<script src> 404)不会冒泡到 window,你永远收不到。别在每篇页面都做一个 1 像素的「心跳」,那是另一回事,这里只统计「首次渲染有没有内容」。
有了这个上报之后,白屏从「用户口头抱怨」变成了「每天几条记录,带原因码」。原因码一看就知道方向:js 是脚本异常、res:SCRIPT 是主脚本加载失败、empty 是渲染没出来。第一次部署后我就发现了一批「只有微信内置浏览器才报 empty」的记录,原因是那种浏览器不执行 > 版本的语法,而我在某个代码块里用了一个新特性。这个问题靠人工测根本发现不了。
七、排查顺序总结成一张单子
- Network 面板 + 禁用缓存刷新,看文档状态码和大小。
- 文档正常 → 看第一个失败资源;文档不正常 → 直接查 Nginx error_log 和 PHP-FPM 日志。
- 资源 404 → 查发布是否清理了旧 hash 文件、CDN 是否缓存了旧 HTML。
- 资源 200 但页面还是白 → 看 Console 有没有报错,没有就查 CSS(颜色、opacity、font-display)。
- 只有部分用户 → 查 HTTPS 混合内容、CDN 错误页缓存、重定向循环。
- 刷新就好 → 100% 是入口 HTML 缓存问题,检查 Cache-Control 有没有被 CDN 覆盖。
- 修完之后补上「空内容上报」,下次不用再靠用户反馈。
八、小结
白屏排查真正的门槛不在技术,而在分类:网络层、服务端、渲染层、版本错位,四类各有各的判据,混在一起查只会越查越乱。而所有修法里,收益最高的其实是第一条——入口 HTML 每次协商、静态资源带哈希长缓存。这一条配好之后,「刷新一下就好」这类报障会直接少掉一大半,剩下的都是真正的代码问题,反而好处理。
最后一句实在话:不要试图做到「永远不白屏」。目标应该是「白屏时用户能看见一句话,而不是一片白」。一个带「加载失败,点此刷新」的兜底页,比任何精妙的预加载策略都更值得先做。