12-提交数据
原文链接:https://nextjs.org/learn/dashboard-app/mutating-data (opens new window)
在上一章中,您使用URL search Params和Next.js API实现了搜索和分页。让我们添加创建、更新和删除Invoices 页面的功能,继续使用Invoices 页面!
# 本章目标
- 什么是React Server Actions,怎么使用React Server Actions进行提交数据
- 如何使用表单和服务器组件。
- 使用原生的 formData 对象的最佳实践,包括类型验证。
- 如何使用revalidatePath API重新验证客户端缓存。
- 如何创建具有特定ID的动态路由。
# 什么是Server Actions
React Server Actions允许您直接在服务器上运行异步代码。该功能取消了通过创建API才能提交数据的流程。现在,您可以编写在服务器上执行的异步函数,这些函数可以从客户端或服务器组件调用。 安全是web应用程序的首要任务,因为它们可能容易受到各种威胁。这就是服务器操作的作用所在。它们提供了一个有效的安全解决方案,可以抵御不同类型的攻击,保护您的数据安全,并确保授权访问。Server Actions 通过POST请求等技术实现这一点,加密闭包、严格的输入检查、错误消息哈希和主机限制等所有方案共同努力,显著提高应用程序的安全性。
# 使用forms的server actions
在react中,你可以使用react中form的action属性进行回调。该操作将自动接收捕获form表格的原始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>;
}
2
3
4
5
6
7
8
9
10
11
12
在服务器组件中调用服务器操作的一个优点是渐进式增强——即使客户端禁用了JavaScript,表单也能工作。
# next.js的server actions
server actions是深度集成到next.js框架中。通过服务器操作提交表单时,您不仅可以使用该操作来变异数据,您还可以使用诸如revalidatePath和revalidateTag之类的API来重新验证关联的缓存。
# 创建Invoices数据
以下是创建新发票的步骤
- 创建一个表单来捕获用户的输入。
- 创建server action 并从表单中调用它。
- 在 server action 中,从formData中提取数据
- 验证并准备要插入数据库的数据。
- 插入数据并处理任何错误。
- 重新验证缓存并将用户重定向回invoice页面。
# 新加一个路由和form
首先,在/invoices文件夹中,使用page.tsx文件添加名为/create的路由:
您将使用此路路由创建新invoie数据。在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>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
您的页面是一个服务器组件,用于获取客户并将其传递给<Form>
组件。为了节省时间,我们已经为您创建了<Form>
组件。
导航到<Form>
组件,您会看到该表单:
- 有一个
<select>
的下拉组件,内容是包含了customers的列表。 - 包含一个类型为type="number"的
input
组件,设置amount数据 - 包含一个类型为type="radio"的input组件,用来设置status数据。
- 有一个类型为 type="submit"的提交按钮
打开http://localhost:3000/dashboard/invoices/create (opens new window),您应该会看到以下UI:
# 创建server action
很好,现在让我们创建一个服务器操作,该操作将在提交表单时调用。 导航到lib目录并创建一个名为actions.ts的新文件。在该文件的顶部,添加React use server (opens new window)指令:
'use server';
通过添加'use server'
,将文件中所有导出的函数标记为“服务器操作”。然后可以导入这些服务器功能,并在客户端和服务器组件中使用这些功能。
您也可以通过在操作中添加'use server'
,直接在服务器组件中编写服务器操作。但对于本课程,我们将把它们全部组织在一个单独的文件中。
在actions.ts文件中,创建一个接受formData的新异步函数:
'use server';
export async function createInvoice(formData: FormData) {}
2
3
然后,在<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}>
// ...
)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 从formData中提取数据
回到 actions.ts 文件中,您需要提取formData的值。有以下几种方法 (opens new window)可以使用,对于这个例子,让我们使用.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);
}
2
3
4
5
6
7
8
9
10
11
提示:如果您使用的表单有很多字段,您可能需要考虑将
entries()
方法与JavaScript的Object.fromEntries()
一起使用。例如const rawFormData = Object.fromEntries(formData.entries());
要检查所有连接是否正确,请继续尝试该表单。提交后,您应该会在终端中看到刚刚输入表单的数据。 现在您的数据是一个对象,它将更容易使用。
# 验证和准备数据
在将表单数据发送到数据库之前,您希望确保它的格式和类型正确。如果您还记得本课程前面的内容,您的invoices
表需要以下格式的数据:
export type Invoice = {
id: string; // Will be created on the database
customer_id: string;
amount: number; // Stored in cents
status: 'pending' | 'paid';
date: string;
};
2
3
4
5
6
7
到目前为止,从form中您只有customer_id
, amount
, and status
数据。
# 类型验证和是否必填
重要的是要验证表单中的数据是否与数据库中的预期类型一致。例如,如果在操作中添加console.log:
console.log(typeof rawFormData.amount);
你将注意到amount
是string类型,而不是number类型。但是input类型为type=“number”的输入元素实际上返回的是字符串,而不是数字!
要处理类型验证,您有几个选项。虽然您可以手动验证类型,但使用类型验证库可以节省时间和精力。对于您的示例,我们将使用Zod (opens new window),这是一个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) {
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
zod插件将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'),
});
}
2
3
4
5
6
7
8
# 以分为单位处理值
通常,在数据库中存储以分为单位的货币值是一种很好的做法,以消除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;
}
2
3
4
5
6
7
8
9
# 创建日期数据
让我们为invoice
的创建日期创建一个格式为“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];
}
2
3
4
5
6
7
8
9
10
# 将数据插入数据库
现在您已经拥有了数据库所需的所有值,可以创建一个SQL查询,将新发票插入数据库并传入变量:
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})
`;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
现在,我们没有处理任何错误。我们将在下一章中做这件事。
# 重新验证和重定向
Next.js有一个客户端路由器缓存,它将路由存储在用户的浏览器中一段时间。与预获取 (opens new window)一起,该缓存确保用户可以在路由之间快速导航,同时减少对服务器的请求数量。
由于您正在更新invoices
路由中显示的数据,因此需要清除此缓存并触发对服务器的新请求。您可以使用Next.js中的revalidatePath (opens new window)函数来执行此操作:
'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');
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
一旦数据库已经被更新,将重新验证/dashboard/invoits路径,并从服务器获取新数据。此时,您还需要将用户重定向回/dashboard/invoices
页面。您可以使用Next.js中的重定向函数来完成此操作:
'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');
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
祝贺您刚刚实现了第一个服务器操作。如果一切正常,请添加新 invoices 数据进行测试:
- 提交时应该重定向到
/dashboard/invoices
路由下 - 可以在表格的顶部看到新提交的数据
# 编辑更新Invoices数据
更新invoices表单类似于创建发票表单,更新需要传递发票id来更新数据库中的记录。让我们看看如何获取和传递invoices id。 您将采取以下步骤更新invoices
- 使用 invoices id创建一个新的动态路由。
- 从页面参数中读取 invoices id。
- 从数据库中获取特定 invoices 数据。
- 使用 invoices 数据预先填充表单。
- 更新数据库中的 invoices 数据。
# 使用 invoices id创建一个新的动态路由
Next.js允许您在不知道确切的路由名称并希望根据props创建路由时创建动态路由 (opens new window)。这可能是博客文章标题、产品页面等。可以通过将文件夹的名称括在方括号[]
中来创建动态路由。例如[id], [post] or [slug]
在你的/invoices
文件夹中,创建一个名为[id]
的新动态路由,然后使用page.tsx文件创建一个名为edit的新路由。您的文件结构应该如下所示:
在<Table>
组件中,注意到<UpdateInvoice />
组件它从表记录中接收 invoices
的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>
// ...
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
导航到<UpdateInvoice/>
组件,并更新链接的href以接受id prop。可以使用模板文字链接到动态路由:
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
3
4
5
6
7
8
9
10
11
12
13
14
15
# 从页面参数获取 invoice 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>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
现在你应该可以看到和/create
页面有相似之处,除了导入了一个不同的表单(从edit-form.tsx文件);此表单预先填充表单的defaultValue 默认值,customer's name, invoice amount, and status;要预填充表单字段,您需要使用id获取特定invoice数据。
除了searchParams,页面组件还接受一个名为params的props,您可以使用它来访问id。更新您的<page>
组件以接收props:
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;
// ...
}
2
3
4
5
6
7
8
# 获取特定的invoice
- 导入一个名为
fetchInvoiceById
的新函数,并将该id作为参数传递。 - 导入
fetchCustomers
可获取下拉列表中的客户名称。
您可以使用Promise.all并行获取invoice和customers数据
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(),
]);
// ...
}
2
3
4
5
6
7
8
9
10
11
12
您将在终端中看到invoice prop 的临时TS错误,因为invoice可能undefined。现在不要担心,您将在下一章添加错误处理时解决它。 太棒了现在,测试所有东西是否正确连接。查看http://localhost:3000/dashboard/invoices (opens new window)然后单击铅笔图标编辑invoice。导航后,您应该会看到一个预先填充了invoice详细信息的表单:
URL还应更新为如下id:: [http://localhost:3000/dashboard/invoice/uuid/edit](http://localhost:3000/dashboard/invoice/uuid/edit)
# 将id传递给服务器操作
最后,您希望将id传递给服务器操作,以便更新数据库中的正确记录。您不能像这样将id作为参数传递:
// Passing an id as argument won't work
<form action={updateInvoice(id)}>
2
相反,您可以使用JS绑定将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}>
<input type="hidden" name="id" value={invoice.id} />
</form>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
注意:在表单中使用隐藏的输入字段也可以(例如
<input type="hidden" name="id" value={invoice.id}/>
)。但是,这些值将在HTML源中显示为全文,这对于ID等敏感数据来说并不理想。
然后,在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');
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
对比createInvoice操作,更新有如下操作
- 从formData中提取数据。
- 使用Zod验证类型。
- 将amount转为分
- 将变量传递给SQL查询
- 调用revalidatePath以清除客户端缓存并发出新的服务器请求。
- 调用redirect将用户重定向到invoice的页面。
通过编辑invoice进行测试。提交表单后,应将您重定向到invoice页面,并更新invoice。
# 删除Invoices数据
要使用服务器操作删除invoice,请将删除按钮包装在<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>
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在actions.ts文件中,创建一个名为deleteInvoice的新操作。
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`;
revalidatePath('/dashboard/invoices');
}
2
3
4
由于此操作是在/dashboard/invoices
路径中调用的,因此不需要调用redirect。调用revalidatePath将触发一个新的服务器请求并重新呈现表。
您还可以阅读有关服务器操作安全性 (opens new window)的更多信息,以获得更多学习