跳到内容

12

修改数据

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

本章内容...

以下是我们将会涵盖的主题

React Server Actions 是什么以及如何使用它们来修改数据。

如何使用表单和 Server Components。

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

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

如何创建带有特定 ID 的动态路由段。

什么是 Server Actions?

React Server Actions 允许您直接在服务器上运行异步代码。它们消除了创建 API 端点来修改数据的需求。相反,您可以编写在服务器上执行的异步函数,并可以从您的 Client 或 Server Components 中调用它们。

安全性是 Web 应用程序的首要任务,因为它们可能容易受到各种威胁。这就是 Server Actions 的用武之地。它们包括加密闭包、严格的输入检查、错误消息哈希、主机限制等功能——所有这些功能协同工作,以显著增强您的应用程序安全性。

结合表单使用 Server Actions

在 React 中,您可以使用 <form> 元素中的 action 属性来调用 actions。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>;
}

在 Server Component 中调用 Server Action 的一个优势是渐进增强 - 即使客户端尚未加载 JavaScript,表单也能正常工作。例如,在较慢的网络连接下。

Next.js 与 Server Actions

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

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

创建发票

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

  1. 创建一个表单来捕获用户的输入。
  2. 创建一个 Server Action 并从表单中调用它。
  3. 在您的 Server Action 内部,从 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>
  );
}

您的页面是一个 Server Component,它获取 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. 创建 Server Action

很好,现在让我们创建一个 Server Action,它将在表单提交时被调用。

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

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

通过添加 'use server',您将文件中的所有导出函数标记为 Server Actions。这些服务器函数随后可以被导入并在 Client 和 Server components 中使用。此文件中包含的任何未被使用的函数都将自动从最终应用程序 bundle 中移除。

您也可以通过在 action 内部添加 "use server",直接在 Server Components 内部编写 Server Actions。但是对于本课程,我们将把它们都组织在一个单独的文件中。我们建议为您的 actions 创建一个单独的文件。

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

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

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

/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 中,您会将 URL 传递给 action 属性。此 URL 将是您的表单数据应提交的目标位置(通常是 API 端点)。

然而,在 React 中,action 属性被视为一个特殊的 prop - 这意味着 React 在其基础上构建,以允许调用 actions。

在幕后,Server Actions 创建了一个 POST API 端点。这就是为什么在使用 Server Actions 时,您不需要手动创建 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() 一起使用。

为了检查一切是否连接正确,请尝试使用该表单。提交后,您应该在您的 终端(而不是浏览器)中看到您刚刚输入到表单中的数据。

现在您的数据是对象的形式,将更容易使用。

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

类型验证和强制转换

重要的是验证来自表单的数据是否与数据库中期望的类型一致。例如,如果您在您的 action 内部添加 console.log

console.log(typeof rawFormData.amount);

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

为了处理类型验证,您有几种选择。虽然您可以手动验证类型,但使用类型验证库可以节省您的时间和精力。对于您的示例,我们将使用 Zod,一个 TypeScript 优先的验证库,它可以为您简化此任务。

在您的 actions.ts 文件中,导入 Zod 并定义一个与您的表单对象形状匹配的 schema。此 schema 将在将 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 postgres from 'postgres';
 
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
 
// ...
 
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 { revalidatePath } from 'next/cache';
import postgres from 'postgres';
 
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
 
// ...
 
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 { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import postgres from 'postgres';
 
const sql = postgres(process.env.POSTGRES_URL!, { ssl: 'require' });
 
// ...
 
export async function createInvoice(formData: FormData) {
  // ...
 
  revalidatePath('/dashboard/invoices');
  redirect('/dashboard/invoices');
}

恭喜!您刚刚实现了您的第一个 Server Action。通过添加新发票来测试它,如果一切正常

  1. 您应该在提交时被重定向到 /dashboard/invoices 路由。
  2. 您应该在表格顶部看到新的发票。

更新发票

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

以下是您更新发票的步骤

  1. 创建一个新的动态路由段,其中包含发票 id
  2. 从页面 params 中读取发票 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 以接受 id prop。您可以使用模板字面量链接到动态路由段

/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 prop 的临时 TypeScript 错误,因为 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 传递给 Server Action

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

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

相反,您可以使用 JS bindid 传递给 Server Action。这将确保传递给 Server Action 的任何值都被编码。

/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}>{/* ... */}</form>;
}

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

然后,在您的 actions.ts 文件中,创建一个新的 action,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 action 类似,在这里您

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

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

删除发票

要使用 Server Action 删除发票,请将删除按钮包装在 <form> 元素中,并使用 bindid 传递给 Server Action

/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 的新 action。

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

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

延伸阅读

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

您还可以阅读更多关于 Server Actions 的安全性 以进行额外的学习。

您已完成本章12

恭喜!您学习了如何使用表单和 React Server Actions 修改数据。

下一步

13:处理错误

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