跳至内容

12

修改数据

在上一章中,您使用 URL 搜索参数和 Next.js API 实现了搜索和分页功能。让我们继续处理发票页面,添加创建、更新和删除发票的功能!

本章内容…

以下是我们将涵盖的主题

什么是 React 服务器操作以及如何使用它们来修改数据。

如何使用表单和服务器组件。

使用原生 formData 对象的最佳实践,包括类型验证。

如何使用 revalidatePath API 重新验证客户端缓存。

如何使用特定 ID 创建动态路由段。

什么是服务器操作?

React 服务器操作允许您直接在服务器上运行异步代码。它们消除了创建 API 端点来修改数据的需要。相反,您编写在服务器上执行的异步函数,并且可以从您的客户端或服务器组件中调用它们。

安全性是 Web 应用程序的首要任务,因为它们可能容易受到各种威胁。这就是服务器操作发挥作用的地方。它们提供了一种有效的安全解决方案,防止不同类型的攻击,保护您的数据并确保授权访问。服务器操作通过 POST 请求、加密闭包、严格的输入检查、错误消息哈希和主机限制等技术来实现这一点,所有这些技术共同显著增强了应用程序的安全性。

使用表单和服务器操作

在 React 中,您可以在 <form> 元素中使用 action 属性来调用操作。该操作将自动接收原生 FormData 对象,其中包含捕获的数据。

例如

// Server Component
export default function Page() {
  // Action
  async function create(formData: FormData) {
    'use server';
 
    // Logic to mutate data...
  }
 
  // Invoke the action using the "action" attribute
  return <form action={create}>...</form>;
}

在服务器组件中调用服务器操作的一个优点是渐进增强——即使客户端禁用了 JavaScript,表单也能正常工作。

Next.js 和服务器操作

服务器操作也与 Next.js 的 缓存 深度集成。当通过服务器操作提交表单时,您不仅可以使用该操作来修改数据,还可以使用 revalidatePathrevalidateTag 等 API 重新验证相关缓存。

让我们看看它们是如何协同工作的!

创建发票

以下是您将采取的创建新发票的步骤

  1. 创建一个表单来捕获用户的输入。
  2. 创建一个服务器操作并从表单中调用它。
  3. 在您的服务器操作中,从 formData 对象中提取数据。
  4. 验证并准备要插入数据库的数据。
  5. 插入数据并处理任何错误。
  6. 重新验证缓存并将用户重定向回发票页面。

1. 创建一个新的路由和表单

首先,在 /invoices 文件夹内,添加一个名为 /create 的新路由段,并创建一个 page.tsx 文件

Invoices folder with a nested create folder, and a page.tsx file inside it

您将使用此路由创建新发票。在您的 page.tsx 文件中,粘贴以下代码,然后花一些时间学习它

/dashboard/invoices/create/page.tsx
import Form from '@/app/ui/invoices/create-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
 
export default async function Page() {
  const customers = await fetchCustomers();
 
  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Create Invoice',
            href: '/dashboard/invoices/create',
            active: true,
          },
        ]}
      />
      <Form customers={customers} />
    </main>
  );
}

您的页面是一个服务器组件,它获取 customers 并将其传递给 <Form> 组件。为了节省时间,我们已经为您创建了 <Form> 组件。

导航到 <Form> 组件,您会看到该表单

  • 有一个 <select>(下拉列表)元素,其中包含 客户 列表。
  • 有一个 <input> 元素用于 金额,其 type="number"
  • 有两个 <input> 元素用于状态,其 type="radio"
  • 有一个带有 type="submit" 的按钮。

https://127.0.0.1:3000/dashboard/invoices/create 上,您应该会看到以下 UI

Create invoices page with breadcrumbs and form

2. 创建一个服务器操作

很好,现在让我们创建一个服务器操作,当提交表单时将调用它。

导航到您的 lib 目录并创建一个名为 actions.ts 的新文件。在此文件的顶部,添加 React 的 use server 指令

/app/lib/actions.ts
'use server';

通过添加 'use server',您将文件中的所有导出函数标记为服务器操作。然后,这些服务器函数可以在客户端和服务器组件中导入和使用。

您还可以通过在操作内部添加 "use server" 来直接在服务器组件中编写服务器操作。但是对于本课程,我们将把它们全部组织在一个单独的文件中。

在您的 actions.ts 文件中,创建一个新的异步函数,它接受 formData

/app/lib/actions.ts
'use server';
 
export async function createInvoice(formData: FormData) {}

然后,在您的 <Form> 组件中,从您的 actions.ts 文件导入 createInvoice。向 <form> 元素添加一个 action 属性,并调用 createInvoice 操作。

/app/ui/invoices/create-form.tsx
import { customerField } from '@/app/lib/definitions';
import Link from 'next/link';
import {
  CheckIcon,
  ClockIcon,
  CurrencyDollarIcon,
  UserCircleIcon,
} from '@heroicons/react/24/outline';
import { Button } from '@/app/ui/button';
import { createInvoice } from '@/app/lib/actions';
 
export default function Form({
  customers,
}: {
  customers: customerField[];
}) {
  return (
    <form action={createInvoice}>
      // ...
  )
}

注意:在 HTML 中,您将向 action 属性传递一个 URL。此 URL 将是表单数据应提交到的目标(通常是 API 端点)。

但是,在 React 中,action 属性被认为是一个特殊的 prop——这意味着 React 在其基础上构建以允许调用操作。

在幕后,服务器操作会创建一个 POST API 端点。这就是为什么在使用服务器操作时不需要手动创建 API 端点的原因。

3. 从 formData 中提取数据

回到你的actions.ts文件,你需要提取formData的值,有几种方法可以使用。在本例中,让我们使用.get(name)方法。

/app/lib/actions.ts
'use server';
 
export async function createInvoice(formData: FormData) {
  const rawFormData = {
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  };
  // Test it out:
  console.log(rawFormData);
}

提示:如果你正在处理包含许多字段的表单,你可能需要考虑使用entries()方法以及JavaScript的Object.fromEntries()。例如

const rawFormData = Object.fromEntries(formData.entries())

要检查所有内容是否正确连接,请尝试使用表单。提交后,你应该会看到你在表单中输入的数据已记录在你的终端中。

现在你的数据已成为对象的形式,处理起来会容易得多。

4. 验证和准备数据

在将表单数据发送到数据库之前,你需要确保它格式正确且类型正确。如果你还记得课程前面讲的内容,你的发票表期望数据采用以下格式

/app/lib/definitions.ts
export type Invoice = {
  id: string; // Will be created on the database
  customer_id: string;
  amount: number; // Stored in cents
  status: 'pending' | 'paid';
  date: string;
};

到目前为止,你只有表单中的customer_idamountstatus

类型验证和强制转换

验证表单数据是否与数据库中预期类型一致非常重要。例如,如果你在操作中添加一个console.log

console.log(typeof rawFormData.amount);

你会注意到amount的类型是string而不是number。这是因为type="number"input元素实际上返回的是字符串,而不是数字!

为了处理类型验证,你有一些选择。虽然你可以手动验证类型,但使用类型验证库可以为你节省时间和精力。在本例中,我们将使用Zod,这是一个TypeScript优先的验证库,可以简化你的任务。

在你的actions.ts文件中,导入Zod并定义一个与表单对象形状匹配的模式。此模式将在将formData保存到数据库之前对其进行验证。

/app/lib/actions.ts
'use server';
 
import { z } from 'zod';
 
const FormSchema = z.object({
  id: z.string(),
  customerId: z.string(),
  amount: z.coerce.number(),
  status: z.enum(['pending', 'paid']),
  date: z.string(),
});
 
const CreateInvoice = FormSchema.omit({ id: true, date: true });
 
export async function createInvoice(formData: FormData) {
  // ...
}

amount字段专门设置为从字符串强制转换为数字,同时还验证其类型。

然后,你可以将你的rawFormData传递给CreateInvoice来验证类型

/app/lib/actions.ts
// ...
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
}

以美分存储值

通常最好在数据库中以美分存储货币值,以消除JavaScript浮点错误并确保更高的精度。

让我们将金额转换为美分

/app/lib/actions.ts
// ...
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
}

创建新日期

最后,让我们使用“YYYY-MM-DD”格式为发票的创建日期创建一个新日期

/app/lib/actions.ts
// ...
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
}

5. 将数据插入数据库

现在你拥有了数据库所需的所有值,你可以创建一个SQL查询将新发票插入到你的数据库中并传入变量

/app/lib/actions.ts
import { z } from 'zod';
import { sql } from '@vercel/postgres';
 
// ...
 
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  await sql`
    INSERT INTO invoices (customer_id, amount, status, date)
    VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
  `;
}

目前,我们没有处理任何错误。我们将在下一章中进行处理。现在,让我们继续下一步。

6. 重新验证和重定向

Next.js有一个客户端路由缓存,它在一段时间内将路由片段存储在用户的浏览器中。与预取一起,此缓存确保用户可以快速在路由之间导航,同时减少对服务器发出的请求数量。

由于你正在更新发票路由中显示的数据,因此你需要清除此缓存并触发对服务器的新请求。你可以使用Next.js中的revalidatePath函数来实现。

/app/lib/actions.ts
'use server';
 
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
 
// ...
 
export async function createInvoice(formData: FormData) {
  const { customerId, amount, status } = CreateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
  const amountInCents = amount * 100;
  const date = new Date().toISOString().split('T')[0];
 
  await sql`
    INSERT INTO invoices (customer_id, amount, status, date)
    VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
  `;
 
  revalidatePath('/dashboard/invoices');
}

数据库更新后,/dashboard/invoices路径将重新验证,并且将从服务器获取最新数据。

此时,你还需要将用户重定向回/dashboard/invoices页面。你可以使用Next.js中的redirect函数来实现。

/app/lib/actions.ts
'use server';
 
import { z } from 'zod';
import { sql } from '@vercel/postgres';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
 
// ...
 
export async function createInvoice(formData: FormData) {
  // ...
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

恭喜!你刚刚实现了你的第一个服务器操作。通过添加新发票进行测试,如果一切正常

  1. 提交后,你应该会重定向到/dashboard/invoices路由。
  2. 你应该会看到新发票位于表格顶部。

更新发票

更新发票表单与创建发票表单类似,只是你需要传递发票id来更新数据库中的记录。让我们看看如何获取和传递发票id

以下是你将采取的更新发票步骤

  1. 使用发票id创建一个新的动态路由片段。
  2. 从页面参数中读取发票id
  3. 从你的数据库中获取特定发票。
  4. 使用发票数据预填充表单。
  5. 更新数据库中的发票数据。

1. 使用发票id创建动态路由片段

Next.js允许你在不知道确切的片段名称并希望根据数据创建路由时创建动态路由片段。这可能是博客文章标题、产品页面等。你可以通过将文件夹名称用方括号括起来来创建动态路由片段。例如,[id][post][slug]

在你的/invoices文件夹中,创建一个名为[id]的新动态路由,然后创建一个名为edit的新路由,并包含一个page.tsx文件。你的文件结构应如下所示

Invoices folder with a nested [id] folder, and an edit folder inside it

在你的<Table>组件中,请注意有一个<UpdateInvoice />按钮,它从表格记录中接收发票的id

/app/ui/invoices/table.tsx
export default async function InvoicesTable({
  query,
  currentPage,
}: {
  query: string;
  currentPage: number;
}) {
  return (
    // ...
    <td className="flex justify-end gap-2 whitespace-nowrap px-6 py-4 text-sm">
      <UpdateInvoice id={invoice.id} />
      <DeleteInvoice id={invoice.id} />
    </td>
    // ...
  );
}

导航到你的<UpdateInvoice />组件,并将Linkhref更新为接受idprop。你可以使用模板文字链接到动态路由片段

/app/ui/invoices/buttons.tsx
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
 
// ...
 
export function UpdateInvoice({ id }: { id: string }) {
  return (
    <Link
      href={`/dashboard/invoices/${id}/edit`}
      className="rounded-md border p-2 hover:bg-gray-100"
    >
      <PencilIcon className="w-5" />
    </Link>
  );
}

2. 从页面params中读取发票id

回到你的<Page>组件,粘贴以下代码

/app/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
 
export default async function Page() {
  return (
    <main>
      <Breadcrumbs
        breadcrumbs={[
          { label: 'Invoices', href: '/dashboard/invoices' },
          {
            label: 'Edit Invoice',
            href: `/dashboard/invoices/${id}/edit`,
            active: true,
          },
        ]}
      />
      <Form invoice={invoice} customers={customers} />
    </main>
  );
}

请注意,它与你的/create发票页面类似,只是它导入了不同的表单(来自edit-form.tsx文件)。此表单应使用客户姓名、发票金额和状态的defaultValue进行预填充。要预填充表单字段,你需要使用id获取特定发票。

除了searchParams之外,页面组件还接受一个名为params的prop,你可以使用它来访问id。更新你的<Page>组件以接收此prop

/app/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchCustomers } from '@/app/lib/data';
 
export default async function Page(props: { params: Promise<{ id: string }> }) {
  const params = await props.params;
  const id = params.id;
  // ...
}

3. 获取特定发票

然后

  • 导入一个名为 fetchInvoiceById 的新函数,并将 id 作为参数传递。
  • 导入 fetchCustomers 以获取下拉菜单中的客户姓名。

您可以使用 Promise.all 并行获取发票和客户信息

/dashboard/invoices/[id]/edit/page.tsx
import Form from '@/app/ui/invoices/edit-form';
import Breadcrumbs from '@/app/ui/invoices/breadcrumbs';
import { fetchInvoiceById, fetchCustomers } from '@/app/lib/data';
 
export default async function Page(props: { params: Promise<{ id: string }> }) {
  const params = await props.params;
  const id = params.id;
  const [invoice, customers] = await Promise.all([
    fetchInvoiceById(id),
    fetchCustomers(),
  ]);
  // ...
}

您会在终端看到 invoice 属性的临时 TS 错误,因为 invoice 可能会是 undefined。暂时不用担心,您将在下一章添加错误处理时解决它。

很好!现在,测试所有内容是否正确连接。访问 https://127.0.0.1:3000/dashboard/invoices 并点击铅笔图标编辑发票。导航后,您应该会看到一个预填充了发票详细信息的表单

Edit invoices page with breadcrumbs and form

URL 也会更新为包含 id,如下所示:https://127.0.0.1:3000/dashboard/invoice/uuid/edit

UUID 与自增主键

我们使用 UUID 而不是自增主键(例如,1、2、3 等)。这使得 URL 更长;但是,UUID 消除了 ID 冲突的风险,是全局唯一的,并降低了枚举攻击的风险——这使得它们非常适合大型数据库。

但是,如果您更喜欢更简洁的 URL,则可能更喜欢使用自增主键。

4. 将 id 传递给服务器操作

最后,您需要将 id 传递给服务器操作,以便您可以在数据库中更新正确的记录。您**不能**像这样将 id 作为参数传递

/app/ui/invoices/edit-form.tsx
// Passing an id as argument won't work
<form action={updateInvoice(id)}>

相反,您可以使用 JS bindid 传递给服务器操作。这将确保传递给服务器操作的任何值都被编码。

/app/ui/invoices/edit-form.tsx
// ...
import { updateInvoice } from '@/app/lib/actions';
 
export default function EditInvoiceForm({
  invoice,
  customers,
}: {
  invoice: InvoiceForm;
  customers: CustomerField[];
}) {
  const updateInvoiceWithId = updateInvoice.bind(null, invoice.id);
 
  return (
    <form action={updateInvoiceWithId}>
      <input type="hidden" name="id" value={invoice.id} />
    </form>
  );
}

注意:在表单中使用隐藏输入字段也可以(例如 <input type="hidden" name="id" value={invoice.id} />)。但是,这些值会以纯文本形式显示在 HTML 源代码中,这对于像 ID 这样的敏感数据来说并不理想。

然后,在您的 actions.ts 文件中,创建一个新的操作 updateInvoice

/app/lib/actions.ts
// Use Zod to update the expected types
const UpdateInvoice = FormSchema.omit({ id: true, date: true });
 
// ...
 
export async function updateInvoice(id: string, formData: FormData) {
  const { customerId, amount, status } = UpdateInvoice.parse({
    customerId: formData.get('customerId'),
    amount: formData.get('amount'),
    status: formData.get('status'),
  });
 
  const amountInCents = amount * 100;
 
  await sql`
    UPDATE invoices
    SET customer_id = ${customerId}, amount = ${amountInCents}, status = ${status}
    WHERE id = ${id}
  `;
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

createInvoice 操作类似,这里您将

  1. formData 中提取数据。
  2. 使用 Zod 验证类型。
  3. 将金额转换为美分。
  4. 将变量传递给您的 SQL 查询。
  5. 调用 revalidatePath 以清除客户端缓存并发出新的服务器请求。
  6. 调用 redirect 将用户重定向到发票页面。

通过编辑发票进行测试。提交表单后,您应该会被重定向到发票页面,并且发票应该已更新。

删除发票

要使用服务器操作删除发票,请将删除按钮包装在 <form> 元素中,并使用 bindid 传递给服务器操作

/app/ui/invoices/buttons.tsx
import { deleteInvoice } from '@/app/lib/actions';
 
// ...
 
export function DeleteInvoice({ id }: { id: string }) {
  const deleteInvoiceWithId = deleteInvoice.bind(null, id);
 
  return (
    <form action={deleteInvoiceWithId}>
      <button type="submit" className="rounded-md border p-2 hover:bg-gray-100">
        <span className="sr-only">Delete</span>
        <TrashIcon className="w-4" />
      </button>
    </form>
  );
}

在您的 actions.ts 文件中,创建一个名为 deleteInvoice 的新操作。

/app/lib/actions.ts
export async function deleteInvoice(id: string) {
  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath('/dashboard/invoices');
}

由于此操作在 /dashboard/invoices 路径中被调用,因此您不需要调用 redirect。调用 revalidatePath 将触发新的服务器请求并重新渲染表格。

进一步阅读

在本节中,您学习了如何使用服务器操作来修改数据。您还学习了如何使用 revalidatePath API 重新验证 Next.js 缓存,以及如何使用 redirect 将用户重定向到新页面。

您还可以阅读更多关于 服务器操作的安全 以进行额外学习。

您已完成章节12

恭喜!您学习了如何使用表单和 React 服务器操作来修改数据。

下一步

13:处理错误

让我们探索使用表单修改数据的最佳实践,包括错误处理和可访问性。