白屏优化
什么是白屏?
白屏是指用户在浏览器中打开页面后,屏幕长时间停留在空白状态,没有任何有意义的内容展示。这是前端性能中用户感知最明显的问题之一 —— 用户点击了链接,却只看到一片空白,体验极差。
白屏时间的定义
白屏时间(First Paint / First Contentful Paint)是指从用户发起页面请求到浏览器渲染出第一个有意义内容之间的时间差。
白屏时间 = 首次内容绘制时间(FCP) - 页面请求时间
Google 建议 FCP 应控制在 1.8 秒以内,超过 3 秒则被认为是差体验。
白屏产生的原因
要优化白屏,首先需要理解它的成因。以下是导致白屏的主要环节:
1. 网络层面
| 原因 | 说明 |
|---|---|
| DNS 解析慢 | 域名解析耗时,特别是首次访问 |
| TCP/TLS 握手 | 建立连接需要多次往返 |
| 服务器响应慢 | TTFB(首字节时间)过长 |
| 带宽限制 | 弱网环境下资源传输缓慢 |
2. 资源层面
- JS/CSS 打包体积过大:单个 bundle 几 MB,下载耗时长
- 资源未压缩:没有启用 Gzip/Brotli 压缩
- 图片未优化:首屏大图未做懒加载或格式优化
- 过多阻塞资源:
<head>中大量同步 CSS/JS
3. 渲染层面
- CSS 阻塞渲染:浏览器必须等所有 CSS 加载完才开始渲染
- JS 阻塞 DOM 解析:同步
<script>标签会阻断 HTML 解析 - 关键渲染路径过长:DOM → CSSOM → Render Tree → Layout → Paint 的链路过长
4. 代码层面(SPA 特有问题)
现代 SPA(如 React/Vue)页面的 HTML 通常只有一个空的 <div id="app"></div>,所有内容都依赖 JS 执行后才能渲染。这意味着:
<!-- 典型 SPA 的 HTML -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/css/app.abc123.css">
</head>
<body>
<div id="app"></div> <!-- 空的! -->
<script src="/js/chunk-vendors.def456.js"></script>
<script src="/js/app.ghi789.js"></script>
</body>
</html>
在 JS 下载、解析、执行完毕之前,用户看到的就是白屏。
优化策略
一、网络层优化
1.1 DNS 预解析
对于需要加载第三方资源的域名,提前进行 DNS 解析:
<!-- DNS 预解析 -->
<link rel="dns-prefetch" href="//cdn.example.com">
<link rel="dns-prefetch" href="//api.example.com">
<link rel="dns-prefetch" href="//fonts.googleapis.com">
1.2 预连接(Preconnect)
比 DNS 预解析更进一步,提前完成 DNS + TCP + TLS:
<!-- 预连接:DNS + TCP + TLS 一步到位 -->
<link rel="preconnect" href="https://cdn.example.com">
<link rel="preconnect" href="https://api.example.com" crossorigin>
preconnect 不宜滥用,建议只对关键的 2-3 个域名使用,否则反而浪费连接资源。
1.3 使用 CDN
将静态资源部署到 CDN 节点,缩短资源传输的物理距离:
1.4 开启 HTTP/2 或 HTTP/3
HTTP/2 支持多路复用,可以在同一个 TCP 连接上并行传输多个资源,消除了 HTTP/1.1 的队头阻塞问题。
# Nginx 配置示例
server {
listen 443 ssl http2;
# ...
}
二、资源加载优化
2.1 关键资源预加载(Preload)
提前加载当前页面必需的关键资源:
<!-- 预加载关键 CSS -->
<link rel="preload" href="/css/critical.css" as="style">
<!-- 预加载关键字体 -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<!-- 预加载关键 JS -->
<link rel="preload" href="/js/app.js" as="script">
2.2 非关键资源延迟加载(Prefetch)
对于下一页可能需要的资源,使用 prefetch 在空闲时预取:
<!-- 预取下一页资源 -->
<link rel="prefetch" href="/js/about-page.js">
<link rel="prefetch" href="/css/about-page.css">
2.3 JS 异步加载
避免 JS 阻塞 HTML 解析:
<!-- ❌ 同步加载 —— 阻塞 HTML 解析 -->
<script src="/js/app.js"></script>
<!-- ✅ async —— 下载不阻塞,下载完立即执行 -->
<script async src="/js/analytics.js"></script>
<!-- ✅ defer —— 下载不阻塞,HTML 解析完后按顺序执行 -->
<script defer src="/js/app.js"></script>
三者的区别:
- 入口脚本用
defer(保证执行顺序) - 独立的统计/分析脚本用
async - 关键渲染无关的脚本放到
</body>前
2.4 开启资源压缩
# Nginx Gzip 配置
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 1024;
gzip_comp_level 5;
# Brotli 压缩(效果更好,需要安装模块)
brotli on;
brotli_types text/plain text/css application/json application/javascript;
| 压缩方式 | 压缩率 | 兼容性 | 推荐场景 |
|---|---|---|---|
| Gzip | 较好 | 全部浏览器 | 通用方案 |
| Brotli | 更优(比 Gzip 小 15-25%) | 现代浏览器 | 优先使用 |
三、代码层优化
3.1 代码分割(Code Splitting)
避免将所有代码打包成一个巨大的 bundle,按路由或功能拆分:
React 路由懒加载:
import { lazy, Suspense } from 'react';
// ❌ 同步导入 —— 全部打包进主 bundle
import Home from './pages/Home';
import About from './pages/About';
import Dashboard from './pages/Dashboard';
// ✅ 懒加载 —— 按需加载各页面
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
);
}
Vue 路由懒加载:
const routes = [
{
path: '/',
component: () => import('./views/Home.vue'),
},
{
path: '/about',
component: () => import('./views/About.vue'),
},
];
3.2 Tree Shaking
确保构建工具能正确移除未使用的代码:
// ❌ 导入整个库
import _ from 'lodash';
_.get(obj, 'a.b.c');
// ✅ 按需导入
import get from 'lodash/get';
get(obj, 'a.b.c');
// ✅ 或使用支持 Tree Shaking 的替代库
import { get } from 'lodash-es';
3.3 第三方库优化
// webpack-bundle-analyzer 分析打包体积
// package.json
{
"scripts": {
"analyze": "webpack --profile --json > stats.json && webpack-bundle-analyzer stats.json"
}
}
常见优化手段:
| 策略 | 示例 |
|---|---|
| 替换为轻量库 | moment.js → dayjs(280KB → 2KB) |
| 按需加载 UI 库 | antd 使用 babel-plugin-import 按需引入 |
| 外置大型库 | 通过 CDN 引入 react/vue,不打包进 bundle |
四、渲染层优化
4.1 骨架屏(Skeleton Screen)
在内容加载完成前,展示页面结构的占位图形,给用户"正在加载"的视觉反馈:
<!-- 在 HTML 中直接内联骨架屏 -->
<div id="app">
<!-- JS 加载前用户看到的是骨架屏,而非白屏 -->
<div class="skeleton">
<div class="skeleton-header"></div>
<div class="skeleton-content">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
<div class="skeleton-line"></div>
</div>
</div>
</div>
.skeleton-line {
height: 16px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
margin-bottom: 12px;
}
.skeleton-line.short {
width: 60%;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
骨架屏优于简单的 Loading spinner,因为它模拟了页面的真实布局,让用户对即将展示的内容有预期,减少感知等待时间。
4.2 内联关键 CSS(Critical CSS)
将首屏渲染必需的 CSS 内联到 HTML 中,避免额外的网络请求:
<head>
<!-- 内联关键 CSS:首屏立即可用 -->
<style>
body { margin: 0; font-family: sans-serif; }
.header { height: 60px; background: #fff; box-shadow: 0 2px 4px rgba(0,0,0,.1); }
.hero { padding: 40px 20px; text-align: center; }
</style>
<!-- 非关键 CSS 异步加载 -->
<link rel="preload" href="/css/full.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/full.css"></noscript>
</head>
可以使用工具自动提取关键 CSS:
# 使用 critical 工具
npx critical index.html --inline --base dist/
4.3 服务端渲染(SSR)
SSR 是解决 SPA 白屏问题最根本的方案。服务器直接返回渲染好的 HTML,用户无需等待 JS 执行即可看到内容:
| 方案 | 白屏表现 | 适用场景 |
|---|---|---|
| CSR | 白屏时间长 | 后台管理系统等对首屏要求不高的场景 |
| SSR | 白屏时间短 | 面向用户的页面、需要 SEO 的页面 |
| SSG | 几乎无白屏 | 博客、文档等内容不频繁变化的页面 |
Next.js SSR 示例:
// pages/index.js
export async function getServerSideProps() {
const data = await fetch('https://api.example.com/data');
return { props: { data: await data.json() } };
}
export default function Home({ data }) {
return <div>{data.title}</div>;
}
Nuxt.js SSR 示例:
<!-- pages/index.vue -->
<script setup>
const { data } = await useFetch('/api/data');
</script>
<template>
<div>{{ data.title }}</div>
</template>
4.4 预渲染(Prerender)
对于不需要动态数据的页面,可以在构建阶段直接生成静态 HTML:
// vite.config.js
import { defineConfig } from 'vite';
import prerender from 'vite-plugin-prerender';
export default defineConfig({
plugins: [
prerender({
routes: ['/', '/about', '/contact'],
}),
],
});
五、缓存策略
5.1 强缓存 + 协商缓存
# 静态资源:带 hash 的文件使用强缓存
location ~* \.(js|css|png|jpg|gif|svg|woff2)$ {
# 缓存 1 年(文件名含 hash,内容变更时 hash 自动变化)
expires 1y;
add_header Cache-Control "public, immutable";
}
# HTML 文件:使用协商缓存
location ~* \.html$ {
add_header Cache-Control "no-cache";
# 每次请求都向服务器验证是否更新
}
5.2 Service Worker 缓存
// sw.js
const CACHE_NAME = 'app-v1';
const PRECACHE_URLS = [
'/',
'/css/critical.css',
'/js/app.js',
];
// 安装时预缓存关键资源
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
});
// 请求时优先使用缓存
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
六、监控与度量
6.1 Performance API 测量白屏时间
// 获取 FCP 时间
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
console.log('FCP:', entry.startTime, 'ms');
}
}
});
observer.observe({ type: 'paint', buffered: true });
// 获取各阶段耗时
window.addEventListener('load', () => {
const timing = performance.getEntriesByType('navigation')[0];
console.log('DNS 解析:', timing.domainLookupEnd - timing.domainLookupStart, 'ms');
console.log('TCP 连接:', timing.connectEnd - timing.connectStart, 'ms');
console.log('请求响应:', timing.responseEnd - timing.requestStart, 'ms');
console.log('DOM 解析:', timing.domInteractive - timing.responseEnd, 'ms');
console.log('页面加载:', timing.loadEventStart - timing.fetchStart, 'ms');
});
6.2 Web Vitals
Google 定义的核心 Web 指标,是衡量用户体验的关键数据:
| 指标 | 全称 | 含义 | 良好标准 |
|---|---|---|---|
| FCP | First Contentful Paint | 首次内容绘制 | < 1.8s |
| LCP | Largest Contentful Paint | 最大内容绘制 | < 2.5s |
| FID | First Input Delay | 首次输入延迟 | < 100ms |
| CLS | Cumulative Layout Shift | 累积布局偏移 | < 0.1 |
// 使用 web-vitals 库
import { onFCP, onLCP, onFID, onCLS } from 'web-vitals';
onFCP(console.log);
onLCP(console.log);
onFID(console.log);
onCLS(console.log);
6.3 Lighthouse 审计
使用 Chrome DevTools 内置的 Lighthouse 进行全面的性能审计:
- 打开 Chrome DevTools(F12)
- 切换到 Lighthouse 面板
- 选择 Performance 类别
- 点击 Analyze page load
优化策略总结
面试高频问答
Q1:什么是白屏?白屏时间怎么计算?
答:白屏是指用户从发起页面请求到浏览器首次渲染出有意义内容之间的空白状态。白屏时间通常用 FCP(First Contentful Paint) 来衡量,可以通过 PerformanceObserver 监听 first-contentful-paint 事件获取。Google 建议 FCP 控制在 1.8 秒以内。
Q2:SPA 为什么容易出现白屏?如何解决?
答:SPA 的 HTML 只有一个空的挂载节点(如 <div id="app"></div>),所有页面内容依赖 JS 下载、解析、执行后才能渲染。在 JS 就绪前,用户看到的就是白屏。
解决方案:
- 骨架屏:在 HTML 中内联骨架屏样式,JS 加载前就有视觉反馈
- SSR/SSG:服务端直接返回渲染好的 HTML
- 代码分割:减小首屏 JS 体积,加快加载速度
- 预加载关键资源:使用
preload提前加载首屏必需的 CSS/JS
Q3:async 和 defer 有什么区别?
答:
async:JS 文件下载与 HTML 解析并行,但下载完成后会立即执行,执行时阻塞 HTML 解析。多个 async 脚本的执行顺序不确定。适合独立脚本(如统计代码)。defer:JS 文件下载与 HTML 解析并行,但等 HTML 解析完毕后才按顺序执行。适合有依赖关系的业务脚本。
Q4:preload 和 prefetch 有什么区别?
答:
preload:告诉浏览器"这个资源当前页面立即需要",会以高优先级提前加载。用于关键 CSS、首屏字体等。prefetch:告诉浏览器"这个资源未来可能需要",会在浏览器空闲时以低优先级预取。用于下一页的资源。
Q5:如何优化首屏的 CSS 加载?
答:
- 内联关键 CSS:将首屏必需的 CSS 直接写在
<style>标签中,避免额外网络请求 - 异步加载非关键 CSS:使用
<link rel="preload" as="style" onload="this.rel='stylesheet'">延迟加载非首屏 CSS - 移除未使用的 CSS:使用 PurgeCSS 等工具清理无用样式
- CSS 代码分割:按路由拆分 CSS,只加载当前页面需要的样式
Q6:说说你了解的白屏优化手段,至少说 5 个。
答:
- DNS 预解析和预连接(
dns-prefetch/preconnect):减少 DNS 和连接建立时间 - 代码分割和路由懒加载:减小首屏 JS 体积
- 骨架屏:消除视觉上的白屏感知
- SSR/SSG:服务端渲染,直接返回可见的 HTML
- 关键 CSS 内联:避免 CSS 阻塞首次渲染
- 静态资源使用 CDN:缩短资源传输距离
- 开启 Gzip/Brotli 压缩:减小资源传输体积
- 使用
defer加载 JS:避免 JS 阻塞 HTML 解析 - 合理使用缓存策略:强缓存 + 协商缓存减少重复请求
- 使用 Service Worker 离线缓存:二次访问几乎零白屏
Q7:如何监控线上页面的白屏情况?
答:
- Performance API:通过
PerformanceObserver监听paint事件获取 FCP 数据 - Web Vitals 库:使用 Google 官方的
web-vitals库采集 FCP/LCP 等指标 - 上报到监控平台:将指标数据发送到 Sentry、阿里云 ARMS 等监控平台进行聚合分析
- Lighthouse CI:在 CI/CD 流程中集成 Lighthouse 自动化审计,设置性能预算阈值