现在你已经创建并填充了数据库,让我们讨论你可以用来获取应用数据的不同方式,并构建你的仪表板概览页面。
在本章中
我们将涵盖以下主题:
- 数据获取方法的选择: 了解一些数据获取的方式,比如 API、ORM 和 SQL 等。
- 服务器组件的使用: 学习服务器组件如何帮助你更安全地访问后端资源。
- 什么是网络瀑布流: 了解网络瀑布流的概念。
- 实现并行数据获取: 使用 JavaScript 模式来实现并行的数据获取。
选择数据获取方式
API 层
API 是你的应用代码和数据库之间的中介层。你可能会在以下几种情况下使用 API:
- 如果你正在使用提供 API 的第三方服务。
- 如果你从客户端获取数据,你需要一个在服务器上运行的 API 层,以避免将数据库密钥暴露给客户端。
在 Next.js 中,你可以使用 路由处理程序(Route Handlers) 来创建 API 端点。
数据库查询
当你创建一个全栈应用时,你还需要编写逻辑来与数据库交互。对于 关系型数据库(如 Postgres),你可以使用 SQL 或 ORM。
在以下几种情况下,你需要编写数据库查询:
- 当创建 API 端点时,你需要编写逻辑来与数据库交互。
- 如果你使用 React 服务器组件(在服务器上获取数据),你可以跳过 API 层,直接查询数据库,而不会将数据库密钥暴露给客户端。
现在是测试时间!
在以下场景中,你不应该直接查询数据库的是哪个?
A. 当你在客户端获取数据时 B. 当你在服务器上获取数据时 C. 当你创建自己的 API 层与数据库交互时
让我们进一步了解 React 服务器组件。
使用服务器组件获取数据
默认情况下,Next.js 应用使用 React 服务器组件。使用服务器组件获取数据是一种相对较新的方法,它有几个优点:
- 服务器组件支持 Promise,提供了一个更简单的解决方案来处理异步任务,比如数据获取。你可以使用
async/await
语法,而无需使用useEffect
、useState
或数据获取库。 - 服务器组件在服务器上执行,因此你可以将昂贵的数据获取和逻辑保留在服务器上,只将结果发送到客户端。
- 正如前面提到的,服务器组件在服务器上执行,因此你可以直接查询数据库,而无需额外的 API 层。
现在是测试时间!
使用 React 服务器组件获取数据的一个优点是什么?
A. 它们自动保护你免受 SQL 注入攻击。 B. 它们允许你直接从服务器查询数据库,而无需额外的 API 层。 C. 它们要求你使用 API 层并创建端点。
使用 SQL
对于你的仪表板项目,你将使用 Vercel Postgres SDK 和 SQL 编写数据库查询。我们使用 SQL 的原因有几点:
- SQL 是查询关系型数据库的行业标准(例如,ORM 在底层生成 SQL)。
- 对 SQL 有基本了解可以帮助你理解关系型数据库的基本原理,从而将知识应用于其他工具。
- SQL 是多功能的,允许你获取和操作特定数据。
- Vercel Postgres SDK 提供了对 SQL 注入 的保护。
即使你之前没有使用过 SQL,也不用担心——我们已经为你提供了查询。
前往 /app/lib/data.ts
,在这里你会看到我们从 @vercel/postgres
导入了 sql
函数。这个函数允许你查询数据库:
// /app/lib/data.ts import { sql } from '@vercel/postgres'; // ...
你可以在任何服务器组件中调用 sql
。为了让你更方便地浏览组件,我们将所有数据查询保留在 data.ts
文件中,你可以将它们导入到组件中。
现在是测试时间!
测试你的知识,看看你刚学到的内容。
SQL 在数据获取方面允许你做什么?
A. 无差别地获取所有数据 B. 获取和操作特定数据 C. 自动缓存数据以提高性能 D. 动态更改数据库模式
注意: 如果你在第六章中使用了自己的数据库提供商,你需要更新数据库查询以使其适应你的提供商。你可以在
/app/lib/data.ts
文件中找到查询。
为仪表板概览页面获取数据
现在你了解了不同的数据获取方式,让我们为仪表板概览页面获取数据。前往 /app/dashboard/page.tsx
,粘贴以下代码,并花些时间探索它:
// /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
是一个 异步 组件。这允许你使用await
来获取数据。 - 还有三个接收数据的组件:
<Card>
、<RevenueChart>
和<LatestInvoices>
。它们目前被注释掉了,以防止应用程序出现错误。
为 <RevenueChart/> 获取数据
要为 <RevenueChart/>
组件获取数据,从 data.ts
导入 fetchRevenue
函数,并在组件内部调用它:
// /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'; import { fetchRevenue } from '@/app/lib/data'; export default async function Page() { const revenue = await fetchRevenue(); // ... }
然后,取消注释 <RevenueChart/>
组件,前往组件文件 (/app/ui/dashboard/revenue-chart.tsx
),并取消注释其中的代码。检查你的本地服务器,你应该能看到使用 revenue
数据的图表。
我们继续导入更多数据查询吧!
为 <LatestInvoices/> 获取数据
对于 <LatestInvoices />
组件,我们需要获取最新的 5 张发票,并按日期排序。
你可以获取所有发票,然后使用 JavaScript 对它们进行排序。虽然这样做没有问题,因为数据量小,但随着应用程序的增长,这会显著增加每次请求的数据传输量以及需要排序的 JavaScript 代码量。
与其在内存中对最新发票进行排序,不如使用 SQL 查询仅获取最后 5 张发票。例如,这是你 data.ts
文件中的 SQL 查询:
// /app/lib/data.ts // 获取按日期排序的最后 5 张发票 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 `;
在你的页面中,导入 fetchLatestInvoices
函数:
// /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'; import { fetchRevenue, fetchLatestInvoices } from '@/app/lib/data'; export default async function Page() { const revenue = await fetchRevenue(); const latestInvoices = await fetchLatestInvoices(); // ... }
然后,取消注释 <LatestInvoices />
组件。你还需要取消注释 <LatestInvoices />
组件文件中的相关代码,位于 /app/ui/dashboard/latest-invoices
。
如果你访问本地服务器,你应该能看到从数据库中返回的最新 5 张发票。希望你开始看到直接查询数据库的优势!
练习:为 <Card> 组件获取数据
现在轮到你来为 <Card>
组件获取数据了。这些卡片将显示以下数据:
- 已收取的发票总金额。
- 待处理的发票总金额。
- 发票总数。
- 客户总数。
你可能会想直接获取所有发票和客户,并使用 JavaScript 操作数据。例如,你可以使用 Array.length
来获取发票和客户的总数:
const totalInvoices = allInvoices.length; const totalCustomers = allCustomers.length;
但使用 SQL,你可以仅获取所需的数据。虽然这样做比使用 Array.length
稍长,但它意味着每次请求时需要传输的数据更少。这是 SQL 的替代方案:
// /app/lib/data.ts const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`; const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
你需要导入的函数是 fetchCardData
。你需要解构函数返回的值。
提示:
- 查看卡片组件以了解它们需要哪些数据。
- 查看
data.ts
文件以了解函数返回的内容。
一旦准备好,查看最终代码:
解决方案
// /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'; import { fetchRevenue, fetchLatestInvoices, fetchCardData } from '@/app/lib/data'; export default async function Page() { const revenue = await fetchRevenue(); 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"> <RevenueChart revenue={revenue} /> <LatestInvoices latestInvoices={latestInvoices} /> </div> </main> ); }
太棒了!你现在已经为仪表板概览页面获取了所有数据。你的页面应该看起来像这样:
不过,有两件事你需要注意:
- 数据请求无意中互相阻塞,形成了 **请求瀑布流(request waterfall)**。
- 默认情况下,Next.js 预渲染(prerenders) 路由以提高性能,这叫做 **静态渲染(Static Rendering)**。所以如果数据发生变化,它不会在你的仪表板中反映出来。
让我们在本章讨论第 1 点,然后在下一章详细了解第 2 点。
什么是请求瀑布流?
“瀑布流”指的是一系列网络请求,它们依赖于前一个请求的完成。在数据获取的情况下,每个请求只能在前一个请求返回数据后开始。
例如,我们需要等 fetchRevenue()
执行完成后,fetchLatestInvoices()
才能开始运行,以此类推。
// /app/dashboard/page.tsx const revenue = await fetchRevenue(); const latestInvoices = await fetchLatestInvoices(); // 等待 fetchRevenue() 完成 const { numberOfInvoices, numberOfCustomers, totalPaidInvoices, totalPendingInvoices, } = await fetchCardData(); // 等待 fetchLatestInvoices() 完成
这种模式不一定是坏的。在某些情况下,你可能希望使用瀑布流,因为你希望在进行下一个请求之前满足某个条件。例如,你可能希望首先获取用户的 ID 和个人信息。获得 ID 后,你可能会继续获取他们的好友列表。在这种情况下,每个请求都依赖于前一个请求返回的数据。
然而,这种行为也可能是无意的,会影响性能
现在是测试时间!
在什么情况下你可能会使用瀑布流模式?
A. 为了在进行下一个请求之前满足条件 B. 为了同时发起所有请求 C. 通过一次获取减少服务器负载
并行数据获取
避免瀑布流的一种常见方法是同时发起所有数据请求——即并行处理。
在 JavaScript 中,你可以使用 Promise.all() 或 Promise.allSettled() 函数来同时发起所有 Promise。例如,在 data.ts
中,我们在 fetchCardData()
函数中使用了 Promise.all()
:
// /app/lib/data.ts 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 `; const data = await Promise.all([ invoiceCountPromise, customerCountPromise, invoiceStatusPromise, ]); // ... } }
通过使用这种模式,你可以:
- 同时启动所有数据获取,这可以带来性能提升。
- 使用 JavaScript 原生模式,这可以应用于任何库或框架。
然而,仅依赖这种 JavaScript 模式有一个缺点: 如果某个数据请求比其他请求都慢,该怎么办?