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://:3000/dashboard,你现在应该会看到

这里发生了几件事:
loading.tsx是一个基于 React Suspense 构建的特殊 Next.js 文件。它允许你创建备用 UI,以便在页面内容加载时显示。- 由于
<SideNav>是静态的,它会立即显示。用户可以在动态内容加载时与<SideNav>进行交互。 - 用户无需等待页面加载完成即可导航离开(这称为可中断导航)。
恭喜!你刚刚实现了流式传输。但我们可以做更多来改善用户体验。让我们显示一个加载骨架屏而不是 Loading... 文本。
添加加载骨架屏
加载骨架屏是 UI 的简化版本。许多网站使用它们作为占位符(或备用),以向用户指示内容正在加载。你添加到 loading.tsx 中的任何 UI 都将作为静态文件的一部分嵌入,并首先发送。然后,其余的动态内容将从服务器流式传输到客户端。
在你的 loading.tsx 文件中,导入一个新的组件 <DashboardSkeleton>
import DashboardSkeleton from '@/app/ui/skeletons';
export default function Loading() {
return <DashboardSkeleton />;
}然后,刷新 https://: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,可以帮助你创建更愉悦的用户体验。
展望未来
流式传输和服务器组件为我们提供了处理数据获取和加载状态的新方法,最终目标是改善终端用户体验。
在下一章中,你将学习局部预渲染(Partial Prerendering),这是一种以流式传输为核心构建的新型 Next.js 渲染模型。
这有帮助吗?





