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 三个核心指标

指标全称衡量什么需改进
LCPLargest Contentful Paint最大内容元素渲染完成的时间≤ 2.5s≤ 4.0s> 4.0s
INPInteraction to Next Paint用户交互到界面响应的延迟≤ 200ms≤ 500ms> 500ms
CLSCumulative 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 图片
  • 大段文字块(&lt;h1&gt;&lt;p&gt;
  • 视频封面
  • 背景图(通过 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 + 字体文件),会延迟 LCP

2.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 时,&lt;h1&gt;&lt;HeroImage&gt; 几乎立即发送(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-adjustascent-overridedescent-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 的工作原理

  1. 构建时:Next.js 渲染页面,遇到 &lt;Suspense&gt; 包裹的动态组件时停下来,把 fallback 渲染为占位符,其余静态部分生成 HTML
  2. 请求时:CDN 立即返回静态 HTML(包含 Suspense fallback),同时服务端开始渲染动态部分
  3. 流式填充:动态部分渲染完后,通过 Streaming 替换对应的 fallback

用户体验:页面几乎瞬间出现(CDN 返回静态壳),动态内容逐步填充——没有白屏等待。

5.4 PPR vs 传统方案对比

方案TTFBLCP个性化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 本质是自动包裹 &lt;Suspense&gt; 的语法糖:

// 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)
☐ 骨架屏和实际内容尺寸匹配
☐ 动态内容(广告、通知)预留空间
☐ 不在视口上方动态插入内容

本章小结

  • LCPpriority 图片 + 并行数据请求 + Streaming 首屏先发 + next/font 避免字体阻塞
  • INP:Server Component 减少客户端 JS + useTransition 非阻塞更新 + 防抖 + 虚拟列表
  • CLSnext/image 自动占位 + next/fontsize-adjust + 骨架屏尺寸匹配 + 预留空间
  • PPR:静态壳 CDN 缓存 + 动态部分 Streaming 填充,兼顾速度和个性化
  • Streaming:用 &lt;Suspense&gt; 编排加载顺序,让重要内容先到达用户