これはNext.jsの公式チュートリアルの9. Streaming に関するメモです
本章では、データ取得が遅い時にもUXを向上させる方法を学びます
前章のメモ
Next.jsの公式チュートリアルの該当ページ
学ぶこと
- ストリーミングとは何か?いつ使うのか?
- loading.tsxとSuspenseを使用してストリーミングを実装する方法
- スケルトンスクリーンとはなにか?
- ルートグループとは何か?いつ使うのか?
- Suspenseの境界をどこにするか?
ストリーミングとは何か?
ストリーミングとは、
- ページを小さな塊(チャンク)に分割し、準備できたものから表示していく技術。
- コンポーネント単位でチャンクに分割。
- 処理が遅いコンポーネントの影響でページが表示されないことを防ぐ。
実装方法としては
- ページに対しては、loading.tsx を使う
- コンポーネントに対しては、<Suspense> を使う
の2通り。
ページ全体をストリーミング
loading.tsx
ダッシュボードページでストリーミングを使ってみます!
ページ全体に適用させたいので /app/dashboard/loading.tsx を作成します
1 2 3 |
export default function Loading() { return <div>Loading...</div>; } |
ローカルサーバを起動して http://localhost:3000/dashboard にアクセスするとLoadingと表示
数秒後、今までのダッシュボード画面が表示されるようになりました
ポイントとしては
- ページ読み込み中にloading.tsxで定義したUIが表示される
- <SideNav>は静的なので、データ取得中でも表示される
- ページ読み込み中でも別のページに遷移できる
スケルトンスクリーンの実装
スケルトンスクリーンとは、ページ読み込み中に灰色のコンテンツが表示されるもの。
Youtubeとかで使われています(読み込み中はサムネが灰色になっていますね)
今回のチュートリアルでは、すでにこのUI(DashboardSkeleton)が用意されています
/app/dashboard/loading.tsx で DashboardSkeletonをインポートして使ってみましょう
1 2 3 4 5 |
import DashboardSkeleton from "@/app/ui/skeletons"; export default function Loading() { return <DashboardSkeleton />; } |
http://localhost:3000/dashboard をリロードしてみると、、、
スケルトンスクリーンが実装されていますね!
スケルトンスクリーンをダッシュボード画面でのみ使用する
現在の /app/dashboard/ 配下は↓。
1 2 3 4 5 6 7 8 |
/app/dashboard/ ├── customers │ └── page.tsx ├── invoices │ └── page.tsx ├── layout.tsx ├── loading.tsx └── page.tsx |
/app/dashboard/loading.tsx でスケルトンスクリーンを実装したんですが、
- /app/dashboard/
だけでなく、
- /app/dashboard/customers/
- /app/dashboard/invoices/
にも適用されるんですね。これが。
本アプリでは、/app/dashboard/ だけに適用したいのでRoute Groupsという機能を使います
これは、フォルダ名を () で囲うことでパスに影響を与えず、フォルダ構成をいじれます
今回の場合だと、/app/dashboard/ 配下に (overview) というフォルダを作成します。
そこに page.tsx と loading.tsx を入れます(↓参照)
1 2 3 4 5 6 7 8 9 10 |
// フォルダ構成変更後 /app/dashboard/ ├── (overview) // 新規作成 │ ├── loading.tsx // ここに移動 │ └── page.tsx // ここに移動 ├── customers │ └── page.tsx ├── invoices │ └── page.tsx └── layout.tsx |
(overview) フォルダはパスに影響を与えないので、
http://localhost:3000/dashboard にアクセスすると /app/dashboard/(overview)/page.tsxを表示。
このようにパスを変更せずにファイルの影響範囲をコントロールできるのがRoute Groups。
コンポーネントをストリーミング
Suspense
ページ全体ではなく特定のコンポーネントをストリーミングするにはSuspenseを使います
現状 fetchRevenue() の処理を重たくしているので
このデータを使っている<RevenueChart>にSuspenseを使ってストリーミング。
コードとしては、
- <RevenueChart>で fetchRevenue() を実行するように変更
- <Suspense>を<RevenueChart>に適用
Suspenseは囲ったコンポーネントに効くので
そのコンポーネント内で重たい処理をさせないと意味がないのかなと。
なので、fetchRevenue() の実行場所を移しています
まずは、/app/dashboard/(overview)/page.tsx からfetchRevenue() を削除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import { Card } from '@/app/ui/dashboard/cards'; import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; import RevenueChart from '@/app/ui/dashboard/revenue-chart'; import { lusitana } from '@/app/ui/fonts'; import { fetchCardData, fetchLatestInvoices, fetchRevenue, // 削除 } from '@/app/lib/data'; export default async function Page() { const revenue = await fetchRevenue(); // 削除 const latestInvoices = await fetchLatestInvoices(); .... |
<RevenueChart>で fetchRevenue() を実行します。
また、引数で受け取る必要がなくなったので削除します
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import { generateYAxis } from '@/app/lib/utils'; import { CalendarIcon } from '@heroicons/react/24/outline'; import { lusitana } from '@/app/ui/fonts'; import { Revenue } from '@/app/lib/definitions'; // 削除 import { fetchRevenue } from '@/app/lib/data'; // 追加 // This component is representational only. // For data visualization UI, check out: // https://www.tremor.so/ // https://www.chartjs.org/ // https://airbnb.io/visx/ export default async function RevenueChart() { // 引数削除 const revenue = await fetchRevenue(); // 追加 const chartHeight = 350; ... |
最後に引数を削除したのでそれも対応しつつ、<RevenueChart>を<Suspense>で囲みます
Suspenseのfallbackには子コンポーネントが表示されるまでのUIを設定。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import { Card } from '@/app/ui/dashboard/cards'; import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; import RevenueChart from '@/app/ui/dashboard/revenue-chart'; import { lusitana } from '@/app/ui/fonts'; import { fetchCardData, fetchLatestInvoices } from '@/app/lib/data'; import { Suspense } from 'react'; import { RevenueChartSkeleton } from '@/app/ui/skeletons'; export default async function Page() { ... <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> ); } |
ローカルサーバを立ち上げて
http://localhost:3000/dashboard にアクセスして左のグラフがスケルトンスクリーンならOK!
<LatestInvoices> もストリーミングしてみる
流れはさっきといっしょ。
- <LatestInvoices>で fetchLatestInvoices() を実行するように変更
- <Suspense>を<LatestInvoices>に適用
まずは、/app/dashboard/(overview)/page.tsx からfetchLatestInvoices() を削除
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import { Card } from '@/app/ui/dashboard/cards'; import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; import RevenueChart from '@/app/ui/dashboard/revenue-chart'; import { lusitana } from '@/app/ui/fonts'; import { fetchCardData, fetchLatestInvoices, // 削除 } from '@/app/lib/data'; import { Suspense } from 'react'; import { RevenueChartSkeleton } from '@/app/ui/skeletons'; export default async function Page() { const latestInvoices = await fetchLatestInvoices(); // 削除 const { numberOfCustomers, numberOfInvoices, totalPaidInvoices, totalPendingInvoices, } = await fetchCardData(); .... |
<LatestInvoices>で fetchLatestInvoices() を実行します。
また、引数で受け取る必要がなくなったので削除します
1 2 3 4 5 6 7 8 9 10 11 12 |
import { ArrowPathIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import Image from 'next/image'; import { lusitana } from '@/app/ui/fonts'; import { LatestInvoice } from '@/app/lib/definitions'; // 削除 import { fetchLatestInvoices } from '@/app/lib/data'; // 追加 export default async function LatestInvoices() { // 引数削除 const latestInvoices = await fetchLatestInvoices(); // 追加 ... |
最後に引数を削除したのでそれも対応しつつ、<LatestInvoices>を<Suspense>で囲みます
Suspenseのfallbackには子コンポーネントが表示されるまでのUIを設定。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import { Card } from '@/app/ui/dashboard/cards'; import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; import RevenueChart from '@/app/ui/dashboard/revenue-chart'; import { lusitana } from '@/app/ui/fonts'; import { fetchCardData } from '@/app/lib/data'; import { Suspense } from 'react'; import { RevenueChartSkeleton, LatestInvoicesSkeleton, } from '@/app/ui/skeletons'; export default async function Page() { ... <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> ); } |
スケルトンスクリーンを実装できたかわかりやすくするためにfetchLatestInvoices() を変更。
fetchRevenue() よりも実行時間が長くなるようにしておく。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// 一部抜粋 export async function fetchLatestInvoices() { noStore(); try { await new Promise((resolve) => setTimeout(resolve, 5000)); const data = await sql<LatestInvoiceRaw>` SELECT invoices.amount, customers.name, customers.image_url, customers.email, invoices.id FROM invoices JOIN customers ON invoices.customer_id = customers.id ORDER BY invoices.date DESC LIMIT 5`; const latestInvoices = data.rows.map((invoice) => ({ ...invoice, amount: formatCurrency(invoice.amount), })); return latestInvoices; } catch (error) { console.error('Database Error:', error); throw new Error('Failed to fetch the latest invoices.'); } } |
ローカルサーバを立ち上げて
http://localhost:3000/dashboard にアクセスしてグラフの右側がスケルトンスクリーンならOK!
コンポーネントのグループ化
つづいて<Card>コンポーネントをStreamingしたいのですが、コンポーネントが複数あります。
個々にSuspenseで囲うと表示できるようになったコンポーネントから順に表示され
ユーザが不快に感じる可能性があります。
また、複数のコンポーネントを一気にSuspenseで囲っても大丈夫です。
が、今回は同じコンポーネントであり、fetchCardData() が複数回はしるのがよろしくない。
そんな時はラッパーコンポーネントを使っていきます
つまり、<Card>を4つ含む新しいコンポーネントを作成しそれをSuspenseで囲います
コードとしては
- <CardWrapper>のコメントアウトを外し fetchCardData() を実行するように変更
- <Card>を削除して、<Suspense>を<CardWrapper>に適用
今回のアプリではラッパーコンポーネントである<CardWrapper>があるので、
その中身のコメントアウトを外します
また、fetchCardData() を実行するようにコードを追加します
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
import { BanknotesIcon, ClockIcon, UserGroupIcon, InboxIcon, } from '@heroicons/react/24/outline'; import { lusitana } from '@/app/ui/fonts'; import { fetchCardData } from '@/app/lib/data'; const iconMap = { collected: BanknotesIcon, customers: UserGroupIcon, pending: ClockIcon, invoices: InboxIcon, }; export default async function CardWrapper() { const { numberOfCustomers, numberOfInvoices, totalPaidInvoices, totalPendingInvoices, } = await fetchCardData(); return ( <> {/* NOTE: comment in this code when you get to this point in the course */} <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" /> </> ); } ... |
/app/dashboard/page.tsx から<Card>をすべて削除。
代わりに<Suspense>で囲った<CardWrapper>を追加。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import CardWrapper from '@/app/ui/dashboard/cards'; import LatestInvoices from '@/app/ui/dashboard/latest-invoices'; import RevenueChart from '@/app/ui/dashboard/revenue-chart'; import { lusitana } from '@/app/ui/fonts'; import { fetchCardData } from '@/app/lib/data'; // 削除 import { Suspense } from 'react'; import { RevenueChartSkeleton, LatestInvoicesSkeleton, CardsSkeleton, } from '@/app/ui/skeletons'; export default async function Page() { const { numberOfCustomers, numberOfInvoices, 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"> <Suspense fallback={<CardsSkeleton/>}> <CardWrapper /> </Suspense> </div> ... |
スケルトンスクリーンを実装できたかわかりやすくするためにfetchCardData() を変更。
fetchRevenue() よりも実行時間が長くなるようにしておく。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// 一部抜粋 export async function fetchCardData() { noStore(); try { // You can probably combine these into a single SQL query // However, we are intentionally splitting them to demonstrate // how to initialize multiple queries in parallel with JS. const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`; const customerCountPromise = sql`SELECT COUNT(*) FROM customers`; const invoiceStatusPromise = sql`SELECT SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid", SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending" FROM invoices`; await new Promise((resolve) => setTimeout(resolve, 5000)); const data = await Promise.all([ invoiceCountPromise, customerCountPromise, invoiceStatusPromise, ]); const numberOfInvoices = Number(data[0].rows[0].count ?? '0'); const numberOfCustomers = Number(data[1].rows[0].count ?? '0'); const totalPaidInvoices = formatCurrency(data[2].rows[0].paid ?? '0'); const totalPendingInvoices = formatCurrency(data[2].rows[0].pending ?? '0'); return { numberOfCustomers, numberOfInvoices, totalPaidInvoices, totalPendingInvoices, }; } catch (error) { console.error('Database Error:', error); throw new Error('Failed to fetch card data.'); } } |
ローカルサーバを立ち上げて
http://localhost:3000/dashboard にアクセスして画面上部のカードがスケルトンスクリーンならOK!
Streamingはページ全体?単一もしくは複数コンポーネント?
Streamingをページ全体やコンポーネント単位に適用させる方法を見てきました。
では、どう使い分ければいいのでしょうか??
データを取得する時間やUXなど各アプリごとに要件が異なるので一概には定義できません
結局はメリデメを把握して自分で選択できるようになるしかないのかなと。
今回だと
- ページ全体:loading.tsxで一気に定義できるが処理が長いコンポーネントに引っ張られる
- 単一のコンポーネント:個別に制御できるが個々に表示されるようになる
- 複数のコンポーネント:段階的に表示できるが、ラッパーコンポーネントが必要
いろいろ試してみてその時の最適解を探してみましょう!!
次章のメモ
コメント