第 17 章:性能优化(Core Web Vitals 与 Bundle 瘦身)

全面提升 Next.js 应用性能——从 Core Web Vitals 指标理解、Bundle 分析与瘦身、图片与字体优化、Streaming SSR,到缓存策略与运行时性能调优。

本章目标:建立 Next.js 性能优化的系统方法论——理解 Core Web Vitals 三大指标,掌握 Bundle 分析与瘦身技巧,学会图片、字体、第三方脚本的优化策略,最终让你的 Next.js 应用达到 90+ Lighthouse 评分。


17.1 Core Web Vitals 理解

三大核心指标

指标全称衡量维度优秀需改进
LCPLargest Contentful Paint加载性能< 2.5s2.5-4.0s> 4.0s
INPInteraction to Next Paint交互响应< 200ms200-500ms> 500ms
CLSCumulative Layout Shift视觉稳定性< 0.10.1-0.25> 0.25

:INP 已于 2024 年 3 月正式替代 FID(First Input Delay)成为核心指标。

其他重要指标

指标说明
FCPFirst Contentful Paint — 首次内容绘制
TTFBTime to First Byte — 首字节时间
TBTTotal Blocking Time — 总阻塞时间
TTITime to Interactive — 可交互时间

测量工具

# Lighthouse CLI
npm install -D lighthouse
lighthouse https://your-site.com --view

# Chrome DevTools: Performance 面板(实时分析)

# web-vitals 库(RUM - 真实用户监控)
npm install web-vitals
// lib/web-vitals.ts

import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals';

export function reportWebVitals() {
  onLCP((metric) => {
    console.log('LCP:', metric.value, metric.rating);
    // 上报到分析服务
    // analytics.track('LCP', { value: metric.value });
  });

  onINP((metric) => {
    console.log('INP:', metric.value, metric.rating);
  });

  onCLS((metric) => {
    console.log('CLS:', metric.value, metric.rating);
  });

  onFCP((metric) => {
    console.log('FCP:', metric.value, metric.rating);
  });

  onTTFB((metric) => {
    console.log('TTFB:', metric.value, metric.rating);
  });
}

17.2 Bundle 分析与瘦身

安装 Bundle Analyzer

npm install -D @next/bundle-analyzer
// next.config.js

const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

/** @type {import('next').NextConfig} */
const nextConfig = {
  // ...
};

module.exports = withBundleAnalyzer(nextConfig);
// package.json
{
  "scripts": {
    "analyze": "ANALYZE=true next build",
    "build": "next build"
  }
}
npm run analyze
# 自动打开浏览器,显示三个 HTML 报告:
# - nodejs.html(服务端 bundle)
# - edge.html(Edge bundle)
# - client.html(客户端 bundle)← 最重要

常见包体积优化

常见大包的替代方案:

❌ moment.js (330KB) → ✅ date-fns (可 tree-shaking) 或 dayjs (2KB)
❌ lodash (72KB)    → ✅ lodash-es(可 tree-shaking)或手写工具函数
❌ axios (14KB)     → ✅ 原生 fetch(Next.js 已扩展)
❌ react-icons (全量) → ✅ 只导入用到的图标
❌ @mui/icons-material (全量) → ✅ 按需导入

动态导入(Dynamic Import)

// ✅ 延迟加载非关键组件
import dynamic from 'next/dynamic';

// 基础动态导入
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
  loading: () => <p>加载图表中...</p>,
  ssr: false, // 仅客户端渲染(如图表库依赖 window)
});

// 命名导出
const Editor = dynamic(
  () => import('@/components/Editor').then((mod) => mod.MarkdownEditor),
  {
    loading: () => <div className="animate-pulse h-96 bg-muted rounded" />,
    ssr: false,
  }
);

// 条件加载
export default function ArticlePage({ article }: { article: Article }) {
  const [showComments, setShowComments] = useState(false);

  const Comments = useMemo(
    () => showComments
      ? dynamic(() => import('@/components/Comments'))
      : null,
    [showComments]
  );

  return (
    <article>
      <h1>{article.title}</h1>
      {/* ... */}
      <button onClick={() => setShowComments(true)}>
        显示评论
      </button>
      {Comments && <Comments articleId={article.id} />}
    </article>
  );
}

减少客户端 JS

// ❌ 整个组件都是 "use client",导致大量 JS 发送到客户端
'use client';

import { useState } from 'react';
import { getArticles } from '@/lib/services/article';

export default function ArticlesPage() {
  const [filter, setFilter] = useState('all');
  const articles = getArticles({ category: filter }); // 这是 Server Action!

  return (
    <div>
      <FilterBar current={filter} onChange={setFilter} />
      <ArticleList articles={articles} />
    </div>
  );
}

// ✅ 将交互部分提取为 Client Component
// Server Component(无 JS 发送)
import { getArticles } from '@/lib/services/article';
import { FilterBar } from './filter-bar';  // Client Component
import { ArticleList } from './article-list'; // Server Component

export default async function ArticlesPage({ searchParams }: Props) {
  const articles = await getArticles({ category: searchParams.category });

  return (
    <div>
      <FilterBar />
      <ArticleList articles={articles} />
    </div>
  );
}

17.3 图片优化

Next.js Image 组件

import Image from 'next/image';

// ✅ 基础用法
<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={600}
  priority              // 首屏关键图片,预加载
  placeholder="blur"    // 模糊占位
  blurDataURL="data:image/..."
/>

// ✅ 填充模式(响应式容器)
<div className="relative aspect-video">
  <Image
    src="/article-cover.jpg"
    alt="Article cover"
    fill
    className="object-cover rounded-lg"
    sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  />
</div>

// ✅ 远程图片(需配置域名)
<Image
  src="https://cdn.example.com/photo.jpg"
  alt="Remote image"
  width={800}
  height={600}
/>

图片配置

// next.config.js

module.exports = {
  images: {
    // 远程图片域名白名单
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.example.com',
        pathname: '/images/**',
      },
      {
        protocol: 'https',
        hostname: 'avatars.githubusercontent.com',
      },
      {
        protocol: 'https',
        hostname: 'images.unsplash.com',
      },
    ],

    // 输出格式
    formats: ['image/avif', 'image/webp'],

    // 设备尺寸
    deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],

    // 缓存策略(Vercel 自动处理)
    minimumCacheTTL: 60 * 60 * 24 * 30, // 30 天
  },
};

图片优化策略

// 1. 首屏图片:priority(预加载)
<Image src="/hero.jpg" alt="Hero" width={1200} height={600} priority />

// 2. 折叠以下图片:默认懒加载
<Image src="/article.jpg" alt="Article" width={400} height={300} />

// 3. 占位图减少 CLS
<Image
  src="/photo.jpg"
  alt="Photo"
  width={800}
  height={600}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
/>

// 4. 响应式 sizes 属性
<Image
  src="/banner.jpg"
  alt="Banner"
  fill
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 80vw, 1200px"
  className="object-cover"
/>

// 5. 头像圆形裁剪
<div className="relative w-12 h-12 rounded-full overflow-hidden">
  <Image
    src={user.avatar}
    alt={user.name}
    fill
    className="object-cover"
    sizes="48px"
  />
</div>

生成 BlurDataURL

// lib/blur.ts

import { getPlaiceholder } from 'plaiceholder';

export async function getBlurData(src: string) {
  const buffer = await fetch(src).then(async (res) =>
    Buffer.from(await res.arrayBuffer())
  );

  const { base64 } = await getPlaiceholder(buffer);
  return base64;
}
// app/articles/[slug]/page.tsx

import Image from 'next/image';
import { getBlurData } from '@/lib/blur';

export default async function ArticlePage({ params }: Props) {
  const article = await getArticle(params.slug);

  const blurData = article.coverImage
    ? await getBlurData(article.coverImage)
    : undefined;

  return (
    <div className="relative aspect-video">
      <Image
        src={article.coverImage}
        alt={article.title}
        fill
        className="object-cover rounded-xl"
        placeholder="blur"
        blurDataURL={blurData}
        priority
      />
    </div>
  );
}

17.4 字体优化

next/font(零 CLS 字体方案)

// app/layout.tsx

import { Inter, Noto_Sans_SC, JetBrains_Mono } from 'next/font/google';

// 英文字体
const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
});

// 中文字体(按需子集化)
const notoSans = Noto_Sans_SC({
  subsets: ['latin'],
  weight: ['400', '500', '700'],
  display: 'swap',
  variable: '--font-noto-sans',
});

// 代码字体
const jetbrainsMono = JetBrains_Mono({
  subsets: ['latin'],
  weight: ['400', '500'],
  display: 'swap',
  variable: '--font-mono',
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html
      lang="zh-CN"
      className={`${inter.variable} ${notoSans.variable} ${jetbrainsMono.variable}`}
    >
      <body className="font-sans">{children}</body>
    </html>
  );
}
// tailwind.config.ts

const config: Config = {
  theme: {
    extend: {
      fontFamily: {
        sans: ['var(--font-inter)', 'var(--font-noto-sans)', 'sans-serif'],
        mono: ['var(--font-mono)', 'monospace'],
      },
    },
  },
};

本地字体

// app/layout.tsx

import localFont from 'next/font/local';

const calSans = localFont({
  src: '../public/fonts/CalSans-SemiBold.woff2',
  display: 'swap',
  variable: '--font-cal-sans',
  weight: '600',
});

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN" className={calSans.variable}>
      <body>{children}</body>
    </html>
  );
}

字体优化原则

✅ 使用 next/font(自动 self-host,零布局偏移)
✅ display: 'swap'(文本先显示,字体加载后替换)
✅ 只加载需要的字重(400、500、700 足够)
✅ 使用 CSS 变量引用字体
✅ variable 字体(一个文件包含所有字重)

❌ Google Fonts CDN 直接引入(额外 DNS + 连接)
❌ 加载全字重(100-900 全部)
❌ 使用 @import url() 在 CSS 中引入

17.5 Streaming SSR 与 Suspense

使用 Suspense 分割渲染

// app/page.tsx

import { Suspense } from 'react';
import { Hero } from '@/components/hero';
import { ArticleList } from '@/components/article-list';
import { Sidebar } from '@/components/sidebar';

export default function HomePage() {
  return (
    <div className="max-w-6xl mx-auto">
      {/* 静态内容:立即渲染 */}
      <Hero />

      <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mt-8">
        {/* 文章列表:可能需要等待数据库查询 */}
        <div className="lg:col-span-2">
          <Suspense fallback={<ArticleListSkeleton />}>
            <ArticleList />
          </Suspense>
        </div>

        {/* 侧边栏:独立的异步数据 */}
        <aside>
          <Suspense fallback={<SidebarSkeleton />}>
            <Sidebar />
          </Suspense>
        </aside>
      </div>
    </div>
  );
}

Loading Skeleton 设计

// components/skeletons/article-list-skeleton.tsx

export function ArticleListSkeleton() {
  return (
    <div className="space-y-6 animate-pulse">
      {Array.from({ length: 3 }).map((_, i) => (
        <div key={i} className="card p-5">
          <div className="flex items-center gap-3 mb-3">
            <div className="h-4 w-16 bg-muted rounded" />
            <div className="h-4 w-24 bg-muted rounded" />
          </div>
          <div className="h-6 w-3/4 bg-muted rounded mb-2" />
          <div className="h-4 w-full bg-muted rounded mb-1" />
          <div className="h-4 w-5/6 bg-muted rounded" />
        </div>
      ))}
    </div>
  );
}

使用 loading.tsx

// app/articles/loading.tsx

import { ArticleListSkeleton } from '@/components/skeletons';

export default function Loading() {
  return <ArticleListSkeleton />;
}

17.6 缓存策略

Next.js 四层缓存

请求流程(从快到慢):

1. Request Memoization     — 单次请求内去重(自动)
2. Data Cache             — 跨请求持久化(fetch 自动缓存)
3. Full Route Cache       — 完整页面 HTML + RSC Payload(构建时)
4. Router Cache           — 客户端路由缓存(导航时)

缓存控制

// 1. 静态缓存(默认)
export default async function Page() {
  const data = await fetch('https://api.example.com/data');
  // 构建时缓存,直到重新验证
}

// 2. 定时重新验证(ISR)
export default async function Page() {
  const data = await fetch('https://api.example.com/data', {
    next: { revalidate: 3600 }, // 每小时重新验证
  });
}

// 3. 不缓存(动态渲染)
export default async function Page() {
  const data = await fetch('https://api.example.com/data', {
    cache: 'no-store',
  });
}

// 4. 路由段配置
export const dynamic = 'force-dynamic';     // 强制动态渲染
export const revalidate = 60;               // 60 秒 ISR
export const fetchCache = 'force-no-store'; // 强制不缓存

按需重新验证

// app/actions/article.ts
'use server';

import { revalidatePath, revalidateTag } from 'next/cache';

export async function createArticle(formData: FormData) {
  await prisma.article.create({ data: { /* ... */ } });

  // 重新验证特定路径
  revalidatePath('/articles');
  revalidatePath('/articles/[slug]', 'page');

  // 重新验证特定标签
  revalidateTag('articles');
}
// 使用 fetch 时打标签
const articles = await fetch('https://api.example.com/articles', {
  next: { tags: ['articles'] },
});

// 后续可以按标签重新验证
revalidateTag('articles');

17.7 第三方脚本优化

next/script

// app/layout.tsx

import Script from 'next/script';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body>
        {children}

        {/* afterInteractive:页面可交互后加载(默认) */}
        <Script
          src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"
          strategy="afterInteractive"
        />
        <Script id="gtag" strategy="afterInteractive">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', 'G-XXXXXXX');
          `}
        </Script>

        {/* lazyOnload:空闲时加载(低优先级) */}
        <Script
          src="https://chat.example.com/widget.js"
          strategy="lazyOnload"
        />

        {/* beforeInteractive:页面 hydration 前加载(仅 _document 中) */}
        {/* worker:在 Web Worker 中执行 */}
        <Script
          src="https://analytics.example.com/script.js"
          strategy="worker"
        />
      </body>
    </html>
  );
}

Script 策略选择

策略加载时机适用场景
beforeInteractivehydration 前关键脚本(Cookie 同意、Bot 检测)
afterInteractivehydration 后(默认)分析工具(GA、GTM)
lazyOnload浏览器空闲时聊天组件、社交嵌入
workerWeb Worker 中重型第三方脚本

17.8 渲染优化

减少不必要的重渲染

// ❌ 每次父组件渲染都会重渲染
function ExpensiveComponent({ data }) {
  return <div>{/* 大量 DOM 节点 */}</div>;
}

// ✅ 使用 React.memo 缓存
const ExpensiveComponent = React.memo(function ExpensiveComponent({ data }) {
  return <div>{/* 大量 DOM 节点 */}</div>;
});

// ✅ 使用 useMemo 缓存计算结果
function ArticleList({ articles, sortBy }) {
  const sorted = useMemo(() => {
    return [...articles].sort((a, b) => {
      if (sortBy === 'newest') return b.createdAt - a.createdAt;
      if (sortBy === 'popular') return b.views - a.views;
      return 0;
    });
  }, [articles, sortBy]);

  return sorted.map((article) => <ArticleCard key={article.id} article={article} />);
}

// ✅ 使用 useCallback 缓存函数引用
function FilterBar({ onFilter }: { onFilter: (value: string) => void }) {
  const handleClick = useCallback((value: string) => {
    onFilter(value);
  }, [onFilter]);

  return <button onClick={() => handleClick('new')}>最新</button>;
}

虚拟化长列表

'use client';

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';

export function VirtualList({ items }: { items: Article[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 120, // 每项预估高度
    overscan: 5, // 多渲染 5 项(滚动缓冲)
  });

  return (
    <div ref={parentRef} className="h-[600px] overflow-auto">
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualRow) => (
          <div
            key={virtualRow.key}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: `${virtualRow.size}px`,
              transform: `translateY(${virtualRow.start}px)`,
            }}
          >
            <ArticleCard article={items[virtualRow.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}
npm install @tanstack/react-virtual

17.9 SEO 与 Metadata

Metadata API

// app/layout.tsx

import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: {
    default: 'MyBlog - Next.js 技术博客',
    template: '%s | MyBlog',
  },
  description: '系统学习 Next.js App Router,从入门到生产实战。',
  keywords: ['Next.js', 'React', 'App Router', 'Server Components'],
  authors: [{ name: 'bingrong', url: 'https://example.com' }],
  creator: 'bingrong',

  // Open Graph
  openGraph: {
    type: 'website',
    locale: 'zh_CN',
    url: 'https://example.com',
    siteName: 'MyBlog',
    title: 'MyBlog - Next.js 技术博客',
    description: '系统学习 Next.js App Router',
    images: [
      {
        url: '/og-image.png',
        width: 1200,
        height: 630,
        alt: 'MyBlog',
      },
    ],
  },

  // Twitter
  twitter: {
    card: 'summary_large_image',
    title: 'MyBlog',
    description: '系统学习 Next.js App Router',
    images: ['/og-image.png'],
    creator: '@bingrong',
  },

  // Robots
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-video-preview': -1,
      'max-image-preview': 'large',
      'max-snippet': -1,
    },
  },
};

动态 Metadata

// app/articles/[slug]/page.tsx

import type { Metadata } from 'next';
import { getArticle } from '@/lib/services/article';

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const article = await getArticle(slug);

  if (!article) {
    return { title: '文章未找到' };
  }

  return {
    title: article.title,
    description: article.excerpt,
    openGraph: {
      title: article.title,
      description: article.excerpt || '',
      images: article.coverImage ? [{ url: article.coverImage }] : [],
      type: 'article',
      publishedTime: article.publishedAt?.toISOString(),
      authors: [article.author.name || ''],
      tags: article.tags,
    },
    twitter: {
      card: 'summary_large_image',
      title: article.title,
      description: article.excerpt || '',
      images: article.coverImage ? [article.coverImage] : [],
    },
  };
}

JSON-LD 结构化数据

// app/articles/[slug]/page.tsx

export default async function ArticlePage({ params }: Props) {
  const article = await getArticle(params.slug);

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: article.title,
    description: article.excerpt,
    image: article.coverImage,
    datePublished: article.publishedAt,
    dateModified: article.updatedAt,
    author: {
      '@type': 'Person',
      name: article.author.name,
    },
    publisher: {
      '@type': 'Organization',
      name: 'MyBlog',
      logo: {
        '@type': 'ImageObject',
        url: 'https://example.com/logo.png',
      },
    },
  };

  return (
    <article>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      {/* 文章内容 */}
    </article>
  );
}

17.10 性能检查清单

## Next.js 性能优化检查清单

### 加载性能 (LCP)
- [ ] 首屏图片使用 priority
- [ ] 使用 next/font 替代 Google Fonts CDN
- [ ] 关键路由使用 Streaming + Suspense
- [ ] 数据库查询使用 cache() 去重
- [ ] 非关键第三方脚本使用 lazyOnload

### 交互响应 (INP)
- [ ] 重型组件使用 dynamic import
- [ ] 避免在主线程执行耗时计算
- [ ] 长列表使用虚拟化
- [ ] 减少客户端 JS 体积
- [ ] 事件处理器使用 debounce / throttle

### 视觉稳定性 (CLS)
- [ ] 图片 / 视频有固定宽高或 aspect-ratio
- [ ] 字体使用 display: swap + size-adjust
- [ ] 动态内容预留空间
- [ ] 避免在现有内容上方插入内容
- [ ] 广告 / 嵌入内容使用固定尺寸容器

### Bundle 优化
- [ ] 使用 Bundle Analyzer 检查包体积
- [ ] 替换大型依赖(moment → dayjs)
- [ ] 使用 tree-shaking 友好的导入
- [ ] 减少 "use client" 组件数量
- [ ] 工具函数使用 lodash-es 替代 lodash

### 缓存策略
- [ ] 静态页面使用 Full Route Cache
- [ ] 动态数据使用 ISR(revalidate)
- [ ] 数据库查询使用 React cache
- [ ] 正确使用 revalidatePath / revalidateTag
- [ ] 客户端使用 Router Cache

### 图片优化
- [ ] 使用 next/image 替代 <img>
- [ ] 首屏图片设置 priority
- [ ] 响应式图片使用 sizes 属性
- [ ] 使用 WebP / AVIF 格式
- [ ] 模糊占位图减少 CLS

本章小结

Key Takeaways

  1. Core Web Vitals 是性能优化的北极星指标:LCP(加载)、INP(交互)、CLS(稳定)
  2. Bundle 瘦身是最直接的性能提升:动态导入、替换大包、减少 “use client”
  3. next/image 和 next/font 是必须的:自动优化格式、尺寸、预加载
  4. Streaming + Suspense 提升感知性能:让用户更快看到内容
  5. 缓存策略决定了 TTFB:静态缓存 → ISR → 动态渲染,逐级变慢
  6. 性能优化是一个持续过程:定期 Lighthouse 审计,关注 RUM 数据

下一步

下一章我们将深入 SEO 与 Metadata API——构建完整的 SEO 优化体系,包括 sitemap 生成、robots.txt、OpenGraph 图片、结构化数据等。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页