流式渲染

在上一章节中,您已经了解了 Next.js 的不同渲染方式。我们还讨论了慢速数据请求如何影响应用性能。现在让我们看看当存在慢速数据请求时,如何提升用户体验。

什么是流式渲染?

流式渲染是一种数据传输技术,它允许您将路由拆分为较小的"块"(chunk),并在服务器端准备就绪后逐步流式传输到客户端。

展示顺序数据获取与并行数据获取的时间对比图

通过流式渲染,您可以防止慢速数据请求阻塞整个页面。这使得用户无需等待所有数据加载完成,就能看到页面部分内容并进行交互。

展示顺序数据获取与并行数据获取的时间对比图

流式渲染与 React 的组件模型完美契合,因为每个组件都可以被视为一个_块_。

在 Next.js 中有两种实现流式渲染的方式:

  1. 在页面级别使用 loading.tsx 文件(它会自动创建 <Suspense> 边界)
  2. 在组件级别使用 <Suspense> 实现更细粒度的控制

让我们看看具体如何实现。

使用 loading.tsx 流式渲染整个页面

/app/dashboard 文件夹中创建一个名为 loading.tsx 的新文件:

/app/dashboard/loading.tsx
export default function Loading() {
  return <div>加载中...</div>;
}

刷新 http://localhost:3000/dashboard,您现在应该会看到:

显示'加载中...'文字的仪表盘页面

这里发生了以下几件事:

  1. loading.tsx 是 Next.js 基于 React Suspense 构建的特殊文件,它允许您创建备用 UI 来在页面内容加载时显示
  2. 由于 <SideNav> 是静态的,它会立即显示。用户可以在动态内容加载时与 <SideNav> 交互
  3. 用户无需等待页面完全加载即可导航离开(这称为可中断导航)

恭喜!您已经实现了流式渲染。但我们还可以进一步优化用户体验。让我们用加载骨架屏 (skeleton) 替代"加载中..."文字。

添加加载骨架屏

加载骨架屏是 UI 的简化版本。许多网站使用它们作为占位符(或备用内容)来向用户表明内容正在加载。您在 loading.tsx 中添加的任何 UI 都将作为静态文件的一部分嵌入,并首先发送。然后,其余的动态内容将从服务器流式传输到客户端。

在您的 loading.tsx 文件中,导入一个名为 <DashboardSkeleton> 的新组件:

/app/dashboard/loading.tsx
import DashboardSkeleton from '@/app/ui/skeletons';
 
export default function Loading() {
  return <DashboardSkeleton />;
}

然后刷新 http://localhost:3000/dashboard,您现在应该会看到:

带有加载骨架屏的仪表盘页面

使用路由组修复加载骨架屏的问题

目前,您的加载骨架屏也会应用于发票页面。

由于 loading.tsx 在文件系统中比 /invoices/page.tsx/customers/page.tsx 高一级,它也会应用于这些页面。

我们可以使用路由组 (Route Groups) 来解决这个问题。在 dashboard 文件夹中创建一个名为 /(overview) 的新文件夹。然后将您的 loading.tsxpage.tsx 文件移动到这个文件夹中:

展示如何使用括号创建路由组的文件夹结构

现在,loading.tsx 文件将仅应用于您的仪表盘概览页面。

路由组允许您将文件组织成逻辑组,而不会影响 URL 路径结构。当您使用括号 () 创建新文件夹时,名称不会包含在 URL 路径中。因此 /dashboard/(overview)/page.tsx 会变为 /dashboard

在这里,您使用路由组确保 loading.tsx 仅应用于仪表盘概览页面。但您也可以使用路由组将应用程序划分为不同部分(例如 (marketing) 路由和 (shop) 路由),或者在大型应用中按团队划分。

流式渲染组件

到目前为止,您是在流式渲染整个页面。但您也可以使用 React Suspense 实现更细粒度的控制,流式渲染特定组件。

Suspense 允许您延迟渲染应用程序的某些部分,直到满足某些条件(例如数据已加载)。您可以将动态组件包裹在 Suspense 中,然后传递一个备用组件,在动态组件加载时显示。

如果您还记得慢速数据请求 fetchRevenue(),正是这个请求拖慢了整个页面。您可以使用 Suspense 仅流式渲染这个组件,而立即显示页面的其余 UI,而不是阻塞整个页面。

为此,您需要将数据获取移动到组件中,让我们更新代码看看效果:

/dashboard/(overview)/page.tsx 中删除所有 fetchRevenue() 实例及其数据:

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; // 移除 fetchRevenue
 
export default async function Page() {
  const revenue = await fetchRevenue() // 删除这一行
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    // ...
  );
}

然后,从 React 导入 <Suspense>,并用它包裹 <RevenueChart />。您可以传递一个名为 <RevenueChartSkeleton> 的备用组件。

/app/dashboard/(overview)/page.tsx
import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data';
import { Suspense } from 'react';
import { RevenueChartSkeleton } from '@/app/ui/skeletons';
 
export default async function Page() {
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        仪表盘
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="已收款" value={totalPaidInvoices} type="collected" />
        <Card title="待处理" value={totalPendingInvoices} type="pending" />
        <Card title="总发票数" value={numberOfInvoices} type="invoices" />
        <Card
          title="总客户数"
          value={numberOfCustomers}
          type="customers"
        />
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

最后,更新 <RevenueChart> 组件以自行获取数据并移除传递给它的 props:

/app/ui/dashboard/revenue-chart.tsx
import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
import { fetchRevenue } from '@/app/lib/data';
 
// ...
 
export default async function RevenueChart() { // 使组件异步,移除 props
  const revenue = await fetchRevenue(); // 在组件内部获取数据
 
  const chartHeight = 350;
  const { yAxisLabels, topLabel } = generateYAxis(revenue);
 
  if (!revenue || revenue.length === 0) {
    return <p className="mt-4 text-gray-400">无可用数据</p>;
  }
 
  return (
    // ...
  );
}
 

现在刷新页面,您应该会立即看到仪表盘信息,同时为 <RevenueChart> 显示备用骨架屏:

显示收入图表骨架屏及已加载卡片和最新发票组件的仪表盘页面

练习:流式渲染 <LatestInvoices>

现在轮到您了!练习您刚刚学到的知识,流式渲染 <LatestInvoices> 组件。

fetchLatestInvoices() 从页面移动到 <LatestInvoices> 组件中。用 <Suspense> 边界包裹该组件,并使用 <LatestInvoicesSkeleton> 作为备用内容。

完成后,展开切换查看解决方案代码:

组件分组

很好!您已经接近完成,现在需要用 Suspense 包裹 <Card> 组件。您可以为每个单独的卡片获取数据,但这可能导致卡片加载时出现_弹跳_效果,这对用户来说可能视觉上不太友好。

那么,如何解决这个问题呢?

为了创建更_渐进_的效果,您可以使用包装组件对卡片进行分组。这意味着静态的 <SideNav/> 会首先显示,然后是卡片等。

在您的 page.tsx 文件中:

  1. 删除您的 <Card> 组件
  2. 删除 fetchCardData() 函数
  3. 导入一个新的包装组件 <CardWrapper />
  4. 导入一个新的骨架屏组件 <CardsSkeleton />
  5. 用 Suspense 包裹 <CardWrapper />
/app/dashboard/(overview)/page.tsx
import CardWrapper from '@/app/ui/dashboard/cards';
// ...
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
  CardsSkeleton,
} from '@/app/ui/skeletons';
 
export default async function Page() {
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        仪表盘
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<CardsSkeleton />}>
          <CardWrapper />
        </Suspense>
      </div>
      // ...
    </main>
  );
}

然后,进入 /app/ui/dashboard/cards.tsx 文件,导入 fetchCardData() 函数,并在 <CardWrapper/> 组件内部调用它。确保取消注释该组件中任何必要的代码。

/app/ui/dashboard/cards.tsx
// ...
import { fetchCardData } from '@/app/lib/data';
 
// ...
 
export default async function CardWrapper() {
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <>
      <Card title="已收款" value={totalPaidInvoices} type="collected" />
      <Card title="待处理" value={totalPendingInvoices} type="pending" />
      <Card title="总发票数" value={numberOfInvoices} type="invoices" />
      <Card
        title="总客户数"
        value={numberOfCustomers}
        type="customers"
      />
    </>
  );
}

刷新页面,您应该会看到所有卡片同时加载。当您希望多个组件同时加载时,可以使用这种模式。

决定 Suspense 边界的位置

您放置 Suspense 边界的位置取决于以下几个因素:

  1. 您希望用户在页面流式传输时的体验
  2. 您希望优先显示哪些内容
  3. 组件是否依赖数据获取

看看您的仪表盘页面,您会有什么不同的做法吗?

不用担心,这里没有标准答案。

  • 您可以像我们使用 loading.tsx 那样流式渲染整个页面...但如果某个组件有慢速数据请求,可能会导致加载时间更长
  • 您可以逐个组件流式渲染...但这可能导致 UI 在准备就绪时_弹入_屏幕
  • 您也可以通过流式渲染页面部分来创建_渐进_效果。但您需要创建包装组件

Suspense 边界的放置位置会因应用而异。一般来说,最佳实践是将数据获取下移到需要它的组件中,然后用 Suspense 包裹这些组件。但如果您的应用需要流式渲染部分或整个页面,这也没有问题。

不要害怕尝试 Suspense,看看哪种方式最适合,它是一个强大的 API,可以帮助您创造更令人愉悦的用户体验。

展望未来

流式渲染和服务器组件为我们提供了处理数据获取和加载状态的新方法,最终目标是改善最终用户体验。

在下一章节中,您将了解部分预渲染 (Partial Prerendering),这是一种基于流式渲染构建的新 Next.js 渲染模型。