Learn Next.js 官方教學筆記 - Chapter 7: Fetching Data
此系列為 Next.js 官方教學 Learn Next.js 的筆記。
上一章節 Setting Up Your Database,主要在教你把專案 push 到 GitHub 上並且利用 Vercel 部署以及建立 database 的步驟,因為蠻清楚的,所以就不多說明。
而現在我們已經建立好了 database。接下來就來討論要如何使用不同方法來 fetch 資料,並且呈現在 dashboard 的概覽上。
在這一章節可以學到以下幾個項目:
- 學習用一些方法來 fetch 資料,比如 APIs、ORMs、SQL 等等。
- Server Components 可以如何幫助我們更安全地讀取後端資源。
- Network waterfalls 是什麼?
- 如何使用 JavaScript Pattern 來實作 parallel data fetching。
Choosing how to fetch data
API layer
APIs 是你應用程式跟資料庫的中間層。那可能有幾個原因讓你必須要使用 API:
- 若你使用有提供 API 的第三方服務。
- 若你是從 client 端 fetch 資料,你可能會想避免你在伺服器的資料庫金鑰暴露在 client 端。
而在 Next.js 中,你可以使用 Route Handlers 來建立 API endpoints。
Database queries
若要建立全端的專案,可能就要有與資料庫互動的邏輯。像是 Postgres 這樣的關聯式資料庫,就可以用 SQL 或是 ORM (像是 Prisma)。
那可能有幾種情況你可能需要寫 database queries:
- 當你建立 API point時,你會需要寫一些跟資料庫互動的邏輯。
- 若你使用 React Server Components (在伺服器 fetch 資料),你可以略過 API layer,並不用冒著資料庫金鑰暴露的風險,而直接 query 你的資料庫。
Using Server Components to fetch data
預設的話,Next.js 會使用 React Server Components。這種用 Server Components fetch 資料的方式相對來說較新,而使用它們有一些好處:
- Server Components 支援 promises,也提供簡單的方式來非同步執行 data fetching。也就是說你可以使用
async/await
的語法而不用搭配useEffect
、useState
或是其他data fetching libraries。 - 因為 Server Components 會在 server 上執行,所以你可以把一些資料與邏輯留在 server,而只把結果送到 client 端。
- 也如同上面提到的,Server Components 會在 server 上執行,所以你可以不使用額外的 API layer,而直接 query the database。
Using SQL
關於你的 dashboard 專案,你將會利用 Vercel Postgres SDK 以及 SQL 來寫 database queries。而關於為什麼要使用 SQL 這邊有一些理由:
- 對於 querying 關聯式資料庫 SQL 是一個常見的方式(例如 ORMs 產生 SQL)
- 對於 SQL 有基本認識可以幫助你了解關聯式資料庫,讓你可以把知識延伸到其他工具。
- SQL 是很彈性多功的,可以讓你 fetch 以及操作資料。
- Vercel Postgres SDK 提供避免 SQL injections 的保護。
若你沒用過 SQL,別擔心,我們會提供 queries 給你。
到 /app/lib/data.ts
的檔案,你會看到檔案中已經有從 @vercel/postgres
import sql
了。這個function 讓你可以 query 你的資料庫。
import { sql } from '@vercel/postgres';
// ...
你可以在 Server Component 呼叫 sql
。而為了讓之後管理更簡單,我們會將所有 data queries 都保存在 data.ts
的檔案中,讓你可以 import 它們到其他 component。
Fetching data for the dashboard overview page
現在已經了解了不同 fetch 資料的方式,接下來讓我們來幫 dashboard fetch 所需要的資料。到 /app/dashboard/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';
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">
{/* <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">
{/* <RevenueChart revenue={revenue} /> */}
{/* <LatestInvoices latestInvoices={latestInvoices} /> */}
</div>
</main>
);
}
由上可知:
- Page 是一個 async component。這讓你可以使用
await
去 fetch 資料。 - 有三個 component 可以接收資料:
<Card>
、<RevenueChart>
以及<LatestInvoices>
。目前先註解掉以避免錯誤。
Fetching data for <RevenueChart/>
為了幫 <RevenueChart/>
component fetch 資料,必須從 data.ts
import fetchRevenue
function 並在 component 內呼叫。
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 { fetchRevenue } from '@/app/lib/data'; // Add
export default async function Page() {
const revenue = await fetchRevenue(); // Add
// ...
}
先取消註解 <RevenueChart/>
component,並到 /app/ui/dashboard/revenue-chart.tsx
把裡面的程式碼也取消註解。之後到 localhost 看看,就可以看到一個用 revenue
的圖表。
Fetching data for <LatestInvoices/>
對於 component,我們需要最新的五筆 invoice,並以日期排序。
你可以 fetch 所有 invoices 資料並用 JavaScript 來排序它們。而當資料數很少的時後這並不會是一個問題,但當應用程式愈變愈大,就會讓每次 request 的資料以及所需的 JavaScript 2都會大幅增加。
所以比起在記憶體中排序資料,你也可以使用 SQL query 來 fetch 最新五筆 invoices。比如下面在 data.ts
的 SQL query:
// Fetch the last 5 invoices, sorted by date
const data = await sql<LatestInvoiceRaw>`
SELECT invoices.amount, customers.name, customers.image_url, customers.email
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
ORDER BY invoices.date DESC
LIMIT 5`;
並在 page 中 import fetchLatestInvoices
function:
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 { fetchRevenue, fetchLatestInvoices } from '@/app/lib/data';
export default async function Page() {
const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices();
// ...
}
取消註解 <LatestInvoices />
component 並把在 /app/ui/dashboard/latest-invoices
的 component 內相關程式碼取消註解。
這時再看看 localhost,你會發現只有五筆資料會從資料庫回傳。這就是直接 query 資料庫的優勢。
Practice: Fetch data for the <Card>
components
現在來練習為 component 來 fetch 資料。這個卡片會呈現以下資料:
- 已收集的 invoices。
- 未決的 invoices。
- invoices 的總數。
- 顧客的總數。
而再一次的,你可以嘗試用 JavaScript 來 fetch 所有資料並操作它們。例如:你可以使用 Array.length
來取得 invoices 以及 customers 的總數。
const totalInvoices = allInvoices.length;
const totalCustomers = allCustomers.length;
但透過 SQL,你可以只 fetch 你需要的資料。雖然會比使用 Array.length
稍長,但代表有更少資料透過 request 來傳遞。以下是 SQL 方法:
const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
你所需要 import 的 function 是 fetchCardData
。你將需要從 function 回傳的資料做解構。
解答:
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 {
fetchRevenue,
fetchLatestInvoices,
fetchCardData, // ADD
} from '@/app/lib/data';
export default async function Page() {
const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices();
const { // ADD
numberOfInvoices, // ADD
numberOfCustomers, // ADD
totalPaidInvoices, // ADD
totalPendingInvoices, // ADD
} = await fetchCardData(); // ADD
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">
<RevenueChart revenue={revenue} />
<LatestInvoices latestInvoices={latestInvoices} />
</div>
</main>
);
}
若完成的話,就可以看到像下面這樣:
然而,這邊有兩件事需要去注意:
- 這些資料 request 無意中會造成彼此 blocking,會造成 request waterfall 的問題。
- 在預設中,Next.js 會 prerenders routes 來改善效能,這稱為 Static Rendering。所以若你的資料改變了,改變的資料並不會顯示在 dashboard 上。
那在這章節的先討論第一點,第二點會在下一章更深入地介紹。
What are request waterfalls?
waterfall 的現象牽涉到一連串的 network requests,且每一個 request 都會依賴於前一個 request 的完成。也就是說為了要 data fetching,每一個 request 都要等前一個 request 回傳資料後才能開始。
以上圖來說,在 fetchLatestInvoices()
開始前,我們需要等 fetchRevenue()
執行完成才可以。
const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices(); // wait for fetchRevenue() to finish
const {
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
} = await fetchCardData(); // wait for fetchLatestInvoices() to finish
這樣的模式也不是都不好。在某些情況下可能會需要 waterfalls,比如後面的 function 會需要前面的結果時。也有可能你會想先 fetch 使用者 ID 以及個人資料。有了 ID 後才能再去 fetch 其他相關資料。這樣的情況下,request 就需要有順序性。
然而這樣的行為可能會無法預期或是影響效能。
Parallel data fetching
一個避免 waterfall 的常見做法是讓所有 request 在最初的時候同時以平行的方式開始。
使用 JavaScript ,你可以用 Promise.all()
或是 Promise.allSettled()
function 讓所有 promises 同時開始。比方說在 data.ts,我們在 fetchCardData()
使用 Promise.all()
:
export async function fetchCardData() {
try {
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`;
// Change
const data = await Promise.all([
invoiceCountPromise,
customerCountPromise,
invoiceStatusPromise,
]);
// ...
}
}
利用這種模式,你可以:
- 讓所有 data fetching 同時開始,就可以讓效能提升。
- 這種 JavaScript 的模式也可以與其他框架或 library 共用。
然而這邊有一個缺點,那就是如果其中一個 request 比其他都慢會發生什麼事?