文章 / 前端

首屏白屏那一刻,浏览器到底在忙什么

上个月接了个后台系统的活,首页打开要两三秒才有内容,白屏期间连个 loading 都看不见。用户骂得挺难听,但代码里其实没写什么重逻辑。

问题不在 JS 慢,在于我把该并行的东西排成了一队。

先把流程摊开

浏览器从拿到 HTML 到画出第一帧,大致是这么一条链子:

  1. 解析 HTML,边解析边生成 DOM 树
  2. 解析 CSS,生成 CSSOM 树(这一步会阻塞渲染)
  3. 遇到同步 <script>,停下 HTML 解析,等 JS 下载并执行完
  4. DOM 与 CSSOM 合并成渲染树
  5. 布局(Layout):算出每个盒子在哪、多大
  6. 绘制(Paint)与合成(Composite)

第 3 步是罪魁祸首。我把统计图表库、日期组件、埋点 SDK 三个同步脚本放在了 <head> 里,浏览器只能老老实实排队等它们下载。

<!-- 改之前:三份 JS 串行下载,首屏被吊死 -->
<head>
  <script src="/vendor/charts.js"></script>
  <script src="/vendor/datepicker.js"></script>
  <script src="/vendor/analytics.js"></script>
</head>

第一步:把不着急的脚本挪走

<script> 加上 defer,下载变成并行,执行顺序还在文档之后,DOM 也准备好了。埋点这种完全不影响渲染的,直接换成异步加载。

<head>
  <!-- 关键逻辑用 defer:并行下载,DOM 解析完后按序执行 -->
  <script src="/vendor/charts.js" defer></script>
  <script src="/vendor/datepicker.js" defer></script>

  <!-- 埋点不影响首屏,干脆异步 -->
  <script src="/vendor/analytics.js" async></script>
</head>

这里有个容易踩的坑:async 的执行时机不确定,脚本之间如果互相依赖就会偶发报错。所以要么只用 defer,要么确认脚本之间真的没有依赖。

第二步:CSS 也要拆

CSS 是渲染阻塞资源,整站样式表越大,白屏越久。但有意思的是,真正撑起首屏的样式往往只占一小部分。我的做法是把首屏需要的布局、颜色、字体规则抽出来内联到 <head>,剩下的用 preload 加延迟加载。

<head>
  <style>
    /* 首屏关键 CSS:容器宽度、骨架屏、字体、主题色 */
    :root { --brand: #4f46e5; }
    body { margin: 0; font-family: system-ui, sans-serif; }
    .shell { max-width: 960px; margin: 0 auto; padding: 24px; }
    .skeleton { height: 14px; border-radius: 6px; background: #eceaf0; }
  </style>

  <link rel="preload" href="/assets/app.css" as="style" onload="this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="/assets/app.css"></noscript>
</head>

顺序很关键:先给用户一个结构正确的骨架屏,细节样式慢一点没关系。用户的感知是"页面出来了",而不是"页面白着"。

第三步:别让图片把布局顶来顶去

图片没有宽高,浏览器第一次布局时按 0 高度算,加载完再推下去,整页内容跳动。这个指标叫 CLS,用户感受就是"我点错地方了"。

显式写上宽高,再用 CSS 让它自适应:

.thumb {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  background: #f3f1ee;
}

aspect-ratio 比写死 padding-top: 56.25% 那套 hack 干净太多,现代浏览器都支持,可以放心用。

实测数字

同一台机器、同一个网络环境,用 Chrome 的 Performance 面板跑三次取中位数:

指标改之前改之后
FCP2.4s0.9s
LCP3.1s1.4s
CLS0.210.02

改动量其实很小:三个 defer、一段内联 CSS、几处图片宽高。真正花时间的是定位,而不是修改。

可以照抄的清单

  • 首屏之外的一切都往后放:defer、动态 import()、loading="lazy"
  • 关键 CSS 内联,其余 CSS 用 preload 延迟应用
  • 所有 <img> 都写死 width / height 或 aspect-ratio
  • 字体加 font-display: swap,避免文字隐形
  • 用 preconnect 提前建连 CDN 域名
  • 上线后用真实用户数据(RUM)验证,别只信本地 Lighthouse

不折腾架构、不引入新框架,就把白屏从两秒多压到一秒以内。性能优化很多时候就是这样,找到那个串行的点,把它拆开。