数据获取

现在您已经创建并填充了数据库,让我们讨论为应用程序获取数据的不同方式,并构建仪表盘概览页面。

选择数据获取方式

API 层

API 是应用程序代码与数据库之间的中间层。在以下情况下您可能会使用 API:

  • 使用提供 API 的第三方服务时
  • 从客户端获取数据时,您需要运行在服务端的 API 层来避免向客户端暴露数据库凭证

在 Next.js 中,您可以使用 路由处理器 (Route Handlers) 创建 API 端点。

数据库查询

构建全栈应用时,您还需要编写与数据库交互的逻辑。对于 Postgres 等关系型数据库,您可以使用 SQL 或 ORM

以下情况需要编写数据库查询:

  • 创建 API 端点时,需要编写与数据库交互的逻辑
  • 使用 React 服务端组件 (Server Components) 时(在服务端获取数据),您可以跳过 API 层直接查询数据库,而不会向客户端暴露数据库凭证

让我们进一步了解 React 服务端组件。

使用服务端组件获取数据

默认情况下,Next.js 应用使用 React 服务端组件。使用服务端组件获取数据是一种较新的方式,具有以下优势:

  • 服务端组件原生支持 JavaScript Promise,为数据获取等异步任务提供了解决方案。您可以直接使用 async/await 语法,无需依赖 useEffectuseState 或其他数据获取库
  • 服务端组件运行在服务端,因此可以将昂贵的数据获取和逻辑保留在服务端,仅将结果发送到客户端
  • 由于服务端组件运行在服务端,您可以直接查询数据库而无需额外的 API 层,从而减少需要编写和维护的代码量

使用 SQL

在仪表盘应用中,您将使用 postgres.js 库和 SQL 编写数据库查询。我们选择 SQL 的原因如下:

  • SQL 是查询关系型数据库的行业标准(例如 ORM 底层也是生成 SQL)
  • 掌握 SQL 基础有助于理解关系型数据库的核心原理,这些知识可应用于其他工具
  • SQL 功能强大,可以获取和操作特定数据
  • postgres.js 库提供了防止 SQL 注入的保护机制

如果您之前没有使用过 SQL 也不用担心——我们已经为您准备好了查询语句。

转到 /app/lib/data.ts 文件,您会看到我们正在使用 postgressql 函数允许您查询数据库:

/app/lib/data.ts
import postgres from 'postgres';
 
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
 
// ...

您可以在服务端的任何地方(如服务端组件中)调用 sql。但为了便于导航组件,我们将所有数据查询保留在 data.ts 文件中,您可以将其导入组件使用。

注意: 如果您在第 6 章使用了其他数据库提供商,需要更新 /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 是一个 async 服务端组件,允许您使用 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();
  // ...
}

接下来:

  1. 取消注释 <RevenueChart/> 组件
  2. 导航到组件文件 (/app/ui/dashboard/revenue-chart.tsx) 并取消注释其中的代码
  3. 检查 localhost:3000,您应该能看到使用 revenue 数据的图表
展示最近12个月总收入的收入图表

让我们继续导入更多数据并显示在仪表盘上。

<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 /> 组件。您还需要取消注释位于 /app/ui/dashboard/latest-invoices<LatestInvoices /> 组件中的相关代码。

访问本地主机时,您应该看到数据库只返回了最新的 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 文件了解函数返回的内容

准备好后,展开下方查看最终代码:

太棒了!您现在已经为仪表盘概览页面获取了所有数据。页面应该如下所示:

已获取全部数据的仪表盘页面

但是...有两点需要注意:

  1. 数据请求无意中相互阻塞,形成了请求瀑布流
  2. 默认情况下,Next.js 会预渲染路由以提高性能,这称为静态渲染。因此如果数据变化,仪表盘不会更新

我们将在本章讨论第一点,然后在下一章详细探讨第二点。

什么是请求瀑布流?

"瀑布流"指的是一系列相互依赖的网络请求。在数据获取场景中,每个请求必须等待前一个请求返回数据后才能开始。

展示串行数据获取与并行数据获取的时间图

例如,我们需要等待 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 获取好友列表。这种情况下,每个请求都依赖于前一个请求返回的数据。

然而,这种行为也可能是无意的,并影响性能。

并行数据获取

避免瀑布流的常见方法是同时发起所有数据请求——即并行获取。

在 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 模式有一个缺点:如果某个数据请求比其他请求慢会怎样?我们将在下一章详细探讨。

On this page