在上一章中,您使用 URL Search Params 和 Next.js API 实现了搜索和分页。让我们继续开发发票页面,添加创建、更新和删除发票的功能!
在本章中,你将学习
- React 服务端操作是什么,以及如何使用它们来更改数据
- 如何使用表单和服务器组件
- 使用本地
formData对象的最佳实践,包括类型验证 - 如何使用
revalidatePathAPI 重新验证客户端缓存 - 如何创建具有特定 ID 的动态路由段
什么是服务端操作
React Server Actions 允许您直接在服务端运行异步代码。您无需创建 API 端点来更改数据。相反,您可以编写在服务端执行的异步函数,并从客户端或服务端组件中调用。
安全是网络应用程序的重中之重,因为它们很容易受到各种威胁。这就是服务器运行的重要之处。它们提供了有效的安全解决方案,可抵御各种类型的攻击,保护数据安全,并确保授权访问。Server Actions 通过 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 缓存深度集成。当通过服务端操作提交表单时,您不仅可以使用该操作更改数据,还可以使用 revalidatePath 和 revalidateTag 等 API 重新验证相关缓存。
让我们看看这一切是如何运作的!
创建发票
以下是创建新发票的步骤:
- 创建一个表单来获取用户的输入。
- 创建服务端操作,并从表单中调用。
- 在服务端操作中,从
formData对象中提取数据。 - 验证和准备要插入数据库的数据。
- 插入数据并处理任何错误。
- 重新验证缓存并将用户重定向到发票页面。
1.创建新路线和表格
首先,在 /invoices 文件夹中添加一个名为 /create 的新路由段,并添加一个 page.tsx 文件:
您将使用此路径创建新发票。在您的 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"的按钮。
在 http://localhost:3000/dashboard/invoices/create 上,您应该可以看到以下用户界面:
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属性被认为是一种特殊属性,这意味着 React 将在其基础上进行构建,以便调用操作。- 这背后,服务端动作会创建一个
POSTAPI 地址。这就是为什么在使用服务端行为时不需要手动创建 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_id 、 amount 和 status 。
类型验证和强制转换
重要的是要验证表单中的数据是否与数据库中的预期类型一致。例如,如果您在操作中添加了 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');
}
刚刚你编写了第一个服务端操作。如果一切正常,请添加一张新发票进行测试:
- 提交后,您将被重定向到
/dashboard/invoices路由。 - 您应该能在表格顶部看到新发票。
更新发票
更新发票表单与创建发票表单类似,但您需要传递发票 id 以更新数据库中的记录。让我们看看如何获取和传递发票 id 。
以下是更新发票的步骤:
- 使用发票
id创建一个新的动态路由段。 - 从页面参数中读取发票
id。 - 从数据库中获取特定发票。
- 在表格中预先填入发票数据。
- 更新数据库中的发票数据。
1.使用发票 id 创建动态路由段
Next.js 允许您在不知道确切的分段名称并希望根据数据创建路由时创建动态路由分段。这可以是博文标题、产品页面等。您可以用方括号将文件夹名称包起来,创建动态路由段。例如, [id] 、 [post] 或 [slug] 。
在 /invoices 文件夹中,创建一个名为 [id] 的新动态路由,然后创建一个名为 edit 的新路由和一个 page.tsx 文件。文件结构应如下所示:
在您的 <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 /> 组件,更新 Link 的 href 以接受 id 命令。您可以使用模板文字链接到动态路由段:
/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.阅读指定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 的参数,您可以使用它来访问 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({ params }: { params: { id: string } }) {
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({ params }: { params: { id: string } }) {
const id = params.id;
const [invoice, customers] = await Promise.all([
fetchInvoiceById(id),
fetchCustomers(),
]);
// ...
}
您将在终端中看到 invoice prop 的临时 TS 错误,因为 invoice 有可能是 undefined 。现在不用担心,在下一章添加错误处理时就能解决。
现在,测试一切接线是否正确。访问 ttp://localhost:3000/dashboard/invoices点击铅笔图标编辑发票。导航后,您会看到一个预先填入发票详细信息的表单:
URL 也应更新为 id ,如下所示: http://localhost: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 bind 将 id 传递给服务器操作。这将确保传递给服务器动作的任何值都经过编码。
/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 操作类似,这里也是:
- 从
formData中提取数据。 - 验证类型。
- 将金额转换为分。
- 将变量传递给 SQL 查询。
- 调用
revalidatePath清除客户端缓存并发出新的服务端请求。 - 调用
redirect将用户重定向到发票页面。
编辑一张发票来测试一下。提交表单后,您应该会被重定向到发票页面,发票应该会被更新。
删除发票
要使用服务端操作删除发票,请在 <form> 元素中封装删除按钮,并使用 bind 将 id 传递给服务器操作:
/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 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 将触发新的服务器请求并重新渲染表。