8 reasons your Next.js app is slow --- and how to fix them - LogRocket Blog
用户感知速度
- 0.1 秒 --- 系统感觉瞬时的极限。在此情况下,用户不会感知到任何延迟
- 1.0 秒 --- 保持用户思维流不间断的上限,即使他们会注意到延迟
- 10 秒 --- 用户完全失去注意力和注意力之前的最长时间
解决方案:使用加载状态和 React Suspense

jsx
import { Suspense } from 'react';
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<DashboardSkeleton />}>
<DashboardContent />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
</div>
);
}
SSR 渲染影响速度
在第一次加载时,Next.js 应用的行为类似于传统的服务器呈现网站。它在服务器上获取数据,呈现完整的 HTML,并将其发送到浏览器。这对于 SEO 非常有用
js
// First visit: Server does all the heavy lifting
export async function getServerSideProps() {
// This runs on the server, blocking the response
const userData = await fetchUser();
const dashboardData = await fetchDashboard(userData.id);
const notifications = await fetchNotifications(userData.id);
return {
props: {
userData,
dashboardData,
notifications
}
};
}
但是一旦加载了初始页面并且 React 接管了,Next.js 就会切换到 SPA 模式。单击会触发客户端导航,无需重新加载整页------就像 React Router 一样
js
// Subsequent navigation: Pure SPA behavior
function Dashboard({ userData }) {
const router = useRouter();
const goToProfile = () => {
// This doesn't hit the server - pure client-side navigation
router.push('/profile');
};
return (
<div>
<h1>Welcome, {userData.name}</h1>
<button onClick={goToProfile}>View Profile</button>
</div>
);
}
性能问题出现的地方
- 在服务器上,缓慢的数据库查询或阻塞作会延迟 HTML 响应
- 在客户端上,过大的 JavaScript 捆绑包或低效的 API 调用会影响导航响应速度
同步获取数据
js
// The slow way - each request waits for the previous one
export async function getServerSideProps({ req }) {
// Step 1: Get user (300ms)
const user = await fetchUser(req.session.userId);
// Step 2: Wait for user, then get profile (400ms)
const profile = await fetchUserProfile(user.id);
// Step 3: Wait for profile, then get dashboard data (600ms)
const dashboardData = await fetchDashboardData(user.id, profile.preferences);
// Step 4: Wait for dashboard, then get notifications (200ms)
const notifications = await fetchNotifications(user.id);
// Total time: 300 + 400 + 600 + 200 = 1,500ms (1.5 seconds!)
return {
props: {
user,
profile,
dashboardData,
notifications
}
};
}
使用 Promise.all()
优化
js
export async function getServerSideProps({ req }) {
// Step 1: Get user first (still needed for other requests)
const user = await fetchUser(req.session.userId);
// Step 2: Fetch everything else in parallel
const [profile, dashboardData, notifications] = await Promise.all([
fetchUserProfile(user.id), // 400ms
fetchDashboardData(user.id), // 600ms
fetchNotifications(user.id) // 200ms
]);
// Total time: 300ms (user) + 600ms (longest parallel request) = 900ms
// We just saved 600ms!
return {
props: {
user,
profile,
dashboardData,
notifications
}
};
}
并行获取全局数据
如果某些数据不依赖于用户,例如系统范围的设置或服务器状态,您可以与用户接口并行调用
js
export async function getServerSideProps({ req }) {
// Fetch user-independent data alongside user data
const [user, globalSettings, systemStatus] = await Promise.all([
fetchUser(req.session.userId),
fetchGlobalSettings(), // Doesn't need user
fetchSystemStatus() // Doesn't need user
]);
// Now fetch user-dependent data in parallel
const [profile, dashboardData, notifications] = await Promise.all([
fetchUserProfile(user.id),
fetchDashboardData(user.id),
fetchNotifications(user.id)
]);
return {
props: {
user,
profile,
dashboardData,
notifications,
globalSettings,
systemStatus
}
};
}
您的路由触发不必要的服务器往返
使用 Next.js App Router,每次导航都可能触发服务器端渲染。
js
// app/products/[id]/page.js
// This runs on the SERVER for every product page visit
export default async function ProductPage({ params }) {
// Network call to server on every navigation
const product = await fetchProduct(params.id);
const reviews = await fetchReviews(params.id);
const recommendations = await fetchRecommendations(params.id);
return (
<div>
<ProductDetails product={product} />
<ReviewsList reviews={reviews} />
<RecommendationGrid recommendations={recommendations} />
</div>
);
}
使用客户端路由
js
// app/products/[id]/page.js
'use client'; // This makes it client-side rendered
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
export default function ProductPage() {
const params = useParams();
const [product, setProduct] = useState(null);
const [reviews, setReviews] = useState(null);
useEffect(() => {
// Fetch data client-side - no server round trip
Promise.all([
fetch(`/api/products/${params.id}`).then(res => res.json()),
fetch(`/api/reviews/${params.id}`).then(res => res.json())
]).then(([productData, reviewsData]) => {
setProduct(productData);
setReviews(reviewsData);
});
}, [params.id]);
if (!product) return <ProductSkeleton />;
return (
<div>
<ProductDetails product={product} />
<ReviewsList reviews={reviews} />
</div>
);
}
何时使用服务器端渲染与客户端渲染
-
在以下情况下使用服务器端渲染(SSR)
- SEO 至关重要(产品页面、博客文章)
- 您显示的内容是和用户敏感数据有关
- 初始页面加载速度比导航速度更重要
- 内容不经常更改
-
在以下情况下使用客户端渲染(CSR)
- 用户经常在页面之间导航
- 您可以有效地缓存数据
- SEO 不是优先事项(用户仪表板、管理面板)
- 您想要即时、类似应用程序的导航
混合渲染
有时,您需要 SSR 来加载初始页面,但希望在后续交互中获得 CSR 的好处。
js
// app/products/[id]/page.js
// Server-render the initial page for SEO
export default async function ProductPage({ params }) {
const initialProduct = await fetchProduct(params.id);
return (
<div>
<ProductClient initialData={initialProduct} productId={params.id} />
</div>
);
}
// components/ProductClient.js
'use client';
export default function ProductClient({ initialData, productId }) {
const [product, setProduct] = useState(initialData);
// Subsequent navigation is client-side
const router = useRouter();
const navigateToProduct = async (newId) => {
// Update URL immediately (feels instant)
router.push(`/products/${newId}`);
// Fetch new data in background
const newProduct = await fetch(`/api/products/${newId}`).then(res => res.json());
setProduct(newProduct);
};
return (
<div>
<ProductDetails product={product} />
<RelatedProducts onProductClick={navigateToProduct} />
</div>
);
}
JavaScript 捆绑包
JavaScript 捆绑包大小直接影响您的交互时间(TTI),这是衡量页面何时完全发挥作用的指标。
js
// First Bundle bloater: Importing entire libraries;
import _ from 'lodash'; // Imports the entire 70KB library
import * as dateFns from 'date-fns'; // Another massive import
// Second Bundle bloater: Importing heavy components everywhere
import { DataVisualization } from './DataVisualization'; // 500KB component
import { VideoPlayer } from './VideoPlayer'; // 300KB component
import { RichTextEditor } from './RichTextEditor'; // 400KB component
export default function HomePage() {
return (
<div>
<h1>Welcome</h1>
{/* These components might not even be visible on initial load */}
<DataVisualization />
<VideoPlayer />
<RichTextEditor />
</div>
);
}
代码拆分和动态导入
Next.js 支持开箱即用的智能代码拆分。仅在需要时使用动态导入来加载代码。
基于路由的代码拆分
默认情况下,Next.js 按路由拆分代码。但是您可以使用 next/dynamic
优化它
js
// pages/dashboard.js - Only loads when users visit /dashboard
import dynamic from 'next/dynamic';
// Heavy components loaded only when needed
const AnalyticsChart = dynamic(() => import('../components/AnalyticsChart'), {
loading: () => <ChartSkeleton />,
ssr: false // Skip server-side rendering for client-only components
});
const DataExporter = dynamic(() => import('../components/DataExporter'), {
loading: () => <p>Loading exporter...</p>
});
export default function Dashboard() {
const [showAnalytics, setShowAnalytics] = useState(false);
const [showExporter, setShowExporter] = useState(false);
return (
<div>
<h1>Dashboard</h1>
<button onClick={() => setShowAnalytics(true)}>
View Analytics
</button>
{showAnalytics && <AnalyticsChart />}
<button onClick={() => setShowExporter(true)}>
Export Data
</button>
{showExporter && <DataExporter />}
</div>
);
}
组件级代码拆分
跨路由共享的组件,仅在特定情况下需要,可以延迟加载这些组件
js
// components/ConditionalFeatures.js
import dynamic from 'next/dynamic';
// Load only when user has premium subscription
const PremiumChart = dynamic(() => import('./PremiumChart'), {
loading: () => <div>Loading premium features...</div>
});
// Load only when user clicks "Advanced Settings"
const AdvancedSettings = dynamic(() => import('./AdvancedSettings'));
export function ConditionalFeatures({ user, showAdvanced }) {
return (
<div>
{user.isPremium && <PremiumChart />}
{showAdvanced && <AdvancedSettings />}
</div>
);
}
使用 @next/bundle-analyzer
分析捆绑包
要查看什么正在占用您的捆绑包大小,请使用官方捆绑包分析器:
js
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true'
});
module.exports = withBundleAnalyzer({
// Your Next.js config
});
水合作用
在服务器将 HTML 发送到浏览器后,React 需要通过附加事件侦听器并将其虚拟 DOM 与服务器渲染的标记进行协调来"水合"它。此过程可能会阻止交互性并影响性能。
js
// The traditional Next.js page with hydration bottlenecks
export default function ProductPage({ products }) {
return (
<div>
<Header /> {/* Must hydrate before user can interact */}
<ProductGrid products={products} /> {/* Large component tree */}
<FilterSidebar /> {/* Complex interactive components */}
<Footer /> {/* Static content that doesn't need JS */}
{/* Everything hydrates at once, blocking interactivity */}
</div>
);
}
使用 React 服务器组件和部分水合
Next.js App Router 带来了 React Server 组件,它通过让您选择应用程序的哪些部分需要客户端 JavaScript
js
// app/products/page.js - Server Component (no JS sent to client)
import { ProductGrid } from './components/ProductGrid';
import { ClientSideFilter } from './components/ClientSideFilter';
// This component runs on the server and sends only HTML
export default async function ProductPage() {
// Data fetching happens on the server
const products = await fetchProducts();
return (
<div>
<h1>Products</h1>
{/* Static parts remain as HTML only */}
<ProductGrid products={products} />
{/* Only interactive parts are hydrated */}
<ClientSideFilter products={products} />
</div>
);
}
// components/ClientSideFilter.js
'use client'; // Marks this as needing hydration
export function ClientSideFilter({ products }) {
const [filters, setFilters] = useState({});
// Interactive component logic...
}
没有缓存数据
- 重复获取相同的数据
js
export default function UserProfile({ userId }) {
const [user, setUser] = useState(null);
// This runs on every component mount - no caching!
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((res) => res.json())
.then(setUser);
}, [userId]);
return user ? user.name : "Loading...";
}
- 在每次页面加载时提取新数据
js
export async function getServerSideProps({ params }) {
// This hits the database on every single request
const posts = await db.posts.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
});
return { props: { posts } };
}
尽可能使用 SSG 和 SWR
有效的缓存适用于不同的级别:API 路由、页面渲染,甚至数据库查询。
使用 ISR 的服务器端缓存
如果您的数据不是实时更新,请不要实时重新提取它。使用增量静态重新生成 (ISR) 提供预构建的页面并偶尔刷新它们
js
// pages/blog/[slug].js
export async function getStaticProps({ params }) {
const post = await fetchPost(params.slug);
return {
props: { post },
revalidate: 3600, // Regenerate at most once per hour
};
}
export async function getStaticPaths() {
// Generate paths for popular posts
const popularPosts = await fetchPopularPosts();
return {
paths: popularPosts.map((post) => ({
params: { slug: post.slug }
})),
fallback: 'blocking' // Generate other pages on-demand
};
}
在 API 上使用 cache-control
对于昂贵的 API 作,请使用 unstable_cache 缓存服务器端逻辑
js
// pages/api/posts.js
import { unstable_cache } from 'next/cache';
const getCachedPosts = unstable_cache(
async () => {
// Expensive database query
return await db.posts.findMany({
include: {
author: true,
comments: { take: 5 },
tags: true
},
orderBy: { createdAt: 'desc' }
});
},
['posts-list'],
{
revalidate: 300, // Cache for 5 minutes
tags: ['posts']
}
);
export default async function handler(req, res) {
const posts = await getCachedPosts();
res.setHeader('Cache-Control', 'public, s-maxage=300, stale-while-revalidate=600');
res.json(posts);
}
静态资源
原始 <img>
标签
javascript
export default function ProductCard({ product }) {
return (
<>
<img src={product.image} alt={product.name} />
{product.name}
{product.price}
</>
);
}
热切加载所有内容
js
export default function Gallery({ images }) {
return (
<div className="gallery">
{images.map((image, index) => (
// All 50 images load at once, even if users only see 6
<img key={index} src={image.url} alt={image.caption} />
))}
</div>
);
}
使用响应式的 next/image
Next.js 提供了一个图像组件,用于处理响应式大小调整、延迟加载和格式转换(如 WebP/AVIF)。它更快、更易于访问,并节省了大量带宽。
基础优化
js
// components/ProductCard.js
import Image from "next/image";
export default function ProductCard({ product }) {
return (
<div className="product-card">
<Image
src={product.imageUrl}
alt={product.name}
width={300}
height={200}
priority={product.featured} // Load featured products immediately
placeholder="blur"
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R//2Q=="
className="rounded-lg object-cover"
/>
<h3>{product.name}</h3>
<p>{product.price}</p>
</div>
);
}
响应式图片
对于在不同屏幕上以不同尺寸显示的图像
js
// components/HeroSection.js
import Image from "next/image";
export default function HeroSection() {
return (
<div className="hero relative h-screen">
<Image
src="/hero-image.jpg"
alt="Hero image"
fill
priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center">
<h1 className="text-white text-6xl font-bold">Welcome</h1>
</div>
</div>
);
}
延迟加载
js
// components/ImageGallery.js
import Image from "next/image";
import { useState } from "react";
export default function ImageGallery({ images }) {
const [visibleCount, setVisibleCount] = useState(6);
const loadMore = () => {
setVisibleCount((prev) => Math.min(prev + 6, images.length));
};
return (
<div className="gallery">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{images.slice(0, visibleCount).map((image, index) => (
<div key={image.id} className="aspect-square relative">
<Image
src={image.url}
alt={image.caption}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover rounded-lg"
priority={index < 6} // Prioritize first 6 images
/>
</div>
))}
</div>
{visibleCount < images.length && (
<button
onClick={loadMore}
className="mt-8 px-6 py-3 bg-blue-600 text-white rounded-lg"
>
Load More ({images.length - visibleCount} remaining)
</button>
)}
</div>
);
}