Next.js14从入门到实战014:NextJS基础篇之流媒体
在上一章中,您将仪表盘页面设置为动态,但我们讨论了缓慢的数据获取速度会如何影响应用程序的性能。下面我们就来看看如何在数据请求速度较慢时改善用户体验。
在本章中你将学到...
- 什么是流媒体以及何时使用。
- 如何使用
loading.tsx
和 Suspense 实现流式传输。 - 什么是装载骨架?
- 什么是路由组,什么时候可以使用它们。
- 在应用程序中放置悬念边界的位置。
什么是流媒体?
流式传输是一种数据传输技术,可将路由分解成较小的 "块",并在准备就绪时逐步从服务器将其传输到客户端。
通过流式传输,可以防止缓慢的数据请求阻塞整个页面。这样,用户就可以看到页面的部分内容并与之交互,而无需在向用户显示任何用户界面之前等待加载所有数据。
流媒体与 React 的组件模型配合得很好,因为每个组件都可以被视为一个块。
在 Next.js 中实现流式传输有两种方法:
- 在页面级别,使用
loading.tsx
文件。 - 对于特定组件,使用
<Suspense>
。
让我们看看它是如何工作的。
使用loading.tsx
串流整个页面
在 /app/dashboard
文件夹中,新建一个名为 loading.tsx
的文件:
export default function Loading() {
return <div>Loading...</div>;
}
刷新 http://localhost:3000/dashboard
,现在应该可以看到了:
这里发生了几件事:
loading.tsx
是建立在 Suspense 基础上的一个特殊 Next.js 文件,它允许您创建后备用户界面,在页面内容加载时作为替代显示。- 由于
<SideNav>
是静态内容,因此会立即显示。在加载动态内容时,用户可以与<SideNav>
进行交互。 - 用户无需等待页面加载完毕就可以离开(这被称为可中断导航)。
恭喜您您刚刚实施了流媒体。但我们还可以做更多工作来改善用户体验。让我们显示一个加载骨架,而不是 Loading…
文本。
添加加载骨架
加载骨架是用户界面的简化版本。许多网站将其用作占位符(或后备占位符),以向用户表明内容正在加载。您嵌入到 loading.tsx
中的任何用户界面都将作为静态文件的一部分嵌入,并首先发送。然后,其余的动态内容将从服务器流式传输到客户端。
在 loading.tsx
文件中,导入名为 <DashboardSkeleton>
的新组件:
import DashboardSkeleton from '@/app/ui/skeletons';
export default function Loading() {
return <DashboardSkeleton />;
}
然后,刷新 http://localhost: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 封装你的动态组件。然后,在动态组件加载时,将一个后备组件传递给它来显示。
如果您还记得缓慢的数据请求 fetchRevenue()
,那么这个请求就会拖慢整个页面的运行速度。与其阻塞页面,您可以使用 Suspense 仅对该组件进行流式处理,然后立即显示页面的其他用户界面。
为此,您需要将数据获取转移到组件中,让我们更新一下代码,看看会是什么样子:
从 /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>
组件以获取自己的数据,并删除传递给它的道具:
// /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>
的后备骨架:
练习:流媒体<LatestInvoices>
现在轮到你了!通过流式传输 <LatestInvoices>
组件来练习刚才所学的内容。
将 fetchLatestInvoices()
从页面向下移动到 <LatestInvoices>
组件。用 <Suspense>
边界包裹该组件,并使用名为 <LatestInvoicesSkeleton>
的后备组件。
准备就绪后,我们查看下答案:
Dashboard Page: 仪表板页面:
// /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'; // Remove fetchLatestInvoices
import { Suspense } from 'react';
import {
RevenueChartSkeleton,
LatestInvoicesSkeleton,
} from '@/app/ui/skeletons';
export default async function Page() {
// Remove `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() { // Remove props
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
那样对整个页面进行流式处理......但如果其中一个组件的数据获取速度较慢,这可能会导致加载时间延长。 - 您可以对每个组件进行单独流式处理......但这可能会导致用户界面在准备就绪时弹入屏幕。
- 您还可以通过分流页面部分来创建交错效果。但您需要创建封装组件。
悬浮边界的位置因应用程序而异。一般来说,好的做法是将数据抓取下移到需要的组件,然后将这些组件封装在Suspense中。 不要害怕使用 Suspense ,多试一下,看看哪种方法最有效,它是一个强大的应用程序接口,可以帮助你创造更好的用户体验。
展望未来
流和服务器组件为我们提供了处理数据获取和加载状态的新方法,最终目的是改善最终用户体验。
在下一章中,您将了解到 "部分预渲染"(Partial Prerendering),这是一种基于流式传输构建的全新 Next.js 渲染模型。
转载自:https://juejin.cn/post/7361102275747676212