12
章节12
变异数据
在上一章中,你使用 URL 搜索参数和 Next.js API 实现了搜索和分页。让我们继续在发票页面上工作,添加创建、更新和删除发票的功能!
在本章中...
我们将涵盖以下主题:
什么是 React 服务器动作以及如何使用它们来更改数据。
如何使用表单和服务器组件。
使用原生 FormData 对象的最佳实践,包括类型验证。
如何使用 revalidatePath API 重新验证客户端缓存。
如何使用特定 ID 创建动态路由段。
什么是服务器动作?
React 服务器动作允许您直接在服务器上运行异步代码。它们消除了创建 API 端点来更改数据的需要。相反,您编写在服务器上执行的异步函数,可以从客户端或服务器组件中调用。
安全是 Web 应用程序的首要任务,因为它们可能容易受到各种威胁。这就是服务器动作发挥作用的地方。它们包括加密闭包、严格的输入检查、错误消息哈希、主机限制等功能——所有这些协同工作,显著增强您的应用程序安全性。
将表单与服务器动作结合使用
在 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 缓存深度集成。当通过服务器动作提交表单时,您不仅可以使用该动作来更改数据,还可以使用 revalidatePath 和 revalidateTag 等 API 重新验证相关缓存。
让我们看看这一切是如何协同工作的!
创建发票
以下是您创建新发票的步骤
- 创建一个表单以捕获用户输入。
- 创建服务器动作并从表单中调用它。
- 在您的服务器动作中,从
formData对象中提取数据。 - 验证并准备数据以插入到您的数据库中。
- 插入数据并处理任何错误。
- 重新验证缓存并将用户重定向回发票页面。
1. 创建新路由和表单
首先,在 /invoices 文件夹内,添加一个名为 /create 的新路由段,其中包含一个 page.tsx 文件。

您将使用此路由来创建新发票。在您的 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://:3000/dashboard/invoices/create,您应该会看到以下 UI:

2. 创建服务器动作
很好,现在让我们创建一个在表单提交时将被调用的服务器动作。
导航到您的 lib/ 目录并创建一个名为 actions.ts 的新文件。在该文件顶部,添加 React use server 指令。
'use server';通过添加 'use server',您将文件中的所有导出函数标记为服务器动作。然后,这些服务器函数可以被导入并在客户端和服务器组件中使用。此文件中包含的任何未使用的函数都将自动从最终应用程序包中删除。
您也可以通过在动作内部添加 "use server" 直接在服务器组件中编写服务器动作。但对于本课程,我们将它们都组织在一个单独的文件中。我们建议为您的动作设置一个单独的文件。
在您的 actions.ts 文件中,创建一个接受 formData 的新的异步函数
'use server';
export async function createInvoice(formData: FormData) {}然后,在您的 <Form> 组件中,从 actions.ts 文件导入 createInvoice。向 <form> 元素添加一个 action 属性,并调用 createInvoice 动作。
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属性被认为是一个特殊属性——这意味着 React 在其基础上构建,允许调用动作。在后台,服务器动作会创建一个
POSTAPI 端点。这就是在使用服务器动作时您无需手动创建 API 端点的原因。
3. 从 formData 中提取数据
回到您的 actions.ts 文件中,您需要提取 formData 的值,有几种方法可以使用。对于此示例,让我们使用 .get(name) 方法。
'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. 验证并准备数据
在将表单数据发送到数据库之前,您需要确保它采用正确的格式并具有正确的类型。如果您还记得本课程前面部分的内容,您的发票表期望的数据格式如下:
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_id、amount 和 status。
类型验证和强制转换
重要的是要验证表单中的数据与数据库中预期的类型是否一致。例如,如果您在动作中添加一个 console.log
console.log(typeof rawFormData.amount);您会注意到 amount 的类型是 string 而不是 number。这是因为带有 type="number" 的 input 元素实际上返回的是字符串,而不是数字!
要处理类型验证,您有几个选择。虽然您可以手动验证类型,但使用类型验证库可以节省您的时间和精力。对于您的示例,我们将使用 Zod,这是一个 TypeScript 优先的验证库,可以为您简化此任务。
在您的 actions.ts 文件中,导入 Zod 并定义一个与您的表单对象形状匹配的模式。此模式将在将 formData 保存到数据库之前对其进行验证。
'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 以验证类型。
// ...
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 浮点错误并确保更高的准确性。
让我们将金额转换为分
// ...
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”的新日期。
// ...
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 查询来将新发票插入到您的数据库中并传入变量。
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 函数来完成此操作。
'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 函数来完成此操作。
'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');
}恭喜!您刚刚实现了您的第一个服务器动作。通过添加新发票来测试它,如果一切正常的话
- 提交后,您应该被重定向到
/dashboard/invoices路由。 - 您应该在表格顶部看到新发票。
更新发票
更新发票表单与创建发票表单类似,只是您需要传递发票 id 来更新数据库中的记录。让我们看看如何获取并传递发票 id。
以下是您更新发票的步骤
- 创建一个带有发票
id的新动态路由段。 - 从页面参数中读取发票
id。 - 从数据库中获取特定发票。
- 使用发票数据预填充表单。
- 更新数据库中的发票数据。
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](/_next/image?url=https%3A%2F%2Fh8DxKfmAPhn8O0p3.public.blob.vercel-storage.com%2Flearn%2Flight%2Fedit-invoice-route.png&w=3840&q=75)
在您的 <Table> 组件中,请注意有一个 <UpdateInvoice /> 按钮,它从表格记录中接收发票的 id。
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 /> 组件,并更新 Link 的 href 以接受 id 属性。您可以使用模板字面量链接到动态路由段。
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> 组件,粘贴以下代码:
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。
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 并行获取发票和客户。
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(),
]);
// ...
}您将在终端中看到一个临时的 TypeScript 错误,因为 invoice 属性可能为 undefined。暂时不用担心,您将在下一章添加错误处理时解决它。
太棒了!现在,测试一切是否连接正确。访问 https://:3000/dashboard/invoices,然后单击铅笔图标编辑发票。导航后,您应该会看到一个预先填充了发票详细信息的表单。

URL 也应该更新为包含 id,如下所示:https://:3000/dashboard/invoice/uuid/edit
UUID 与自增键
我们使用 UUID 而不是自增键(例如 1、2、3 等)。这使得 URL 更长;但是,UUID 消除了 ID 冲突的风险,是全局唯一的,并降低了枚举攻击的风险——使其成为大型数据库的理想选择。
但是,如果您更喜欢简洁的 URL,则可能更喜欢使用自增键。
4. 将 id 传递给服务器动作
最后,您想将 id 传递给服务器动作,以便您可以更新数据库中正确的记录。您不能像这样将 id 作为参数传递。
// Passing an id as argument won't work
<form action={updateInvoice(id)}>相反,您可以使用 JS bind 将 id 传递给服务器动作。这将确保传递给服务器动作的任何值都经过编码。
// ...
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 文件中,创建一个名为 updateInvoice 的新动作。
// 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 动作类似,这里您正在进行
- 从
formData中提取数据。 - 使用 Zod 验证类型。
- 将金额转换为美分。
- 将变量传递给您的 SQL 查询。
- 调用
revalidatePath以清除客户端缓存并发出新的服务器请求。 - 调用
redirect以将用户重定向到发票页面。
通过编辑发票进行测试。提交表单后,您应该会重定向到发票页面,并且发票应该已更新。
删除发票
要使用服务器动作删除发票,请将删除按钮包装在 <form> 元素中,并使用 bind 将 id 传递给服务器动作。
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 的新动作。
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 将用户重定向到新页面。
您还可以阅读更多关于服务器动作的安全性以获取更多学习内容。
这有帮助吗?


![Invoices folder with a nested [id] folder, and an edit folder inside it](/_next/image?url=https%3A%2F%2Fh8DxKfmAPhn8O0p3.public.blob.vercel-storage.com%2Flearn%2Fdark%2Fedit-invoice-route.png&w=3840&q=75)
