likes
comments
collection
share

Next.js14从入门到实战014:NextJS基础篇之流媒体

作者站长头像
站长
· 阅读数 3

在上一章中,您将仪表盘页面设置为动态,但我们讨论了缓慢的数据获取速度会如何影响应用程序的性能。下面我们就来看看如何在数据请求速度较慢时改善用户体验。

在本章中你将学到...

  • 什么是流媒体以及何时使用。
  • 如何使用 loading.tsx 和 Suspense 实现流式传输。
  • 什么是装载骨架?
  • 什么是路由组,什么时候可以使用它们。
  • 在应用程序中放置悬念边界的位置。

什么是流媒体?

流式传输是一种数据传输技术,可将路由分解成较小的 "块",并在准备就绪时逐步从服务器将其传输到客户端。

Next.js14从入门到实战014:NextJS基础篇之流媒体

通过流式传输,可以防止缓慢的数据请求阻塞整个页面。这样,用户就可以看到页面的部分内容并与之交互,而无需在向用户显示任何用户界面之前等待加载所有数据。

Next.js14从入门到实战014:NextJS基础篇之流媒体

流媒体与 React 的组件模型配合得很好,因为每个组件都可以被视为一个块。

在 Next.js 中实现流式传输有两种方法:

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

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

使用loading.tsx串流整个页面

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

export default function Loading() {
  return <div>Loading...</div>;
}

刷新 http://localhost:3000/dashboard,现在应该可以看到了:

Next.js14从入门到实战014:NextJS基础篇之流媒体

这里发生了几件事:

  1. loading.tsx 是建立在 Suspense 基础上的一个特殊 Next.js 文件,它允许您创建后备用户界面,在页面内容加载时作为替代显示。
  2. 由于 <SideNav> 是静态内容,因此会立即显示。在加载动态内容时,用户可以与 <SideNav> 进行交互。
  3. 用户无需等待页面加载完毕就可以离开(这被称为可中断导航)。

恭喜您您刚刚实施了流媒体。但我们还可以做更多工作来改善用户体验。让我们显示一个加载骨架,而不是 Loading… 文本。

添加加载骨架

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

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

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

然后,刷新 http://localhost:3000/dashboard,现在就可以看到了:

Next.js14从入门到实战014:NextJS基础篇之流媒体

修复路由组加载骨架的错误

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

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

我们可以通过路由组来改变这种情况。在 dashboard 文件夹中新建一个名为 /(overview) 的文件夹。然后,将 loading.tsx 和 page.tsx 文件移动到该文件夹中:

Next.js14从入门到实战014:NextJS基础篇之流媒体

现在, 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> 的后备骨架:

Next.js14从入门到实战014:NextJS基础篇之流媒体

练习:流媒体<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 文件中:

  1. 删除 <Card> 组件。
  2. 删除 fetchCardData() 函数。
  3. 导入名为 <CardWrapper /> 的新封装组件。
  4. 导入名为 <CardsSkeleton /> 的新骨架组件。
  5. 用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边界的设置取决于几个方面:

  1. 您希望用户如何体验页面流。
  2. 您要优先考虑哪些内容。
  3. 如果组件依赖于数据获取。

看看你的仪表板页面,有没有什么不同的做法?

  • 您可以像使用 loading.tsx 那样对整个页面进行流式处理......但如果其中一个组件的数据获取速度较慢,这可能会导致加载时间延长。
  • 您可以对每个组件进行单独流式处理......但这可能会导致用户界面在准备就绪时弹入屏幕。
  • 您还可以通过分流页面部分来创建交错效果。但您需要创建封装组件。

悬浮边界的位置因应用程序而异。一般来说,好的做法是将数据抓取下移到需要的组件,然后将这些组件封装在Suspense中。 不要害怕使用 Suspense ,多试一下,看看哪种方法最有效,它是一个强大的应用程序接口,可以帮助你创造更好的用户体验。

展望未来

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

在下一章中,您将了解到 "部分预渲染"(Partial Prerendering),这是一种基于流式传输构建的全新 Next.js 渲染模型。