跳到內容

9

串流

在前一章中,您學習了 Next.js 的不同渲染方法。我們也討論了緩慢的資料提取如何影響應用程式的效能。讓我們看看在有緩慢的資料請求時,您可以如何改善使用者體驗。

在本章中...

以下是我們將涵蓋的主題

什麼是串流,以及您可能在何時使用它。

如何使用 loading.tsx 和 Suspense 實作串流。

什麼是載入骨架。

什麼是 Next.js 路由群組,以及您可能在何時使用它們。

在應用程式中何處放置 React Suspense 邊界。

什麼是串流?

串流是一種資料傳輸技術,可讓您將路由分解為較小的「區塊」,並在區塊準備就緒時,逐步從伺服器串流到用戶端。

Diagram showing time with sequential data fetching and parallel data fetching

透過串流,您可以防止緩慢的資料請求封鎖整個頁面。這讓使用者可以在不等待所有資料載入完成才顯示任何 UI 的情況下,查看頁面的一部分並與之互動。

Diagram showing time with sequential data fetching and parallel data fetching

串流與 React 的組件模型配合良好,因為每個組件都可以被視為一個區塊

在 Next.js 中,有兩種實作串流的方法

  1. 在頁面層級,使用 loading.tsx 檔案(它會為您建立 <Suspense>)。
  2. 在組件層級,使用 <Suspense> 以獲得更精細的控制。

讓我們看看這是如何運作的。

使用 loading.tsx 串流整個頁面

/app/dashboard 資料夾中,建立一個名為 loading.tsx 的新檔案

/app/dashboard/loading.tsx
export default function Loading() {
  return <div>Loading...</div>;
}

重新整理 https://127.0.0.1:3000/dashboard,您現在應該會看到

Dashboard page with 'Loading...' text

這裡發生了一些事情

  1. loading.tsx 是一個特殊的 Next.js 檔案,建立在 React Suspense 之上。它讓您可以建立後備 UI,在頁面內容載入時顯示為替代方案。
  2. 由於 <SideNav> 是靜態的,因此會立即顯示。使用者可以在動態內容載入時與 <SideNav> 互動。
  3. 使用者不必等待頁面完成載入才能離開導航(這稱為可中斷的導航)。

恭喜!您剛剛實作了串流。但是我們可以做更多的事情來改善使用者體驗。讓我們顯示載入骨架,而不是 Loading… 文字。

新增載入骨架

載入骨架是 UI 的簡化版本。許多網站使用它們作為佔位符(或後備方案)來向使用者指示內容正在載入中。您在 loading.tsx 中新增的任何 UI 都將作為靜態檔案的一部分嵌入,並首先傳送。然後,其餘的動態內容將從伺服器串流到用戶端。

在您的 loading.tsx 檔案中,匯入一個名為 <DashboardSkeleton> 的新組件

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

然後,重新整理 https://127.0.0.1:3000/dashboard,您現在應該會看到

Dashboard page with loading skeletons

使用路由群組修正載入骨架錯誤

目前,您的載入骨架將套用至發票。

由於 loading.tsx 在檔案系統中的層級高於 /invoices/page.tsx/customers/page.tsx,因此它也適用於這些頁面。

我們可以透過路由群組來變更此行為。在 dashboard 資料夾內建立一個名為 /(overview) 的新資料夾。然後,將您的 loading.tsxpage.tsx 檔案移至該資料夾內

Folder structure showing how to create a route group using parentheses

現在,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'; // 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> 組件以提取自己的資料,並移除傳遞給它的 prop

/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> 顯示後備骨架

Dashboard page with revenue chart skeleton and loaded Card and Latest Invoices components

練習:串流 <LatestInvoices>

現在輪到您了!透過串流 <LatestInvoices> 組件來練習您剛學到的知識。

fetchLatestInvoices() 從頁面向下移動到 <LatestInvoices> 組件。使用名為 <LatestInvoicesSkeleton> 的後備方案將組件包裝在 <Suspense> 邊界中。

準備就緒後,展開切換按鈕以查看解決方案程式碼

群組化組件

太棒了!您快完成了,現在您需要將 <Card> 組件包裝在 Suspense 中。您可以提取每個個別卡片的資料,但這可能會導致卡片載入時產生跳出效果,這可能會讓使用者在視覺上感到不適。

那麼,您將如何解決這個問題?

若要建立更交錯的效果,您可以使用包裝函式組件將卡片分組。這表示將首先顯示靜態 <SideNav/>,然後是卡片等。

在您的 page.tsx 檔案中

  1. 刪除您的 <Card> 組件。
  2. 刪除 fetchCardData() 函數。
  3. 匯入一個名為 <CardWrapper /> 的新包裝函式組件。
  4. 匯入一個名為 <CardsSkeleton /> 的新骨架組件。
  5. <CardWrapper /> 包裝在 Suspense 中。
/app/dashboard/(overview)/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 一樣串流整個頁面... 但如果其中一個組件的資料提取速度很慢,則可能會導致更長的載入時間。
  • 您可以個別串流每個組件... 但這可能會導致 UI 在準備就緒時跳出到螢幕中。
  • 您也可以透過串流頁面區段來建立交錯效果。但是您需要建立包裝函式組件。

您放置 suspense 邊界的位置會因您的應用程式而異。一般而言,最好將您的資料提取向下移動到需要它的組件,然後將這些組件包裝在 Suspense 中。但是,如果您的應用程式需要,串流區段或整個頁面也沒有錯。

不要害怕嘗試 Suspense 並看看哪種方法最有效,它是一個強大的 API,可以幫助您建立更令人愉悅的使用者體驗。

展望未來

串流和伺服器組件為我們提供了處理資料提取和載入狀態的新方法,最終目標是改善最終使用者體驗。

在下一章中,您將學習部分預先渲染,這是一種新的 Next.js 渲染模型,其設計考慮了串流。

您已完成章節9

您已學習如何使用 Suspense 和載入骨架串流組件。

下一步

10:部分預先渲染

搶先了解部分預先渲染 - 一種新的實驗性渲染模型,其設計考慮了串流。