9
章节9
流式处理
在上一章节中,您学习了 Next.js 的不同渲染方法。我们还讨论了缓慢的数据获取如何影响应用程序的性能。接下来,让我们看看在存在缓慢的数据请求时,如何改善用户体验。
本章内容...
以下是我们将涵盖的主题
什么是流式处理,以及何时可能使用它。
如何使用 loading.tsx
和 Suspense 实现流式处理。
什么是加载骨架屏。
什么是 Next.js 路由组,以及何时可能使用它们。
在应用程序中何处放置 React Suspense 边界。
什么是流式处理?
流式处理是一种数据传输技术,允许您将路由分解为更小的“块”,并在服务器准备好时逐步将它们从服务器流式传输到客户端。

通过流式处理,您可以防止缓慢的数据请求阻止整个页面。这允许用户查看页面的一部分并与之交互,而无需等待所有数据加载完毕后才能向用户显示任何 UI。

流式处理与 React 的组件模型配合良好,因为每个组件都可以被视为一个块。
在 Next.js 中,有两种实现流式处理的方法
- 在页面级别,使用
loading.tsx
文件(它为您创建<Suspense>
)。 - 在组件级别,使用
<Suspense>
可以实现更精细的控制。
让我们看看这是如何工作的。
使用 loading.tsx
流式处理整个页面
在 /app/dashboard
文件夹中,创建一个名为 loading.tsx
的新文件
export default function Loading() {
return <div>Loading...</div>;
}
刷新 https://127.0.0.1:3000/dashboard,现在您应该看到

这里发生了一些事情
loading.tsx
是一个特殊的 Next.js 文件,构建于 React Suspense 之上。它允许您创建回退 UI,以在页面内容加载时显示为替代。- 由于
<SideNav>
是静态的,因此会立即显示。用户可以在动态内容加载时与<SideNav>
交互。 - 用户不必等待页面完成加载就可以离开(这称为可中断导航)。
恭喜!您刚刚实现了流式处理。但是我们可以做得更多来改善用户体验。让我们显示加载骨架屏而不是 Loading…
文本。
添加加载骨架屏
加载骨架屏是 UI 的简化版本。许多网站使用它们作为占位符(或回退),以向用户指示内容正在加载。您在 loading.tsx
中添加的任何 UI 都将作为静态文件的一部分嵌入,并首先发送。然后,其余的动态内容将从服务器流式传输到客户端。
在您的 loading.tsx
文件中,导入一个新的组件,名为 <DashboardSkeleton>
import DashboardSkeleton from '@/app/ui/skeletons';
export default function Loading() {
return <DashboardSkeleton />;
}
然后,刷新 https://127.0.0.1:3000/dashboard,现在您应该看到

使用路由组修复加载骨架屏错误
目前,您的加载骨架屏将应用于发票页面。
由于 loading.tsx
在文件系统中比 /invoices/page.tsx
和 /customers/page.tsx
高一个级别,因此它也应用于这些页面。
我们可以使用路由组来改变这种情况。在 dashboard 文件夹内创建一个名为 /(overview)
的新文件夹。然后,将您的 loading.tsx
和 page.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()
实例及其数据
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>
的回退组件。
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
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>
显示回退骨架屏

练习:流式处理 <LatestInvoices>
现在轮到您了!通过流式处理 <LatestInvoices>
组件来练习您刚刚学到的知识。
将 fetchLatestInvoices()
从页面向下移动到 <LatestInvoices>
组件。将该组件包裹在 <Suspense>
边界中,并使用名为 <LatestInvoicesSkeleton>
的回退。
准备就绪后,展开切换开关以查看解决方案代码
组件分组
太棒了!您快完成了,现在您需要将 <Card>
组件包裹在 Suspense 中。您可以获取每个单独卡片的数据,但这可能会导致卡片加载时出现闪烁效果,这可能会在视觉上让用户感到不适。
那么,您将如何解决这个问题呢?
为了创建更错落有致的效果,您可以使用包装器组件对卡片进行分组。这意味着静态的 <SideNav/>
将首先显示,然后是卡片等。
在您的 page.tsx
文件中
- 删除您的
<Card>
组件。 - 删除
fetchCardData()
函数。 - 导入一个名为
<CardWrapper />
的新包装器组件。 - 导入一个名为
<CardsSkeleton />
的新骨架屏组件。 - 将
<CardWrapper />
包裹在 Suspense 中。
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/>
组件内部调用它。确保取消注释此组件中的任何必要代码。
// ...
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 边界放置在何处取决于以下几个方面
- 您希望用户在页面流式传输时如何体验页面。
- 您希望优先显示哪些内容。
- 组件是否依赖数据获取。
看看您的仪表板页面,有什么您会做不同的吗?
不用担心。没有正确答案。
- 您可以像我们使用
loading.tsx
那样流式处理整个页面... 但是,如果其中一个组件的数据获取速度很慢,则可能会导致更长的加载时间。 - 您可以单独流式处理每个组件... 但这可能会导致 UI 在准备就绪时弹出到屏幕上。
- 您还可以通过流式处理页面部分来创建错落有致的效果。但是您需要创建包装器组件。
Suspense 边界的放置位置将因您的应用程序而异。一般来说,最佳实践是将数据获取下移到需要它的组件,然后将这些组件包裹在 Suspense 中。但是,如果您的应用程序需要,流式处理部分或整个页面也没有什么问题。
不要害怕尝试 Suspense 并看看哪种方法最有效,它是一个强大的 API,可以帮助您创建更令人愉悦的用户体验。
展望未来
流式处理和服务器组件为我们提供了处理数据获取和加载状态的新方法,最终目标是改善最终用户体验。
在下一章中,您将学习部分预渲染,这是一种新的 Next.js 渲染模型,其构建考虑了流式处理。
这有帮助吗?