跳至内容

9

流式传输

在上一章中,您学习了 Next.js 的不同渲染方法。我们还讨论了缓慢的数据获取如何影响应用程序的性能。让我们看看如何在存在缓慢的数据请求时改善用户体验。

本章内容…

以下是我们涵盖的主题

什么是流式传输以及何时可以使用它。

如何使用loading.tsx和Suspense实现流式传输。

什么是加载骨架。

什么是路由组,以及何时可以使用它们。

在应用程序中放置Suspense边界的位置。

什么是流式传输?

流式传输是一种数据传输技术,允许您将路由分解成更小的“块”,并在它们准备好时逐渐从服务器流式传输到客户端。

Diagram showing time with sequential data fetching and parallel data fetching

通过流式传输,您可以防止缓慢的数据请求阻塞整个页面。这允许用户在任何 UI 显示给用户之前,无需等待所有数据加载即可查看和交互页面的部分内容。

Diagram showing time with sequential data fetching and parallel data fetching

流式传输与 React 的组件模型配合得很好,因为每个组件都可以被认为是一个

在 Next.js 中,您可以通过两种方式实现流式传输

  1. 在页面级别,使用loading.tsx文件。
  2. 对于特定组件,使用<Suspense>

让我们看看它是如何工作的。

使用loading.tsx流式传输整个页面

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

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

刷新https://127.0.0.1:3000/dashboard,您现在应该会看到

Dashboard page with 'Loading...' text

这里发生了一些事情

  1. loading.tsx是基于Suspense构建的Next.js特殊文件,它允许您创建回退UI以在页面内容加载时显示为替换。
  2. 由于<SideNav>是静态的,因此它会立即显示。用户可以在动态内容加载时与<SideNav>进行交互。
  3. 用户不必等待页面加载完成才能导航离开(这称为可中断导航)。

恭喜!您刚刚实现了流式传输。但我们可以做更多的事情来改善用户体验。让我们显示一个加载骨架而不是“加载中…”文本。

添加加载骨架

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

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

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

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

Dashboard page with loading skeletons

使用路由组修复加载骨架错误

现在,您的加载骨架也将应用于发票和客户页面。

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

我们可以使用路由组更改此设置。在dashboard文件夹内创建一个名为/(overview)的新文件夹。然后,将您的loading.tsxpage.tsx文件移动到该文件夹内

Folder structure showing how to create a route group using parentheses

现在,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'; // remove fetchRevenue
 
export default async function Page() {
  const revenue = await fetchRevenue() // delete this line
  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`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="Collected" value={totalPaidInvoices} type="collected" />
        <Card title="Pending" value={totalPendingInvoices} type="pending" />
        <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
        <Card
          title="Total Customers"
          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>组件以获取其自己的数据并删除传递给它的prop

/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() { // Make component async, remove the props
  const revenue = await fetchRevenue(); // Fetch data inside the component
 
  const chartHeight = 350;
  const { yAxisLabels, topLabel } = generateYAxis(revenue);
 
  if (!revenue || revenue.length === 0) {
    return <p className="mt-4 text-gray-400">No data available.</p>;
  }
 
  return (
    // ...
  );
}
 

现在刷新页面,您应该会几乎立即看到仪表盘信息,同时为<RevenueChart>显示一个回退骨架

Dashboard page with revenue chart skeleton and loaded Card and Latest Invoices components

练习:流式传输<LatestInvoices>

现在轮到你了!通过流式传输<LatestInvoices>组件来练习您刚刚学到的知识。

fetchLatestInvoices()从页面向下移动到<LatestInvoices>组件。将组件包装在具有名为<LatestInvoicesSkeleton>的回退的<Suspense>边界中。

准备就绪后,展开切换以查看解决方案代码

组合组件

太棒了!你快完成了,现在你需要用 `Suspense` 包裹 `<Card>` 组件。你可以为每个卡片单独获取数据,但这可能导致卡片加载时出现“跳动”效果,这对用户来说视觉上可能很突兀。

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

为了创建更“交错”的效果,你可以使用一个包装组件对卡片进行分组。这意味着静态的 `<SideNav/>` 将首先显示,然后是卡片等。

在你的 `page.tsx` 文件中

  1. 删除你的 `<Card>` 组件。
  2. 删除 `fetchCardData()` 函数。
  3. 导入一个新的 **包装** 组件,名为 `<CardWrapper />`。
  4. 导入一个新的 **骨架** 组件,名为 `<CardsSkeleton />`。
  5. 将 `<CardWrapper />` 包裹在 Suspense 中。
/app/dashboard/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`}>
        Dashboard
      </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="Collected" value={totalPaidInvoices} type="collected" />
      <Card title="Pending" value={totalPendingInvoices} type="pending" />
      <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
      <Card
        title="Total Customers"
        value={numberOfCustomers}
        type="customers"
      />
    </>
  );
}

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

决定在哪里放置你的 Suspense 边界

你放置 Suspense 边界的位置将取决于一些因素

  1. 你希望用户在页面流式传输时的体验。
  2. 你想优先加载哪些内容。
  3. 组件是否依赖于数据获取。

看看你的仪表盘页面,你是否会采取不同的做法?

不用担心。没有正确答案。

  • 你可以像我们在 `loading.tsx` 中所做的那样流式传输 **整个页面**... 但如果其中一个组件的数据获取速度较慢,这可能会导致加载时间更长。
  • 你可以单独流式传输 **每个组件**... 但这可能会导致 UI 在准备就绪时“跳入”屏幕。
  • 你还可以通过流式传输 **页面部分** 来创建“交错”效果。但是你需要创建包装组件。

你放置 Suspense 边界的位置将根据你的应用程序而有所不同。一般来说,最好将数据获取移动到需要它的组件中,然后用 Suspense 包裹这些组件。但是,如果你的应用程序需要流式传输部分或整个页面,也没有什么问题。

不要害怕尝试使用 Suspense 并查看哪种方法最有效,它是一个强大的 API,可以帮助你创建更愉悦的用户体验。

展望未来

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

在下一章中,你将学习部分预渲染,这是一种新的 Next.js 渲染模型,它是考虑到流式传输而构建的。

你已完成本章9

你已经学习了如何使用 Suspense 和加载骨架流式传输组件。

下一步

10:部分预渲染

初步了解部分预渲染 - 一种新的实验性渲染模型,它是基于流式传输构建的。