
在使用 Next.js 构建快速且可扩展的 Web 应用时,理解渲染机制(尤其是结合 App Router 时)至关重要。Next.js 围绕两种主要环境组织渲染:服务器和客户端。在服务器端,有三种关键策略:静态渲染、动态渲染和流式渲染。每种策略都有其独特的权衡和性能优势,因此了解何时使用哪种策略对于提供出色的用户体验至关重要。
在本文中,我们将详细解析每种策略的特点、适用场景,以及它们对网站性能(尤其是核心网络指标)的影响。我们还将探讨混合方法,并提供实用指南,帮助您为具体用例选择合适的策略。
什么是核心网络指标?
核心网络指标是谷歌定义的一组衡量网站真实用户体验的指标。这些指标在搜索引擎排名中起着重要作用,并直接影响用户对网站速度和流畅度的感知。
-
最大内容绘制 (LCP) :衡量加载性能,计算最大可见内容元素渲染所需的时间。良好的 LCP 应在 2.5 秒或更短。
-
交互到下一次绘制 (INP) :衡量对用户输入的响应速度。良好的 INP 应在 200 毫秒或更短。
-
累积布局偏移 (CLS) :衡量页面的视觉稳定性,量化加载过程中的布局不稳定性。良好的 CLS 应在 0.1 或更低。
如果您想深入了解核心网络指标及其对网站性能的影响,建议阅读这篇关于新核心网络指标及其工作原理的详细指南。
Next.js 渲染策略与核心网络指标
让我们详细探讨每种渲染策略:
1. 静态渲染(服务器渲染策略)
静态渲染是 Next.js 中服务器组件的默认策略。通过这种方法,组件在构建时(或重新验证期间)进行渲染,生成的 HTML 会被重复用于每个请求。这种预渲染发生在服务器上,而不是用户的浏览器中。静态渲染非常适合数据不针对用户个性化的路由,适用于以下场景:
- 以内容为中心的网站:博客、文档、营销页面
- 电子商务产品列表:当产品详情不频繁更改时
- 对 SEO 至关重要的页面:当搜索引擎可见性是优先事项时
- 高流量页面:当您希望最小化服务器负载时
静态渲染如何影响核心网络指标
-
最大内容绘制 (LCP) :静态渲染通常会带来出色的 LCP 分数(通常 < 1 秒)。预渲染的 HTML 可以被缓存并从 CDN 即时交付,从而非常快速地交付初始内容(包括最大元素)。此外,无需等待客户端的数据获取或渲染。
-
交互到下一次绘制 (INP) :静态渲染为 INP 提供了良好的基础,但不能保证最佳性能(具体取决于实现,通常在 50-150 毫秒范围内)。虽然服务器组件不需要水合,但页面内的任何客户端组件仍然需要 JavaScript 才能变得可交互。要获得非常好的 INP 分数,您需要确保页面内的客户端组件最少。
-
累积布局偏移 (CLS) :虽然静态渲染会预先交付完整的页面结构,这对 CLS 非常有益,但要实现出色的 CLS,还需要额外的优化策略:
- 如果资源异步加载,仅静态 HTML 无法防止布局偏移
- 必须正确指定图像尺寸,以便在图像加载前预留空间
- 如果不使用字体显示策略正确处理 Web 字体,可能会导致文本重排
- 动态注入的内容(广告、嵌入、延迟加载元素)可能会破坏布局稳定性
- CSS 实现对 CLS 有显著影响 ------ 样式信息的即时可用性有助于保持视觉稳定性
代码示例:
基本静态渲染:
tsx
// app/page.tsx(服务器组件 - 默认静态渲染)
export default async function Page() {
const res = await fetch('https://api.example.com/static-data');
const data = await res.json();
return (
<div>
<h1>静态内容</h1>
<p>{data.content}</p>
</div>
);
}
带重新验证的静态渲染(ISR) :
tsx
// app/dashboard/page.tsx
export default async function Dashboard() {
// 每天重新验证的静态数据
const siteStats = await fetch('https://api.example.com/site-stats', {
next: { revalidate: 86400 } // 24 小时
}).then(r => r.json());
// 每小时重新验证的数据
const popularProducts = await fetch('https://api.example.com/popular-products', {
next: { revalidate: 3600 } // 1 小时
}).then(r => r.json());
// 带有缓存标签的按需重新验证数据
const featuredContent = await fetch('https://api.example.com/featured-content', {
next: { tags: ['featured'] }
}).then(r => r.json());
return (
<div className="dashboard">
<section className="stats">
<h2>网站统计数据</h2>
<p>总用户数: {siteStats.totalUsers}</p>
<p>总订单数: {siteStats.totalOrders}</p>
</section>
<section className="popular">
<h2>热门产品</h2>
<ul>
{popularProducts.map(product => (
<li key={product.id}>{product.name} - {product.sales} 已售出</li>
))}
</ul>
</section>
<section className="featured">
<h2>精选内容</h2>
<div>{featuredContent.html}</div>
</section>
</div>
);
}
静态路径生成:
tsx
// app/products/[id]/page.tsx
export async function generateStaticParams() {
const products = await fetch('https://api.example.com/products').then(r => r.json());
return products.map((product) => ({
id: product.id.toString(),
}));
}
export default async function Product({ params }) {
const product = await fetch(`https://api.example.com/products/${params.id}`).then(r => r.json());
return (
<div>
<h1>{product.name}</h1>
<p>${product.price.toFixed(2)}</p>
<p>{product.description}</p>
</div>
);
}
2. 动态渲染(服务器渲染策略)
动态渲染在每个请求时在服务器上为该请求生成 HTML。与静态渲染不同,内容不会被预渲染或缓存,而是为每个用户重新生成。这种渲染方式最适合以下场景:
- 个性化内容:用户仪表盘、账户页面
- 实时数据:股票价格、实时体育比分
- 特定于请求的信息:使用 cookie、标头或搜索参数的页面
- 频繁更改的数据:每次请求都需要最新的内容
动态渲染如何影响核心网络指标
- 最大内容绘制 (LCP) :使用动态渲染时,服务器需要为每个请求生成 HTML,并且无法在 CDN 级别完全缓存。但它仍然比客户端渲染更快,因为 HTML 是在服务器上生成的。
- 交互到下一次绘制 (INP) :页面加载后,性能与静态渲染类似。但是,如果动态内容包含许多客户端组件,它可能会变慢。
- 累积布局偏移 (CLS) :如果请求时获取的数据与静态结构相比显著改变了页面的布局,动态渲染可能会引入 CLS。但是,如果布局稳定且动态内容的大小适合预定义的区域,则可以有效地管理 CLS。
代码示例:
显式动态渲染:
tsx
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'; // 强制此路由进行动态渲染
export default async function Dashboard() {
// 这将在每个请求上运行
const data = await fetch('https://api.example.com/dashboard-data').then(r => r.json());
return (
<div>
<h1>仪表盘</h1>
<p>上次更新: {new Date().toLocaleString()}</p>
{/* 仪表盘内容 */}
</div>
);
}
带 cookie 的隐式动态渲染:
tsx
// app/profile/page.tsx
import { cookies } from 'next/headers';
export default async function Profile() {
// 使用 cookies() 自动选择动态渲染
const userId = cookies().get('userId')?.value;
const user = await fetch(`https://api.example.com/users/${userId}`).then(r => r.json());
return (
<div>
<h1>欢迎,{user.name}</h1>
<p>电子邮件: {user.email}</p>
{/* 个人资料内容 */}
</div>
);
}
动态路由:
tsx
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
// 对于任何未显式预渲染的 slug,它将在请求时运行
const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
3. 流式渲染(服务器渲染策略)
流式渲染允许您从服务器逐步渲染 UI。服务器不必等待所有数据准备好后再发送任何 HTML,而是在数据可用时发送 HTML 块。这是通过 React 的 Suspense 边界实现的。
React Suspense 的工作原理是在组件树中创建边界,这些边界可以在等待异步操作时 "暂停" 渲染。当 Suspense 边界内的组件抛出 Promise(在 React 服务器组件中进行数据获取时会自动发生这种情况)时,React 会暂停该组件及其子组件的渲染,渲染 Suspense 组件中指定的回退 UI,继续渲染此边界外的页面其他部分,并在 Promise 解决后最终恢复并将回退替换为实际组件。
在流式渲染时,这种机制允许服务器发送带有暂停组件回退的初始 HTML,同时在后台继续处理暂停的组件。然后,服务器会在每个暂停组件解决时流式传输额外的 HTML 块,包括指示浏览器将回退无缝替换为最终内容的指令。它适用于以下场景:
- 具有混合数据需求的页面:一些数据源快,一些慢
- 改善感知性能:在较慢的部分加载时快速向用户显示内容
- 复杂的仪表盘:不同的小部件有不同的加载时间
- 处理慢速 API:防止慢速的第三方服务阻塞整个页面
流式渲染如何影响核心网络指标
- 最大内容绘制 (LCP) :流式渲染可以改善感知的 LCP。通过快速发送初始 HTML 内容(包括可能的最大元素),浏览器可以更快地渲染它。即使页面的其他部分仍在加载,用户也能更快地看到主要内容。
- 交互到下一次绘制 (INP) :流式渲染可以有助于更好的 INP。当与 React 的 一起使用时,页面中加载较快部分的交互元素可以更早地变得可交互,即使其他组件仍在流式传输中。这允许用户更早地与页面互动。
- 累积布局偏移 (CLS) :当新内容流式传输时,流式渲染可能会导致布局偏移。但是,如果仔细实现,流式渲染应该不会对 CLS 产生负面影响。最初流式传输的内容应该建立主要布局,后续流式传输的块应该理想地适合此结构,而不会导致明显的重排或布局偏移。使用占位符并确保尺寸已知可以帮助防止 CLS。
代码示例:
使用 Suspense 的基本流式渲染:
tsx
// app/dashboard/page.tsx
import { Suspense } from 'react';
import UserProfile from './components/UserProfile';
import RecentActivity from './components/RecentActivity';
import PopularPosts from './components/PopularPosts';
export default function Dashboard() {
return (
<div className="dashboard">
{/* 这部分加载很快 */}
<h1>仪表盘</h1>
{/* 用户资料首先加载 */}
<Suspense fallback={<div className="skeleton-profile">加载资料中...</div>}>
<UserProfile />
</Suspense>
{/* 最近活动可能需要更长时间 */}
<Suspense fallback={<div className="skeleton-activity">加载活动中...</div>}>
<RecentActivity />
</Suspense>
{/* 热门帖子可能是最慢的 */}
<Suspense fallback={<div className="skeleton-posts">加载热门帖子中...</div>}>
<PopularPosts />
</Suspense>
</div>
);
}
用于更精细控制的嵌套 Suspense 边界:
tsx
// app/complex-page/page.tsx
import { Suspense } from 'react';
export default function ComplexPage() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header />
<div className="content-grid">
<div className="main-content">
<Suspense fallback={<MainContentSkeleton />}>
<MainContent />
</Suspense>
</div>
<div className="sidebar">
<Suspense fallback={<SidebarTopSkeleton />}>
<SidebarTopSection />
</Suspense>
<Suspense fallback={<SidebarBottomSkeleton />}>
<SidebarBottomSection />
</Suspense>
</div>
</div>
<Footer />
</Suspense>
);
}
使用 Next.js loading.js 约定:
tsx
// app/products/loading.tsx - 这将自动用作 Suspense 回退
export default function Loading() {
return (
<div className="products-loading-skeleton">
<div className="header-skeleton" />
<div className="filters-skeleton" />
<div className="products-grid-skeleton">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="product-card-skeleton" />
))}
</div>
</div>
);
}
// app/products/page.tsx
export default async function ProductsPage() {
// 这个组件可能需要时间加载
// Next.js 会自动将其包装在 Suspense 中
// 并使用 loading.js 作为回退
const products = await fetchProducts();
return <ProductsList products={products} />;
}
4. 客户端组件和客户端渲染
客户端组件使用 React 的 'use client'
指令定义。它们在服务器上预渲染,然后在客户端水合,从而实现交互性。这与纯客户端渲染 (CSR) 不同,在纯客户端渲染中,渲染完全在浏览器中进行。在传统意义上的 CSR(初始 HTML 很少,所有渲染都在浏览器中进行)中,Next.js 已不再将其作为默认方法,但仍可以通过使用动态导入并设置 ssr: false
来实现。
tsx
// app/csr-example/page.tsx
'use client';
import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
// 延迟加载一个没有 SSR 的组件
const ClientOnlyComponent = dynamic(
() => import('../components/heavy-component'),
{ ssr: false, loading: () => <p>加载中...</p> }
);
export default function CSRPage() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return (
<div>
<h1>客户端渲染页面</h1>
{isClient ? (
<ClientOnlyComponent />
) : (
<p>加载客户端组件...</p>
)}
</div>
);
}
尽管向服务器渲染转变,但 CSR 仍有一些有效用例:
-
私有仪表盘:在 SEO 不重要且您希望减少服务器负载的情况下
-
重度交互应用:如数据可视化工具或复杂编辑器
-
仅浏览器 API:当您需要访问特定于浏览器的功能(如 localStorage 或 WebGL)时
-
第三方集成:一些仅在浏览器中工作的第三方小部件或库
虽然这些是有效用例,但在 Next.js 中,使用客户端组件通常比纯 CSR 更可取。客户端组件为您提供了两全其美的优势:初始加载时的服务器渲染 HTML(改善 SEO 和 LCP)以及水合后的客户端交互性。纯 CSR 应保留给服务器渲染不可能或适得其反的特定场景。
客户端组件适用于:
- 交互式 UI 元素:表单、下拉菜单、模态框、选项卡
- 依赖状态的 UI:基于客户端状态更改的组件
- 浏览器 API 访问:需要 localStorage、地理位置等的组件
- 事件驱动的交互:点击处理程序、表单提交、动画
- 实时更新:聊天界面、实时通知
客户端组件如何影响核心网络指标
- 最大内容绘制 (LCP) :初始 HTML 包含客户端组件的服务器渲染版本,因此 LCP 相当快。水合可能会延迟交互性,但不一定会影响 LCP。
- 交互到下一次绘制 (INP) :对于客户端组件,水合可能会在页面加载期间导致输入延迟,并且当页面水合时,性能取决于事件处理程序的效率。此外,复杂的状态管理可能会影响响应性。
- 累积布局偏移 (CLS) :客户端数据获取可能会在新数据到达时导致布局偏移。此外,状态更改可能会意外地改变布局。使用客户端组件需要仔细实现以防止偏移。
代码示例:
基本客户端组件:
tsx
// app/components/Counter.tsx
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>计数: {count}</p>
<button onClick={() => setCount(count + 1)}>增加</button>
</div>
);
}
带有服务器数据的客户端组件:
tsx
// app/products/page.tsx - 服务器组件
import ProductFilter from '../components/ProductFilter';
export default async function ProductsPage() {
// 在服务器上获取数据
const products = await fetch('https://api.example.com/products').then(r => r.json());
// 将服务器数据作为 props 传递给客户端组件
return <ProductFilter initialProducts={products} />;
}
混合方法和组合模式
在实际应用中,您通常会结合使用多种渲染策略来获得最佳性能。Next.js 使服务器组件和客户端组件的组合变得容易。
带有交互孤岛的服务器组件
最有效的模式之一是将服务器组件用于大部分 UI,并仅在需要交互性的地方添加客户端组件。这种方法:
-
最小化发送到客户端的 JavaScript
-
提供出色的初始加载性能
-
在需要的地方保持良好的交互性
tsx
// app/products/[id]/page.tsx - 服务器组件
import AddToCartButton from '../../components/AddToCartButton';
import ProductReviews from '../../components/ProductReviews';
import RelatedProducts from '../../components/RelatedProducts';
export default async function ProductPage({ params }: {
params: { id: string; }
}) {
// 在服务器上获取产品数据
const product = await fetch(`https://api.example.com/products/${params.id}`).then(r => r.json());
return (
<div className="product-page">
<div className="product-main">
<h1>{product.name}</h1>
<p className="price">${product.price.toFixed(2)}</p>
<div className="description">{product.description}</div>
{/* 用于交互性的客户端组件 */}
<AddToCartButton product={product} />
</div>
{/* 用于产品评论的服务器组件 */}
<ProductReviews productId={params.id} />
{/* 用于相关产品的服务器组件 */}
<RelatedProducts categoryId={product.categoryId} />
</div>
);
}
部分预渲染(Next.js 15)
Next.js 15 引入了部分预渲染,这是一种新的混合渲染策略,可在单个路由中结合静态和动态内容。这使您能够:
-
静态生成页面的框架
-
流式传输动态、个性化内容
-
获得静态渲染和动态渲染的最佳效果
注意:在撰写本文时,部分预渲染仍处于实验阶段,尚未准备好投入生产使用。了解更多信息
tsx
// app/dashboard/page.tsx
import { unstable_noStore as noStore } from 'next/cache';
import StaticContent from './components/StaticContent';
import DynamicContent from './components/DynamicContent';
export default function Dashboard() {
return (
<div className="dashboard">
{/* 这部分是静态生成的 */}
<StaticContent />
{/* 这部分是动态渲染的 */}
<DynamicPart />
</div>
);
}
// 这个组件及其子组件将被动态渲染
function DynamicPart() {
// 为此部分取消缓存
noStore();
return <DynamicContent />;
}
在 Next.js 中测量核心网络指标
了解渲染策略选择的影响需要在实际条件下测量核心网络指标。以下是一些方法:
1. Vercel 分析
如果您在 Vercel 上部署,您可以使用 Vercel 分析来自动跟踪生产站点的核心网络指标:
tsx
// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
export default function RootLayout({ children }: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
{children}
<Analytics />
</body>
</html>
);
}
2. Web Vitals API
您可以使用 web-vitals 库手动跟踪核心网络指标:
tsx
// app/components/WebVitalsReporter.tsx
'use client';
import { useEffect } from 'react';
import { onCLS, onINP, onLCP } from 'web-vitals';
export function WebVitalsReporter() {
useEffect(() => {
// 报告核心网络指标
onCLS(metric => console.log('CLS:', metric.value));
onINP(metric => console.log('INP:', metric.value));
onLCP(metric => console.log('LCP:', metric.value));
// 在实际应用中,您会将这些发送到您的分析服务
}, []);
return null; // 这个组件不渲染任何内容
}
3. Lighthouse 和 PageSpeed Insights
对于开发和测试,使用:
- Chrome DevTools 中的 Lighthouse 选项卡
- PageSpeed Insights
- Chrome 用户体验报告
做出实际决策:选择哪种渲染策略?
选择正确的渲染策略取决于您的具体要求。以下是一个决策框架:
选择静态渲染时
- 内容对所有用户都相同
- 数据可以在构建时确定
- 页面不需要频繁更新
- SEO 至关重要
- 您希望获得最佳性能
选择动态渲染时
- 内容为每个用户个性化
- 数据必须在每次请求时都是最新的
- 您需要访问请求时的信息
- 内容频繁更改
选择流式渲染时
- 页面有快速和慢速数据需求的混合
- 您希望改善感知性能
- 页面的某些部分依赖于慢速 API
- 您希望优先显示关键 UI
选择客户端组件时
- UI 需要是交互式的
- 组件依赖于浏览器 API
- UI 基于用户输入频繁更改
- 您需要实时更新
结论
Next.js 提供了一组强大的渲染策略,使您能够针对性能和用户体验进行优化。通过了解每种策略如何影响核心网络指标,您可以就是否构建应用程序做出明智的决策。
请记住,最佳方法通常是混合方法,根据应用程序每个部分的特定要求组合不同的渲染策略。默认情况下从服务器组件开始,在可能的情况下使用静态渲染,并且仅在需要交互性的地方添加客户端组件。
通过遵循这些原则并测量您的核心网络指标,您可以创建快速、响应迅速并提供出色用户体验的 Next.js 应用程序。