本章目标:建立 Next.js 性能优化的系统方法论——理解 Core Web Vitals 三大指标,掌握 Bundle 分析与瘦身技巧,学会图片、字体、第三方脚本的优化策略,最终让你的 Next.js 应用达到 90+ Lighthouse 评分。
17.1 Core Web Vitals 理解
三大核心指标
| 指标 | 全称 | 衡量维度 | 优秀 | 需改进 | 差 |
|---|---|---|---|---|---|
| LCP | Largest Contentful Paint | 加载性能 | < 2.5s | 2.5-4.0s | > 4.0s |
| INP | Interaction to Next Paint | 交互响应 | < 200ms | 200-500ms | > 500ms |
| CLS | Cumulative Layout Shift | 视觉稳定性 | < 0.1 | 0.1-0.25 | > 0.25 |
注:INP 已于 2024 年 3 月正式替代 FID(First Input Delay)成为核心指标。
其他重要指标
| 指标 | 说明 |
|---|---|
| FCP | First Contentful Paint — 首次内容绘制 |
| TTFB | Time to First Byte — 首字节时间 |
| TBT | Total Blocking Time — 总阻塞时间 |
| TTI | Time 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 策略选择
| 策略 | 加载时机 | 适用场景 |
|---|---|---|
beforeInteractive | hydration 前 | 关键脚本(Cookie 同意、Bot 检测) |
afterInteractive | hydration 后(默认) | 分析工具(GA、GTM) |
lazyOnload | 浏览器空闲时 | 聊天组件、社交嵌入 |
worker | Web 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
- Core Web Vitals 是性能优化的北极星指标:LCP(加载)、INP(交互)、CLS(稳定)
- Bundle 瘦身是最直接的性能提升:动态导入、替换大包、减少 “use client”
- next/image 和 next/font 是必须的:自动优化格式、尺寸、预加载
- Streaming + Suspense 提升感知性能:让用户更快看到内容
- 缓存策略决定了 TTFB:静态缓存 → ISR → 动态渲染,逐级变慢
- 性能优化是一个持续过程:定期 Lighthouse 审计,关注 RUM 数据
下一步
下一章我们将深入 SEO 与 Metadata API——构建完整的 SEO 优化体系,包括 sitemap 生成、robots.txt、OpenGraph 图片、结构化数据等。
参考资料
- Next.js 性能优化官方指南
- Core Web Vitals
- next/image 文档
- next/font 文档
- next/script 文档
- @next/bundle-analyzer
- web-vitals 库
- Lighthouse
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。