Skip to content

白屏优化

白屏时间的定义:从用户输入 URL(或点击链接)到浏览器完成首次绘制(First Paint)的间隔。这段时间内用户看到的是空白屏幕——优化它的本质是缩短"从发起请求到像素可见"的关键路径。

优化的四个层面按影响权重排列:

  1. 网络层:减少资源到达浏览器的耗时(DNS、TCP、TLS、CDN、压缩)
  2. 资源层:减少首屏需要加载的代码量(内联、分包、Tree Shaking)
  3. 渲染层:让浏览器更快产生产出首帧像素(SSR、SSG、骨架屏)
  4. 构建层:在打包阶段就削减体积(压缩、混淆、现代化语法)

一、网络层

DNS 预解析(dns-prefetch)

DNS 解析的延迟通常在 20-120ms。对于第三方域名(CDN、字体、分析SDK),这个延迟发生在关键路径上。

html
<!-- 在 <head> 中提前声明,浏览器在解析 HTML 时立即发起 DNS 查询 -->
<link rel="dns-prefetch" href="//cdn.jsdelivr.net" />
<link rel="dns-prefetch" href="//fonts.googleapis.com" />

dns-prefetch 只做 DNS 查询,不建立连接。浏览器在解析到 <link rel="dns-prefetch"> 时立即发起 DNS 查询,而不是等到遇到真正的资源 URL。DNS 查询结果被缓存在浏览器中(通常跟随 OS DNS 缓存 TTL),后续真正的资源请求直接使用缓存结果。

TCP/TLS 预连接(preconnect)

preconnectdns-prefetch 的超集——它不仅做 DNS 解析,还完成 TCP 三次握手和 TLS 握手(HTTPS):

html
<link rel="preconnect" href="https://api.example.com" />

preconnect 的开销比 dns-prefetch 大(TCP+TLS 握手消耗服务端连接资源),只在确定会用到该域名时使用。对于可能用到的域名(如用户可能点击的链接),用 dns-prefetch

判断准则:如果 10 秒内一定会请求该域名 → preconnect;可能用到但不确定 → dns-prefetch

CDN 加速

CDN 将静态资源缓存在全球边缘节点上。用户请求被 DNS 智能解析到最近的节点:

text
用户 → 智能 DNS(基于 IP 定位)→ 最近 CDN 边缘节点
  ├── 命中缓存 → 直接返回
  └── 未命中 → 回源到源站 → 缓存 → 返回

关键配置:

  • 静态资源(JS/CSS/图片/字体)通过 CDN 分发,设置长时间的 Cache-Control: max-age=31536000, immutable
  • HTML 文档通常不缓存或短缓存(max-age=0, must-revalidate),保证内容更新即时生效
  • 使用内容哈希命名(app.a1b2c3d.js)实现缓存失效,而非依赖 Cache-Control 过期

Gzip / Brotli 压缩

Gzip 对文本资源的压缩率通常在 60-80%。现代浏览器也支持 Brotli(br),压缩率比 Gzip 高 15-25%,但压缩速度更慢——适合构建时预压缩静态资源。

Nginx 中 Gzip 的核心配置:

nginx
gzip on;
gzip_comp_level 6;           # 1-9,6 是压缩率与 CPU 的最佳平衡点
gzip_min_length 1000;        # 小于 1KB 的资源不值得压缩(压缩开销 > 传输节省)
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_vary on;                # 发送 Vary: Accept-Encoding 头,告诉 CDN 区分压缩/未压缩缓存
gzip_proxied any;            # 对代理/CDN 的响应也压缩

gzip_comp_level 6 是工程中的常用选择——1-4 压缩率提升明显而 CPU 开销增加有限,5-6 压缩率进入平台期,7-9 CPU 开销陡增但压缩率提升不到 5%。

图片懒加载

html
<!-- 浏览器原生支持,无需 JS -->
<img src="photo.jpg" loading="lazy" alt="" />

loading="lazy" 在 Chrome 77+、Firefox 75+ 中原生支持。浏览器在图片进入视口的一定距离(通常 500-1250px,取决于网络状况)时才开始加载。对于需要更复杂加载逻辑的场景(如占位符、加载动画),使用 IntersectionObserver

js
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      observer.unobserve(img);
    }
  });
});
document.querySelectorAll('img[data-src]').forEach(img => observer.observe(img));

二、资源层

首屏关键 CSS 内联

外部 CSS(<link rel="stylesheet">)阻塞渲染——浏览器必须等待 CSS 下载并解析为 CSSOM 后才会开始 Layout。将首屏所需的关键 CSS 内联到 <head><style> 标签中,消除这次网络往返:

html
<head>
  <style>
    /* 首屏关键样式:布局、字体、首屏可见元素的样式 */
    /* 通常 14KB 以内(一个 TCP 初始窗口的大小) */
  </style>
  <!-- 非关键 CSS 异步加载 -->
  <link rel="preload" href="/styles/full.css" as="style" onload="this.rel='stylesheet'" />
</head>

rel="preload" + onload 切换 rel 的模式让 CSS 不阻塞渲染——浏览器以高优先级下载 CSS,但不在下载期间阻塞渲染,下载完成后通过修改 rel 属性应用样式。这是目前最可靠的非阻塞 CSS 加载方式。

代码分包

Vite/Webpack 的分包策略直接影响首屏加载的 JS 体积。不配置分包时,所有代码打入一个 bundle——首屏加载了整站代码。

Vite(基于 Rollup)的分包配置:

ts
// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor-react': ['react', 'react-dom'],
          'vendor-ui': ['antd'],
          'vendor-utils': ['lodash-es', 'dayjs'],
        }
      }
    }
  }
});

Webpack 的 splitChunks 配置:

js
optimization: {
  splitChunks: {
    chunks: 'all',              // 对同步和异步 chunk 都拆分
    maxInitialRequests: 25,     // 入口的最大并行请求数
    cacheGroups: {
      vendors: {
        test: /[\\/]node_modules[\\/]/,
        priority: -10,
        reuseExistingChunk: true,
      },
      common: {
        minChunks: 2,           // 至少被 2 个 chunk 引用才拆分
        priority: -20,
        reuseExistingChunk: true,
      },
    },
  },
}

路由懒加载是代码分包的最有效手段——每个路由对应的组件仅在访问时加载:

js
// Vue
const Home = () => import('@/views/Home.vue');
// React
const Home = lazy(() => import('@/pages/Home'));

Tree Shaking

Tree Shaking 依赖 ES Module 的静态导入语法(import / export)。CommonJS 的 require() 是动态的,无法在构建时确定哪些代码被使用。

Tree Shaking 生效的前提:

  1. 使用 ES Module(import / export
  2. package.json 中设置 "sideEffects": false(或列出有副作用的文件)
  3. 使用生产模式构建(mode: 'production'

Vite/Webpack 默认开启 Tree Shaking,无需手动配置。

HTTP/2 多路复用

HTTP/2 的核心改进:一个 TCP 连接上可以同时传输多个请求/响应,消除 HTTP/1.1 的队头阻塞(同一连接一个请求一个响应,后续必须排队)。

Nginx 开启 HTTP/2:

nginx
listen 443 ssl http2;

关键权衡:HTTP/2 所有请求共享一个 TCP 连接,一旦丢包所有请求都受影响。 在弱网环境下(丢包率 > 2%),HTTP/2 的表现可能不如 HTTP/1.1 的多连接并行策略。对国际化产品,需要按地区测试 HTTP/2 的实际收益。

http2 指令需要 Nginx 1.9.5+,且必须配合 HTTPS(浏览器默认仅在 TLS 上启用 HTTP/2)。


三、渲染层

SSR(服务端渲染)

SSR 在服务端执行组件代码,生成完整 HTML 返回给浏览器。浏览器收到 HTML 后立即可渲染——不需要等 JS 下载和执行:

指标CSRSSR
FCP(首次内容绘制)需等 JS 执行HTML 到达即可绘制
TTI(可交互时间)JS 执行完即可交互需额外 hydrate
SEO❌ 爬虫看到空壳✅ HTML 中有完整内容
服务器压力低(静态文件)高(每次请求执行组件)

Vue 项目用 Nuxt,React 项目用 Next.js,都有内置 SSR 支持。

SSG(静态站点生成)

对内容不频繁变化的页面(博客、文档、营销站),SSG 在构建时预渲染为静态 HTML。访问时直接返回预渲染的 HTML——比 SSR 更快,且不需要 Node 服务器:

框架SSG 命令
Next.jsnext export / getStaticProps
Nuxtnuxt generate
VitePressvitepress build

骨架屏(Skeleton Screen)

骨架屏不是减少白屏时间,而是改变用户的感知——用一个占位形状(灰色块 + 动画)"暗示"内容正在加载:

  • 用户看到一个骨架屏 > 看到白屏(感知上更快)
  • 骨架屏的渲染开销极低(静态 DOM + CSS)
  • 注意:骨架屏不应触发 Layout(使用 position: absolutecontent-visibility: auto

预渲染

对 SPA 的首屏做预渲染,在构建时生成静态 HTML。与 SSG 的区别:预渲染通常只针对少数几个路由(首页、落地页),其余路由保持 SPA 模式。适合不需要全站 SSR/SSG 但首屏需要 SEO 的场景。

延迟 Hydrate

SSR 页面 hydration 时不需要一次性激活所有组件。非首屏组件可以延迟 hydrate:

  • 使用 requestIdleCallback 调度非关键组件的 hydration
  • 只在组件进入视口时 hydrate(类似懒加载的逻辑)
  • 对纯静态内容(如页脚),可以不 hydrate——它们不需要交互

四、构建层

压缩与混淆

  • Terser(Webpack 默认):移除注释、缩短变量名、删除 dead code
  • esbuild(Vite 默认):比 Terser 快 20-40 倍,压缩率接近
  • CSSNano:CSS 压缩(移除空白、合并规则、缩短颜色值)

现代 JS 语法

使用 type="module"<script> 标签,配合 <script nomodule> 作为降级:

html
<script type="module" src="/app.es2020.js"></script>
<script nomodule src="/app.legacy.js"></script>

支持 ES Module 的现代浏览器加载更小更快的现代语法版本,旧浏览器加载转译后的 legacy 版本。Vite 的 @vitejs/plugin-legacy 自动生成这套双版本方案。


优化效果的衡量

不要凭感觉判断白屏是否变快。用数据:

指标含义目标
FP(First Paint)首次绘制(任何像素)< 1s
FCP(First Contentful Paint)首次内容绘制< 1.8s
LCP(Largest Contentful Paint)最大内容绘制< 2.5s
TTI(Time to Interactive)可交互时间< 3.8s
TBT(Total Blocking Time)主线程阻塞总时长< 300ms

Chrome DevTools → Lighthouse 可以直接测这些指标。线上则通常配合 Web Vitals 库(web-vitals npm 包)做 RUM(Real User Monitoring)采集。