在上一章中,你学习了Next.js的不同渲染方法。我们还讨论了缓慢的数据获取如何影响应用程序的性能。现在,让我们看看在数据请求缓慢的情况下,如何改善用户体验。
在本章中...
我们将涵盖以下主题:
- 什么是流式传输以及何时使用它。
- 如何使用
loading.tsx
和 Suspense 实现流式传输。 - 什么是加载骨架。
- 什么是路由组,以及何时使用它们。
- 在应用程序中放置 Suspense 边界的最佳位置。
什么是流式传输?
流式传输是一种数据传输技术,它允许你将一个路由拆分为多个较小的“块”,并在服务器端准备好时逐步将它们从服务器传输到客户端。
通过流式传输,你可以防止缓慢的数据请求阻塞整个页面。这使用户能够在等待所有数据加载完成之前就可以查看和与页面的部分内容进行交互。
流式传输与React的组件模型配合良好,因为每个组件都可以被视为一个 块。
在Next.js中,有两种方法可以实现流式传输:
- 在页面级别,通过
loading.tsx
文件。 - 对于特定组件,通过
<Suspense>
。
让我们看看这是如何工作的。
是时候进行测验了!
流式传输的一个优势是什么?
A. 通过块加密使数据请求更加安全
B. 所有块都在完全接收后才渲染
C. 块是并行渲染的,从而减少整体加载时间
使用 loading.tsx 流式传输整个页面
在 /app/dashboard
文件夹中,创建一个名为 loading.tsx
的新文件:
// /app/dashboard/loading.tsx export default function Loading() { return <div>Loading...</div>; }
刷新 http://localhost:3000/dashboard,你现在应该能看到:
这里发生了一些事情:
-
loading.tsx
是一个基于 Suspense 的 Next.js 特殊文件,它允许你创建替代的UI,在页面内容加载时显示。 - 由于
<SideNav>
是静态的,它会立即显示。用户可以在动态内容加载时与<SideNav>
进行交互。 - 用户不必等待页面加载完成才能离开(这称为可中断导航)。
恭喜你!你刚刚实现了流式传输。但我们可以做更多的事情来改善用户体验。让我们显示一个**加载动画(loading skeleton)**,而不是“加载中…”的文字。
添加加载骨架(动画)
加载骨架是UI的简化版本。许多网站使用它们作为占位符(或替代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.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()
和其数据的实例:
// /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 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>
组件以获取其自身的数据,并删除传递给它的属性:
// /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() { // 使组件异步,移除传递的属性 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">No data available.</p>; } return ( // ... ); }
现在刷新页面,你应该能立即看到仪表板信息,同时会显示 <RevenueChart>
的骨架加载动画作为回退界面:
练习:流式加载 <LatestInvoices>
现在轮到你了!练习刚学到的内容,将 <LatestInvoices>
组件实现流式加载。
将 fetchLatestInvoices()
从页面移到 <LatestInvoices>
组件中。将该组件包装在一个 <Suspense>
边界中,并使用一个名为 <LatestInvoicesSkeleton>
的回退组件。
准备好后,查看解决方案代码:
解决方案
以下是上述内容的翻译:
仪表板页面:
// /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 { fetchCardData } from '@/app/lib/data'; // 移除 fetchLatestInvoices import { Suspense } from 'react'; import { RevenueChartSkeleton, LatestInvoicesSkeleton, } 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> <Suspense fallback={<LatestInvoicesSkeleton />}> <LatestInvoices /> </Suspense> </div> </main> ); }
<LatestInvoices>
组件。记得删除传递的属性!:
// /app/ui/dashboard/latest-invoices.tsx import { ArrowPathIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import Image from 'next/image'; import { lusitana } from '@/app/ui/fonts'; import { fetchLatestInvoices } from '@/app/lib/data'; export default async function LatestInvoices() { // 删除传递的属性 const latestInvoices = await fetchLatestInvoices(); return ( // ... ); }
分组组件
太棒了!你快做到了,现在你需要将 <Card>
组件包装在 Suspense
中。你可以为每个卡片单独获取数据,但这可能会导致卡片在加载时产生弹出效果,这对用户来说可能会造成视觉上的不适。
那么,你会如何解决这个问题呢?
为了创建更多错落效果,你可以使用一个包装组件来分组这些卡片。这样,静态的 <SideNav/>
将首先显示,随后是卡片等内容。
在你的 page.tsx
文件中:
- 删除你的
<Card>
组件。 - 删除
fetchCardData()
函数。 - 导入一个新的包装组件,名为
<CardWrapper />
。 - 导入一个新的骨架组件,名为
<CardsSkeleton />
。 - 使用
Suspense
包装<CardWrapper />
。
// /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
边界的位置将取决于以下几点:
- 你希望用户如何体验页面的流式加载。
- 你想优先展示哪些内容。
- 组件是否依赖于数据获取。
看看你的仪表板页面,有什么和你做得不同吗?
别担心,这并没有标准答案。
- 你可以像使用
loading.tsx
一样流式加载整个页面……但如果某个组件的数据获取速度较慢,这可能会导致加载时间更长。 - 你可以单独流式加载每个组件……但这可能会导致 UI 弹出到屏幕上的现象。
- 你也可以通过流式加载页面部分来创建错落效果。但你需要创建包装组件。
Suspense
边界的放置位置会根据你的应用程序而有所不同。一般来说,最好将数据获取移到需要它的组件中,然后将这些组件包裹在 Suspense
中。但如果这是你的应用程序所需要的,流式加载部分或整个页面也没有问题。
不要害怕尝试 Suspense
并找到最适合你的方式,它是一个强大的 API,可以帮助你创建更令人愉悦的用户体验。
流式处理和服务器组件为我们提供了处理数据获取和加载状态的新方法,最终目标是改善最终用户体验。
在下一章中,你将学习部分预渲染,这是一个以流式处理为核心的新 Next.js 渲染模型。