
为什么 Next.js 性能需要刻意关注
Next.js 开箱即用提供了性能基础——自动代码分割、服务端渲染、图片优化。但“默认良好”并不意味着“针对你的应用优化”。
实际中的 Next.js 性能问题往往是自找的:未使用 <Image> 组件加载图片、字体导致布局偏移、因不良导入模式导致客户端包过大、以及缓存策略保持默认。
本指南涵盖了那些能对 Core Web Vitals 分数产生有意义影响的实用优化。

图片优化
图片通常是页面上最大的 Contentful Paint 元素。Next.js 的 <Image> 组件自动处理了困难部分:
import Image from 'next/image'
// 基本用法——Next.js 处理格式转换、尺寸调整、懒加载
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={630}
priority // 首屏图片应使用 priority(禁用懒加载)
/>
// 远程图片——在 next.config.js 中添加域名
<Image
src="https://cdn.example.com/avatar.jpg"
alt="User avatar"
width={64}
height={64}
className="rounded-full"
/>
// Fill 模式——填充父容器
<div style={{ position: 'relative', height: '400px' }}>
<Image
src="/background.jpg"
alt="Background"
fill
style={{ objectFit: 'cover' }}
sizes="100vw" // 对 fill 图片至关重要
/>
</div>
sizes 属性
这是最常被忽略的优化。没有 sizes,Next.js 不知道图片将渲染成多大:
// ❌ 没有 sizes——无论视口大小,Next.js 都提供全尺寸图片
<Image src="/card.jpg" alt="Card" width={400} height={300} />
// ✅ 有 sizes——提供适当尺寸的图片
<Image
src="/card.jpg"
alt="Card"
width={400}
height={300}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 400px"
// 翻译为:“移动端全宽,平板端半宽,桌面端 400px”
/>
配置图片域名
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.example.com',
pathname: '/images/**',
},
{
protocol: 'https',
hostname: '**.amazonaws.com', // 通配符子域名
},
],
// 自定义优化
formats: ['image/avif', 'image/webp'], // 支持时提供 AVIF
deviceSizes: [640, 828, 1080, 1200, 1920], // srcset 断点
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], // 固定尺寸图片
},
}
占位符与模糊
import Image from 'next/image'
// 静态导入——自动生成模糊数据 URL
import heroImage from '/public/hero.jpg'
<Image
src={heroImage}
alt="Hero"
placeholder="blur" // 加载时显示模糊
priority
/>
// 动态图片——自行生成模糊占位符
<Image
src={post.coverImage}
alt={post.title}
width={800}
height={400}
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..." // 10px 模糊
/>
字体优化
字体导致累积布局偏移(CLS)并阻塞渲染。Next.js 的 next/font 消除了这两个问题:
// app/layout.tsx
import { Inter, Fira_Code } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap', // 防止字体加载时文本不可见
variable: '--font-inter', // Tailwind 的 CSS 变量
})
const firaCode = Fira_Code({
subsets: ['latin'],
weight: ['400', '500'],
variable: '--font-fira-code',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${firaCode.variable}`}>
<body className={inter.className}>
{children}
</body>
</html>
)
}
next/font 在后台做了什么:
- 构建时下载字体(无运行时请求)
- 自托管并带有最佳缓存头
- 内联
@font-face声明,使用size-adjust防止 CLS - 生成 CSS 变量供 Tailwind 使用

本地字体
import localFont from 'next/font/local'
const brandFont = localFont({
src: [
{
path: '../fonts/BrandFont-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: '../fonts/BrandFont-Bold.woff2',
weight: '700',
style: 'normal',
},
],
variable: '--font-brand',
display: 'swap',
})
包分析
在优化之前,先测量。Next.js 有内置的包分析器:
npm install @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer({
// your config
})
# 运行分析
ANALYZE=true npm run build
常见包问题
1. 导入整个库而只需要一个函数:
// ❌ 导入整个 lodash(约 70KB gzipped)
import _ from 'lodash'
const result = _.groupBy(data, 'category')
// ✅ 只导入需要的(约 1KB)
import groupBy from 'lodash/groupBy'
const result = groupBy(data, 'category')
// ✅ 或使用原生替代
const result = data.reduce((acc, item) => {
(acc[item.category] ??= []).push(item)
return acc
}, {} as Record<string, typeof data>)
2. 日期库:
// ❌ moment.js = 67KB gzipped,包含所有语言环境
import moment from 'moment'
// ✅ date-fns = 可 tree-shake
import { format, parseISO } from 'date-fns'
// 或使用 Intl API 进行基本格式化(零字节!)
new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(date)
3. 图标库:
// ❌ 导入整个图标集
import { IconSettings, IconUser } from '@tabler/icons-react'
// ✅ 相同——现代包是可 tree-shake 的
// 但通过包分析器验证 tree-shaking 是否生效
代码分割策略
Next.js 自动按路由进行代码分割。但路由内的大型组件仍会阻塞渲染:
import dynamic from 'next/dynamic'
// 懒加载重型组件
const MarkdownEditor = dynamic(() => import('./MarkdownEditor'), {
loading: () => <p>Loading editor...</p>,
ssr: false, // 如果是客户端专用(例如使用 DOM API),则不进行 SSR
})
// 条件加载
const AdminPanel = dynamic(() => import('./AdminPanel'))
function Page({ user }: { user: User }) {
return (
<div>
<UserProfile user={user} />
{user.isAdmin && <AdminPanel />} {/* 仅对管理员加载 */}
</div>
)
}
预加载
// 在需要之前预加载组件
const AdminPanel = dynamic(() => import('./AdminPanel'))
function Page({ user }: { user: User }) {
// 悬停时预加载——常见 UX 模式
function handleMouseEnter() {
AdminPanel.preload?.()
}
return (
<button onMouseEnter={handleMouseEnter} onClick={openAdmin}>
Admin Panel
</button>
)
}

缓存策略
Next.js 14+ 具有分层缓存系统:
// 1. 静态渲染(构建时)——最快
// 没有动态数据的 page.tsx = 静态渲染
export default async function StaticPage() {
const data = await fetch('https://api.example.com/static-data', {
cache: 'force-cache' // 无限期缓存
})
return <Component data={data} />
}
// 2. ISR——按间隔重新验证
export const revalidate = 3600 // 每小时重新验证
export default async function IsrPage() {
const posts = await fetch('/api/posts', {
next: { revalidate: 3600 }
}).then(r => r.json())
return <PostList posts={posts} />
}
// 3. 按需重新验证
import { revalidatePath, revalidateTag } from 'next/cache'
export async function POST(request: Request) {
// 由 webhook 在内容更改时调用
await revalidatePath('/blog') // 重新验证特定路径
await revalidateTag('posts') // 重新验证所有带有此标签的 fetch
return Response.json({ revalidated: true })
}
// 4. 动态(无缓存)——始终新鲜
export const dynamic = 'force-dynamic'
// 或:
const data = await fetch(url, { cache: 'no-store' })
测量 Core Web Vitals
// app/layout.tsx — Web Vitals 报告
'use client'
import { useReportWebVitals } from 'next/web-vitals'
export function WebVitals() {
useReportWebVitals((metric) => {
console.log(metric) // { id, name, value, rating }
// 发送到分析
if (metric.rating === 'poor') {
analytics.track('poor_web_vital', {
metric: metric.name,
value: metric.value,
page: window.location.pathname,
})
}
})
return null
}
关键指标
| 指标 | 良好 | 需要改进 | 较差 |
|---|---|---|---|
| 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 |
| FCP (First Contentful Paint) | < 1.8s | 1.8–3.0s | > 3.0s |
| TTFB (Time to First Byte) | < 800ms | 800ms–1.8s | > 1.8s |
实用优化清单
图片:
□ 所有图片使用 <Image> 组件
□ 首屏图片具有 priority 属性
□ 响应式图片具有正确的 sizes 属性
□ 重要图片使用 placeholder="blur"
字体:
□ 使用 next/font 而非 <link> 或 @import
□ 仅加载所需的子集和字重
□ 使用 font-display: swap
包:
□ 运行了包分析器,识别了最大的模块
□ 重型组件使用 dynamic() 导入
□ 没有完整的库导入(lodash、moment 等)
□ 第三方脚本使用 next/script 并指定 strategy
缓存:
□ 静态页面使用 force-cache
□ 动态页面明确指定缓存策略
□ 为偶尔变化的内容配置了 ISR
□ 为 CMS 驱动的内容配置了按需重新验证
性能:
□ 第三方脚本使用 next/script
□ 字体在 <head> 中预加载
□ 识别并优化了 Largest Contentful Paint 元素
□ CLS 分数低于 0.1(无字体/图片导致的布局偏移)
第三方脚本优化
import Script from 'next/script'
// 策略选项:
// - beforeInteractive:在页面水合之前加载(阻塞——尽量避免)
// - afterInteractive:在水合之后加载(默认——适合分析)
// - lazyOnload:在空闲时加载(适合非关键脚本)
// - worker:在 Web Worker 中加载(实验性——将 JS 移出主线程)
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
{/* 分析——在页面可交互后加载 */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_ID"
strategy="afterInteractive"
/>
{/* 聊天小部件——在空闲时加载 */}
<Script
src="https://chat.example.com/widget.js"
strategy="lazyOnload"
onLoad={() => console.log('Chat loaded')}
/>
</body>
</html>
)
}
Next.js 性能优化在于理解可用的调节旋钮并有意识地应用它们。最大的收益通常来自图片优化(使用带有正确 sizes 的 <Image>)、消除字体导致的布局偏移(切换到 next/font),以及识别和分割大型客户端包。先测量,再优化。