Learn Next.js 官方教學筆記 - Chapter 9: Streaming

此系列為 Next.js 官方教學 Learn Next.js 的筆記。

文章連結:Chapter 9: Streaming


在前一個章節,已經讓 dashboard 的頁面可以動態更新資料,並且,也討論了緩慢的 data fetching 過程會如何影響網站的效能。那接下來再來討論看看當有這樣子緩慢的 data fetching 時,要如何改善使用者體驗。

在這一章節可以學到以下幾個項目:

  • 什麼是 streaming?以及要如何使用它?
  • 要怎麼用 streaming 實作 loading.tsx 以及 Suspense。
  • 什麼是 loading skeletons?
  • 什麼是 route groups?什麼時候可能會使用到它?
  • 怎麼在網站中處理 Suspense boundaries?

What is streaming?

Streaming 是一種資料傳輸的技巧,它可以讓你把一個路由分成較小的 chunk,並且當資料準備好時,可以逐步地把資料從 server 送到 client 端。

Image.png

藉由 streaming,你可以避免被緩慢的 data request 阻擋著,而無法載入整個頁面。這樣的方式可以讓使用者在任何 UI 顯示出來前,不需要載入全部的資料,就可以看到並且與部分頁面互動。

Image.png

在 React component 中,預設就是以 Streaming 方式來運作,每一個 component 都被視為一個 chunk。

而在 Next.js 中,你有兩種方式可以實作它:

  1. 在 page 層級,加入 loading.tsx 的檔案。
  2. 在特定的 component ,加入 <Suspense>

接下來來看看它怎麼運作。

Streaming a whole page with loading.tsx

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

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

重新整理 http://localhost:3000/dashboard ,你會看到下面的畫面:

Image.png

在這畫面中有幾件要知道:

  1. loading.tsxpage.tsxlayout.tsx 一樣,都是建立在 Suspense 的 Next.js 特別檔案,它可以讓你在頁面載入時,建立 fallback UI 來顯示一些替代的資訊。
  2. 因為 <SideNav> 是 static 的,所以他會馬上的顯示出來。當其他頁面內入還在載入時,使用者就可以與 <SideNav> 互動了。
  3. 使用者也不需要等待頁面完成載入才移動到其他導航列的項目。

到這邊基本上你已經完成了 streaming 的實作。但我們還可以再多做一些可以改善使用體驗的事。也就是使用 loading skeleton 而不是 Loading... 這樣的文字。

Adding loading skeletons

Loading skeleton 是一種 UI 的簡化版本。很多網站都會用它們來告訴使用者哪些內容正在載入。且任何在 loading.tsx 的 UI 都會是 static 檔案,並且會優先傳遞。而剩下的動態內容才會依照 stream 的方式從 server 送到 client。

loading.tsx 的檔案中,import 一個新的 component 叫做 <DashboardSkeleton>

// /app/dashboard/loading.tsx
import DashboardSkeleton from '@/app/ui/skeletons';

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

重新整理 http://localhost:3000/dashboard,畫面會是以下這樣:

Image.png

Fixing the loading skeleton bug with route groups

到這邊會發現一件事,那就是這些 loading skeleton 的效果也會發生在 invoices 以及 customers 的頁面上。

那是因為 loading.tsx 的檔案資料夾層級比 /invoices/page.tsx 以及 /customers/page.tsx 還高的關係,所以它會讓這些頁面也有相同的效果。

而這部分可以改用 Route Groups 的方式來解決。首先在 dashboard 的資料夾中建立一個新的資料夾叫做 (overview) 。然後把 loading.tsx 以及 page.tsx 兩個檔案放到這個資料夾當中:

Image.png

如此一來,loading.tsx 就會只讓 dashboard 的頁面有 loading skeleton 的效果了。

Route groups 可以讓你在邏輯上用 groups 的方式來管理檔案,並且不會影響 URL 的結構。所以當你利用 () 來建立新的資料夾時,這個資料夾名字並不會被包含在 URL 的路徑中。所以 /dashboard/(overview)/page.tsx 就會是 /dashboard

所以這邊利用了 route group 的方式來確保 loading.tsx 只會在 dashboard 中顯示效果。而 route group 的方式也可以讓你用來切分應用程式的功能(比如 (marketing) routes 或是 (shop) routes)。

Streaming a component

到這裡,已經把整個頁面都以 streaming 的方式做操作。但是,其實還可以使用 React Suspense 針對特定的 component 做更細的 streaming 處理。

在某些情況允許時(例如:資料已被載入......),Suspense 可以讓你延遲渲染部分的頁面。所以當你把動態內容的 component 包裹在 Suspense 後,給它一個 fallback component 讓它可以在 component 載入時顯示。

記得前幾章那個緩慢的 data fetching request fetchRevenue() 嗎?這個 request 會讓整個頁面都慢下來。所以你可以做的是,使用 Suspense 來 stream 這個 component 並立即顯示其他頁面上的 UI,而不是讓他阻擋了頁面的載入。

如果要用這個方法的話,就必須要把 data fetch 的 function 搬到 component 中,接下來就來更新程式碼吧!

/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 import <Suspense>,並用它把 <RevenueChart /> 包住。並且給 <Suspense> 一個 fallback component props <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'; // ADD
import { RevenueChartSkeleton } from '@/app/ui/skeletons'; // ADD

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 />}> // ADD
          <RevenueChart /> // ADD
        </Suspense> // ADD
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

最後,更新 <RevenueChart> component 來 fetch 它自己的資料,並移除原本給的 props:

// /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'; // ADD

// ...

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 (
    // ...
  );
}

然後重新整理後,就可以看到 dashboard 上除了 顯示 fallback skeketon 之外,其他都馬上就顯示出來了。

Image.png

Practice: Streaming <LatestInvoices>

現在來練習看看對 <LatestInvoices> component 做 stream。

大概的方式是把 fetchLatestInvoices()<LatestInvoices> component中移除。並把 <LatestInvoices /> 用帶有 <LatestInvoicesSkeleton> fallback 的 <Suspense> 包裹住。

若做好了再來看接下來的內容:

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> component ,記得移除 props :

// /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 (
    // ...
  );
}

Grouping components

快要完成這個章節了,接下來就把 <Card> components 用 Suspense 包裹住就可以了。但其實以這樣的方式來說,在為每個卡片 fetch data 時,每個卡片載入好之後可能會有突然出現的效果(popping effect),這樣可能會在視覺上給使用者不好的觀感。

所以,要怎麼解決這個問題呢?

為了要錯開這些效果,可以把這些卡片都用 wrapper component 群組起來。這代表說 static 的 <SideNav /> 會先被顯示出來,再來才是這些卡片。

page.tsx 檔案做以下的步驟:

  1. 刪除你的 component。
  2. 刪除 fetchCardData() function。
  3. import 一個新的 wrapper component <CardWrapper />
  4. import 一個新個 skeleton component <CardsSkeleton />
  5. 用 Suspense 把 包裹住。
// /app/dashboard/page.tsx

import CardWrapper from '@/app/ui/dashboard/cards'; // ADD
// ...
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
  CardsSkeleton, // ADD
} 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 />}> // ADD
          <CardWrapper /> // ADD
        </Suspense // ADD
      </div>
      // ...
    </main>
  );
}

接下來到 /app/ui/dashboard/cards.tsx,import fetchCardData() function,並在 <CardWrapper/> component 內呼叫它。確保取消註腳那些在 component 內需要的程式碼。

// /app/ui/dashboard/cards.tsx

// ...
import { fetchCardData } from '@/app/lib/data'; // ADD

// ...

export default async function CardWrapper() {
  const { // ADD
    numberOfInvoices, // ADD
    numberOfCustomers // ADD
    totalPaidInvoices, // ADD
    totalPendingInvoices, // ADD
  } = await fetchCardData(); // ADD

  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"
      />
    </>
  );
}

重新整理頁面,就會看到所有卡片都在同時間載入。你可以利用這樣的 pattern 來讓多個 component 在同時間載入。

Deciding where to place your Suspense boundaries

對於應該把 Suspense boundaries 設置在哪邊,以下有幾個方向可以參考:

  1. 你會想要使用者對於頁面流程有什麼體驗?
  2. 要如何幫頁面的內容設定優先順序?
  3. 哪些 component 會依賴 data fetching?

但別擔心,以下也有一些解答可以參考:

  • 你可以讓整個頁面顯示流程都用 loading.tsx 的方式來處理,但缺點就是若有一個 component 會載入比較久,就會導致比較長的載入時間。
  • 你可以讓每個 component 分別顯示,但可能會有多個 component 突然出現(popping)的效果。
  • 你也可以寄建立 page section 的機制,但可能會需要另外建立 wrapper component 來處理。

要怎麼處理你的 suspense boundaries 非常依賴頁面上內容的形式。一般來說,好的方法是把 fetch data 的動作下移到需要的 component 上,再將這些 component 包裹在 Suspense 中。但如果因為內容需要,利用 section 的概念分別顯示或是整個頁面一起也是可以。

也別太害怕利用 Suspense 做實驗,可以看看哪一個方式最適合你的頁面,可以多多利用這個 API 來幫你挑整更好的使用者體驗。