2024年09月28日7 分钟
首屏加载优化全方案:从白屏到秒开
系统性梳理首屏加载优化的所有方案,涵盖网络、资源、渲染、感知四个维度
首屏加载的时间分解
首屏总耗时 = DNS + TCP + TTFB + HTML下载 + CSS下载 + JS下载 + JS执行 + 数据请求 + 渲染
各阶段典型耗时(未优化):
DNS: 50ms
TCP: 50ms
TTFB: 200ms
HTML: 50ms
CSS: 100ms ← 阻塞渲染
JS: 500ms ← 最大瓶颈
JS执行: 300ms ← 阻塞交互
API: 400ms
渲染: 100ms
─────────────
总计: 1750ms
维度一:网络层优化
1. CDN 加速
静态资源(JS/CSS/图片)部署到 CDN:
源服务器(美国)→ 用户: 200-400ms
CDN 节点(本地)→ 用户: 5-20ms
配置:
HTML 不缓存(或短缓存)→ 保证更新能生效
JS/CSS(带 hash)→ 强缓存 1 年
图片/字体 → 强缓存 1 年
2. HTTP/2 多路复用
HTTP/1.1: 一个连接一次只能传一个文件 → 队头阻塞
[CSS] → [JS1] → [JS2] → [JS3] → [image] 串行
HTTP/2: 一个连接同时传多个文件 → 并行
[CSS ──────]
[JS1 ───]
[JS2 ────] 并行
[JS3 ──]
[image ─────]
3. 资源预加载
<!-- DNS 预解析 -->
<link rel="dns-prefetch" href="//api.example.com">
<link rel="dns-prefetch" href="//cdn.example.com">
<!-- 预连接 -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<!-- 预加载关键资源 -->
<link rel="preload" href="/fonts/inter.woff2" as="font" crossorigin>
<link rel="preload" href="/hero.webp" as="image">
<!-- 预获取下一页资源 -->
<link rel="prefetch" href="/about-page.js">
维度二:资源体积优化
1. JS Bundle 瘦身
优化前:bundle.js = 2MB
① 代码分割:
main.js = 200KB(首屏核心)
vendor.js = 300KB(React + 第三方库)
page-a.js = 150KB(按需加载)
② Tree Shaking:移除未使用代码
lodash 整包 70KB → 只用 debounce 2KB
③ 替换重型依赖:
moment.js (70KB) → date-fns (3KB 按需)
lodash (70KB) → lodash-es (按需 tree-shake)
④ 压缩:Brotli 压缩
500KB → 120KB (gzipped) → 95KB (brotli)
优化后:首屏 JS = 200KB → Brotli = 50KB
2. CSS 优化
<!-- 内联关键 CSS -->
<style>
/* 首屏渲染需要的最小 CSS */
*, *::before, *::after { box-sizing: border-box; margin: 0; }
body { font-family: system-ui, sans-serif; }
.hero { min-height: 100vh; display: flex; align-items: center; }
</style>
<!-- 异步加载完整 CSS -->
<link rel="preload" href="/styles.css" as="style" onload="this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
3. 图片优化
// Next.js Image 自动优化
<Image
src="/hero.jpg"
width={1200}
height={600}
priority // 首屏图片:预加载
placeholder="blur" // 模糊占位图
sizes="100vw" // 响应式
/>
// 等价于自动生成:
// <img srcset="hero-640.webp 640w, hero-1200.webp 1200w, hero-1920.webp 1920w"
// sizes="100vw" src="hero-1200.webp" loading="eager">
维度三:渲染策略优化
1. SSR / SSG 消除白屏
CSR 首屏:
0ms 500ms 1000ms 1500ms 2000ms
│────白屏────│──JS执行──│──API请求──│──渲染──│
↑ 首屏可见
SSR 首屏:
0ms 300ms 800ms
│──TTFB──│──渲染──│──Hydration──│
↑ 首屏可见(提前 1200ms)
SSG 首屏:
0ms 100ms 600ms
│CDN──│──渲染──│──Hydration──│
↑ 首屏可见(最快)
2. 流式 SSR (React 18)
// 不等所有数据到齐,先发 HTML 外壳
export default function Page() {
return (
<main>
<Header /> {/* 立即返回 */}
<Suspense fallback={<HeroSkeleton />}>
<HeroSection /> {/* 数据准备好后流式推送 */}
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<RecentPosts /> {/* 独立加载,不阻塞其他部分 */}
</Suspense>
</main>
)
}
// 用户体验:
// 0ms: 看到 Header + 两个骨架屏
// 200ms: HeroSection 数据到了 → 替换骨架屏
// 500ms: RecentPosts 数据到了 → 替换骨架屏
// 全程无白屏!
3. 渐进式 Hydration
// 非关键组件延迟水合
import dynamic from 'next/dynamic'
const Comments = dynamic(() => import('./Comments'), {
ssr: false, // 不在服务端渲染
loading: () => <CommentsSkeleton />,
})
const Footer = dynamic(() => import('./Footer'), {
ssr: true,
loading: () => <FooterSkeleton />,
})
// 首屏只水合关键组件 → TTI 更快
维度四:感知性能优化
即使实际加载时间不变,也能让用户感觉更快。
1. 骨架屏(Skeleton)
function ArticleSkeleton() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-2/3" />
</div>
)
}
// 用户看到骨架屏 → 知道内容在加载 → 不会认为页面坏了
// 比白屏或 Loading 转圈好得多
2. 渐进式图片加载
// 先加载模糊缩略图,再替换为高清图
function ProgressiveImage({ src, placeholder }) {
const [loaded, setLoaded] = useState(false)
return (
<div className="relative">
{/* 模糊占位图(1KB 内联 base64)*/}
<img
src={placeholder}
className={`w-full transition-opacity duration-300 ${loaded ? 'opacity-0' : 'opacity-100'}`}
style={{ filter: 'blur(20px)' }}
/>
{/* 高清图 */}
<img
src={src}
onLoad={() => setLoaded(true)}
className={`absolute inset-0 w-full transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'}`}
/>
</div>
)
}
3. 乐观更新(Optimistic UI)
// 不等服务器响应,立即更新 UI
function LikeButton({ postId }) {
const [liked, setLiked] = useState(false)
const handleLike = async () => {
setLiked(true) // 立即更新 UI(乐观)
try {
await fetch(`/api/posts/${postId}/like`, { method: 'POST' })
} catch {
setLiked(false) // 失败了回滚
}
}
return <button onClick={handleLike}>{liked ? '❤️' : '🤍'}</button>
}
首屏优化检查清单
网络层:
□ CDN 部署
□ HTTP/2 启用
□ Brotli 压缩
□ DNS 预解析
□ 关键资源 preload
资源体积:
□ 代码分割(路由级 + 组件级)
□ Tree Shaking 验证
□ 图片 WebP/AVIF 格式
□ 字体 woff2 + display:swap
□ 移除未使用依赖
渲染策略:
□ SSR / SSG / ISR 选型
□ 流式 SSR + Suspense
□ 非关键组件延迟加载
□ 关键 CSS 内联
感知优化:
□ 骨架屏
□ 渐进式图片
□ 路由预加载
□ Loading 状态