Next.js 渲染策略及其对核心网络指标的影响

在使用 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 应用程序。

相关推荐
AlienZHOU35 分钟前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc4 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师5 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen6 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒6 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow6 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林8186 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang800786 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明6 小时前
第 6 章 组件进阶
前端·vue.js
江华森7 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端