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 端。
藉由 streaming,你可以避免被緩慢的 data request 阻擋著,而無法載入整個頁面。這樣的方式可以讓使用者在任何 UI 顯示出來前,不需要載入全部的資料,就可以看到並且與部分頁面互動。
在 React component 中,預設就是以 Streaming 方式來運作,每一個 component 都被視為一個 chunk。
而在 Next.js 中,你有兩種方式可以實作它:
- 在 page 層級,加入
loading.tsx
的檔案。 - 在特定的 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 ,你會看到下面的畫面:
在這畫面中有幾件要知道:
loading.tsx
跟page.tsx
或layout.tsx
一樣,都是建立在 Suspense 的 Next.js 特別檔案,它可以讓你在頁面載入時,建立 fallback UI 來顯示一些替代的資訊。- 因為
<SideNav>
是 static 的,所以他會馬上的顯示出來。當其他頁面內入還在載入時,使用者就可以與<SideNav>
互動了。 - 使用者也不需要等待頁面完成載入才移動到其他導航列的項目。
到這邊基本上你已經完成了 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,畫面會是以下這樣:
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
兩個檔案放到這個資料夾當中:
如此一來,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 之外,其他都馬上就顯示出來了。
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
檔案做以下的步驟:
- 刪除你的 component。
- 刪除
fetchCardData()
function。 - import 一個新的 wrapper component
<CardWrapper />
。 - import 一個新個 skeleton component
<CardsSkeleton />
。 - 用 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 設置在哪邊,以下有幾個方向可以參考:
- 你會想要使用者對於頁面流程有什麼體驗?
- 要如何幫頁面的內容設定優先順序?
- 哪些 component 會依賴 data fetching?
但別擔心,以下也有一些解答可以參考:
- 你可以讓整個頁面顯示流程都用
loading.tsx
的方式來處理,但缺點就是若有一個 component 會載入比較久,就會導致比較長的載入時間。 - 你可以讓每個 component 分別顯示,但可能會有多個 component 突然出現(popping)的效果。
- 你也可以寄建立 page section 的機制,但可能會需要另外建立 wrapper component 來處理。
要怎麼處理你的 suspense boundaries 非常依賴頁面上內容的形式。一般來說,好的方法是把 fetch data 的動作下移到需要的 component 上,再將這些 component 包裹在 Suspense 中。但如果因為內容需要,利用 section 的概念分別顯示或是整個頁面一起也是可以。
也別太害怕利用 Suspense 做實驗,可以看看哪一個方式最適合你的頁面,可以多多利用這個 API 來幫你挑整更好的使用者體驗。