Core Web Vitals 优化
Google 用三个指标衡量用户体验:LCP(加载速度)、INP(交互响应)、CLS(视觉稳定性)。这三个指标直接影响 SEO 排名和用户留存。Next.js App Router 从架构层面支持了这些指标的优化——Server Components 减少 JS、Streaming 加速首屏、PPR 结合静态与动态。本章逐一讲清每个指标的优化策略和 Next.js 特有的优化手段。
1. Core Web Vitals 概览
1.1 三个核心指标
| 指标 | 全称 | 衡量什么 | 好 | 需改进 | 差 |
|---|---|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容元素渲染完成的时间 | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| INP | Interaction to Next Paint | 用户交互到界面响应的延迟 | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS | Cumulative Layout Shift | 页面元素意外位移的累计量 | ≤ 0.1 | ≤ 0.25 | > 0.25 |
为什么这三个指标重要?
- Google 搜索排名直接参考 Core Web Vitals
- LCP > 4s 的页面,53% 的用户会离开
- INP > 500ms 的页面,用户感觉"卡顿"
- CLS > 0.25 的页面,用户点错按钮的概率翻倍
1.2 如何测量
// 使用 web-vitals 库在客户端收集
// app/components/web-vitals.tsx
'use client'
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
// 发送到你的分析服务
console.log(metric.name, metric.value)
// 或发送到自定义后端
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
id: metric.id,
}),
})
})
return null
}// app/layout.tsx
import { WebVitals } from '@/components/web-vitals'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<WebVitals />
{children}
</body>
</html>
)
}外部工具:
- Chrome DevTools → Lighthouse:本地测试(模拟数据)
- PageSpeed Insights:真实用户数据(CrUX)+ 模拟数据
- Vercel Analytics:如果部署在 Vercel,自动收集真实用户数据
2. LCP 优化
2.1 什么是 LCP 元素
LCP 元素是视口中面积最大的可见内容元素。通常是:
- Hero 图片
- 大段文字块(
<h1>、<p>) - 视频封面
- 背景图(通过 CSS
background-image)
优化 LCP 的核心思路:让 LCP 元素尽早开始加载 + 尽快渲染完成。
2.2 服务端优化:减少 TTFB
TTFB(Time to First Byte)是服务器返回第一个字节的时间。TTFB 越长,LCP 越慢。
// ❌ 慢:在页面渲染前串行请求多个 API
export default async function Page() {
const user = await getUser() // 200ms
const posts = await getPosts() // 300ms
const comments = await getComments() // 250ms
// 总计 750ms TTFB
return <div>...</div>
}
// ✅ 快:并行请求
export default async function Page() {
const [user, posts, comments] = await Promise.all([
getUser(), // 200ms ─┐
getPosts(), // 300ms ─┤ 并行
getComments(), // 250ms ─┘
])
// 总计 300ms TTFB(取最长的)
return <div>...</div>
}2.3 图片 LCP 优化
如果 LCP 元素是图片,参考第 40 章的策略:
import Image from 'next/image'
// ✅ LCP 图片三件套:priority + sizes + 合理尺寸
<Image
src={heroImage}
alt="Hero"
fill
priority // 1. 禁用懒加载 + 注入 preload
sizes="100vw" // 2. 告诉浏览器实际宽度
quality={85} // 3. 平衡质量和大小
/>额外技巧——提前预加载 LCP 图片:
// app/layout.tsx — 如果 LCP 图片 URL 是动态的
import { headers } from 'next/headers'
export default async function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
{/* 直接在 <head> 中预加载关键图片 */}
<link
rel="preload"
as="image"
href="/images/hero.webp"
type="image/webp"
/>
</head>
<body>{children}</body>
</html>
)
}2.4 字体对 LCP 的影响
如果 LCP 元素是文字,字体加载会阻塞渲染:
// ✅ next/font 自动处理(构建时下载 + swap)
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
// ❌ 手动引入的字体可能阻塞渲染
// <link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
// 这需要两次网络请求(CSS + 字体文件),会延迟 LCP2.5 Streaming 加速 LCP
Streaming SSR 的核心价值:不等所有数据都准备好,先发送已有的 HTML。
import { Suspense } from 'react'
export default function Page() {
return (
<div>
{/* 这部分立即发送——包含 LCP 元素 */}
<h1>Dashboard</h1>
<HeroImage />
{/* 这部分等数据准备好后流式发送 */}
<Suspense fallback={<TableSkeleton />}>
<DataTable /> {/* 需要 2 秒的数据库查询 */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart /> {/* 需要 3 秒的 API 请求 */}
</Suspense>
</div>
)
}原理:没有 Streaming 时,整个页面要等所有组件渲染完(5 秒后一起发送)。有 Streaming 时,<h1> 和 <HeroImage> 几乎立即发送(LCP < 1s),慢的部分后续流入。
3. INP 优化
3.1 什么是 INP
INP(Interaction to Next Paint)替代了 FID,衡量整个页面生命周期中最慢的交互的响应时间。它包括:
- 事件处理耗时
- 浏览器重新渲染耗时
- 下一帧绘制完成
INP 差通常意味着:主线程被长任务阻塞了。
3.2 减少客户端 JavaScript
这是 INP 优化的第一步——JS 越少,主线程越空闲:
// ✅ 把计算密集的逻辑放在 Server Component
// app/dashboard/page.tsx (Server Component)
export default async function DashboardPage() {
// 数据处理在服务端完成——不占用客户端主线程
const rawData = await db.query.analytics.findMany()
const processedData = heavyDataTransform(rawData) // 服务端计算
const summary = generateSummary(processedData) // 服务端计算
return (
<div>
<StaticSummary data={summary} /> {/* Server Component:0 JS */}
<InteractiveChart data={processedData} /> {/* Client Component:只传递处理好的数据 */}
</div>
)
}3.3 优化事件处理
'use client'
import { useTransition } from 'react'
export function SearchFilter({ onFilter }: { onFilter: (query: string) => void }) {
const [isPending, startTransition] = useTransition()
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
// ✅ 用 startTransition 标记为非紧急更新
// 浏览器会优先处理输入框的更新,然后再处理过滤
startTransition(() => {
onFilter(e.target.value)
})
}
return (
<div>
<input onChange={handleChange} placeholder="搜索..." />
{isPending && <span className="text-gray-400">筛选中...</span>}
</div>
)
}useTransition 的原理:React 18 的并发特性。它把状态更新标记为"可中断的"——如果用户在更新过程中又输入了新内容,React 会放弃旧的更新,开始处理新的。这样输入框始终能立即响应。
3.4 防抖和节流
'use client'
import { useMemo } from 'react'
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number) {
let timer: ReturnType<typeof setTimeout>
return (...args: Parameters<T>) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), ms)
}
}
export function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
// 300ms 防抖——用户停止输入 300ms 后才触发搜索
const debouncedSearch = useMemo(
() => debounce((value: string) => onSearch(value), 300),
[onSearch],
)
return <input onChange={(e) => debouncedSearch(e.target.value)} placeholder="搜索..." />
}3.5 长列表虚拟化
渲染 1000 个 DOM 元素会严重影响 INP——每次交互后浏览器都要重新布局这些元素:
'use client'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'
export function VirtualList({ items }: { items: any[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // 每行预估高度
overscan: 5, // 视口外多渲染 5 行(平滑滚动)
})
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualItem.start}px)`,
height: `${virtualItem.size}px`,
width: '100%',
}}
>
{items[virtualItem.index].name}
</div>
))}
</div>
</div>
)
}原理:不管列表有 10,000 项,DOM 中始终只有 ~20 个元素(视口可见的 + overscan)。滚动时只是改变 transform,不创建/销毁 DOM。
4. CLS 优化
4.1 什么导致 CLS
CLS 是页面元素意外位移的累计量。常见原因:
- 图片没有预留尺寸:图片加载后把下方内容推开
- 字体切换:系统字体 → Web 字体的度量不同
- 动态注入内容:广告、弹窗、Cookie 通知
- 异步加载的组件:骨架屏和实际内容尺寸不匹配
4.2 图片 CLS 修复
// ❌ 没有尺寸 — 加载后布局偏移
<img src="/photo.jpg" alt="Photo" />
// ✅ next/image 自动处理
// 本地图片:自动读取尺寸
import photo from '@/public/photo.jpg'
<Image src={photo} alt="Photo" />
// 远程图片:指定尺寸
<Image src={url} alt="Photo" width={800} height={600} />
// fill 模式:父容器必须有尺寸
<div className="relative aspect-video">
<Image src={url} alt="Photo" fill />
</div>关键:aspect-ratio CSS 属性配合 fill 模式是最灵活的方案——父容器用 aspect-video(16:9)或 aspect-square(1:1)预留空间。
4.3 字体 CLS 修复
// ✅ next/font 的 size-adjust 自动消除字体切换偏移
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // 先显示系统字体,加载完切换
adjustFontFallback: true, // 默认 true — 自动调整回退字体度量
})adjustFontFallback 的原理:next/font 在构建时计算目标字体和系统字体(Arial/Times New Roman)之间的度量差异,生成 size-adjust、ascent-override、descent-override 等 CSS 属性,让回退字体和目标字体占据完全相同的空间。
4.4 骨架屏尺寸匹配
// ❌ 骨架屏和实际内容尺寸不匹配 — 加载完后会偏移
<Suspense fallback={<div className="h-[200px]">Loading...</div>}>
<UserCard /> {/* 实际高度 280px → CLS! */}
</Suspense>
// ✅ 骨架屏精确匹配实际内容的尺寸
<Suspense fallback={<UserCardSkeleton />}>
<UserCard />
</Suspense>
function UserCardSkeleton() {
return (
<div className="h-[280px] rounded-lg bg-gray-100 animate-pulse">
<div className="w-16 h-16 rounded-full bg-gray-200 m-4" />
<div className="h-4 w-32 bg-gray-200 mx-4" />
<div className="h-4 w-48 bg-gray-200 mx-4 mt-2" />
</div>
)
}4.5 动态内容预留空间
// ❌ Cookie 通知在页面底部弹出,把内容推上去
<div className="fixed bottom-0">Cookie Notice</div> // 如果高度动态变化 → CLS
// ✅ 预留空间 或 使用 overlay(不影响布局流)
<div className="fixed bottom-0 inset-x-0 z-50"> {/* fixed 不影响文档流 */}
<CookieNotice />
</div>
// ✅ 广告位预留精确尺寸
<div className="w-full h-[250px] bg-gray-50"> {/* 即使广告没加载也占位 */}
<AdSlot />
</div>5. Partial Prerendering (PPR)
5.1 什么是 PPR
PPR 是 Next.js 的实验性功能,解决了一个长期痛点:一个页面既要快(静态缓存)又要个性化(动态渲染)。
传统方式下,一个页面只能是静态的或动态的:
- 静态页面快但不能个性化
- 动态页面个性化但每次都要重新渲染
PPR 让一个页面同时包含静态和动态部分:静态部分在构建时预渲染并缓存到 CDN,动态部分在请求时流式渲染填充。
传统静态: [────── 整个页面从 CDN ──────] 快但不能个性化
传统动态: [────── 整个页面服务端渲染 ────] 慢但能个性化
PPR: [静态壳 CDN] + [动态部分 Streaming] 两者兼得
5.2 启用 PPR
// next.config.ts
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental', // 增量启用,逐个路由开启
},
}// app/dashboard/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
// 标记这个路由启用 PPR
export const experimental_ppr = true
export default function DashboardPage() {
return (
<div>
{/* 静态部分 — 构建时预渲染,CDN 缓存 */}
<header>
<h1>Dashboard</h1>
<nav>...</nav>
</header>
<div className="grid grid-cols-3 gap-4">
{/* 动态部分 — 每次请求时流式渲染 */}
<Suspense fallback={<UserCardSkeleton />}>
<UserCard /> {/* 内部用了 cookies() → 动态 */}
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<RealtimeStats /> {/* 实时数据 → 动态 */}
</Suspense>
{/* 静态部分 — 也被预渲染 */}
<QuickLinks />
</div>
</div>
)
}5.3 PPR 的工作原理
- 构建时:Next.js 渲染页面,遇到
<Suspense>包裹的动态组件时停下来,把 fallback 渲染为占位符,其余静态部分生成 HTML - 请求时:CDN 立即返回静态 HTML(包含 Suspense fallback),同时服务端开始渲染动态部分
- 流式填充:动态部分渲染完后,通过 Streaming 替换对应的 fallback
用户体验:页面几乎瞬间出现(CDN 返回静态壳),动态内容逐步填充——没有白屏等待。
5.4 PPR vs 传统方案对比
| 方案 | TTFB | LCP | 个性化 | CDN 缓存 |
|---|---|---|---|---|
| 纯静态(SSG) | ~50ms | ~200ms | ❌ | ✅ |
| ISR | ~50ms | ~200ms | ❌(stale data) | ✅ |
| 动态 SSR | ~200-500ms | ~500-1000ms | ✅ | ❌ |
| PPR | ~50ms | ~200ms | ✅ | ✅(静态壳) |
6. Streaming SSR 深入
6.1 原理
传统 SSR 是"全有或全无"——服务端把整个页面渲染完,一次性发送给浏览器。Streaming SSR 把页面分成多个块,渲染完一块就发送一块。
传统 SSR:
Server: [──── 渲染全部 ────]
Client: [── 显示 ──]
Streaming SSR:
Server: [壳] [块1] [块2] [块3]
Client: [壳] ↓
[壳+块1] ↓
[壳+块1+块2] ↓
[壳+块1+块2+块3]
底层技术:HTTP Transfer-Encoding: chunked + React 的 renderToPipeableStream()。
6.2 Suspense 编排策略
// 策略 1:独立加载 — 每个区块独立 Suspense
// 适合:各区块互不依赖
export default function Page() {
return (
<>
<Suspense fallback={<HeaderSkeleton />}>
<Header />
</Suspense>
<Suspense fallback={<MainSkeleton />}>
<MainContent />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar />
</Suspense>
</>
)
}
// 策略 2:嵌套加载 — 渐进式展示
// 适合:内容有层级关系
export default function Page() {
return (
<Suspense fallback={<PageSkeleton />}>
<Layout>
<Suspense fallback={<ContentSkeleton />}>
<Content>
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* 最后加载 */}
</Suspense>
</Content>
</Suspense>
</Layout>
</Suspense>
)
}6.3 loading.tsx
Next.js 的 loading.tsx 本质是自动包裹 <Suspense> 的语法糖:
// app/dashboard/loading.tsx
// 等价于在 layout 中用 <Suspense fallback={<Loading />}> 包裹 children
export default function DashboardLoading() {
return (
<div className="space-y-4 animate-pulse">
<div className="h-8 w-48 bg-gray-200 rounded" />
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-32 bg-gray-200 rounded-lg" />
))}
</div>
<div className="h-64 bg-gray-200 rounded-lg" />
</div>
)
}7. 性能优化 Checklist
LCP 优化:
☐ LCP 图片加了 priority
☐ 首屏没有不必要的阻塞请求
☐ 数据请求用 Promise.all 并行
☐ 用 Streaming(Suspense)让首屏内容先发送
☐ 字体用 next/font(构建时下载 + swap)
INP 优化:
☐ 重计算放在 Server Component
☐ 大状态更新用 useTransition
☐ 搜索/过滤输入用防抖
☐ 长列表用虚拟滚动(@tanstack/react-virtual)
☐ 减少客户端 JS(用 bundle analyzer 检查)
CLS 优化:
☐ 所有图片有明确尺寸(width/height 或 aspect-ratio + fill)
☐ 字体用 next/font(自动 size-adjust)
☐ 骨架屏和实际内容尺寸匹配
☐ 动态内容(广告、通知)预留空间
☐ 不在视口上方动态插入内容
本章小结
- LCP:
priority图片 + 并行数据请求 + Streaming 首屏先发 +next/font避免字体阻塞 - INP:Server Component 减少客户端 JS +
useTransition非阻塞更新 + 防抖 + 虚拟列表 - CLS:
next/image自动占位 +next/font的size-adjust+ 骨架屏尺寸匹配 + 预留空间 - PPR:静态壳 CDN 缓存 + 动态部分 Streaming 填充,兼顾速度和个性化
- Streaming:用
<Suspense>编排加载顺序,让重要内容先到达用户