在上一章中,你使用了 URL 搜索参数和 Next.js API 实现了搜索和分页。现在,让我们继续完善发票页面,添加创建、更新和删除发票的功能!
在本章中...
我们将讨论以下内容:
- 什么是 React 服务器操作(Server Actions)以及如何使用它们来修改数据。
- 如何使用表单和服务器组件(Server Components)。
- 使用原生
formData
对象的最佳实践,包括类型验证。 - 如何使用
revalidatePath
API 重新验证客户端缓存。 - 如何创建具有特定 ID 的动态路由段。
什么是服务器操作?
React 服务器操作允许你直接在服务器上运行异步代码。它们消除了创建 API 端点来修改数据的需要。相反,你可以编写在服务器上执行的异步函数,并可以从客户端或服务器组件中调用它们。
安全性是 Web 应用程序的重中之重,因为它们可能会受到各种威胁。这就是服务器操作的作用所在。它们提供了有效的安全解决方案,保护数据免受不同类型的攻击,确保授权访问。服务器操作通过 POST 请求、加密闭包、严格的输入检查、错误消息哈希和主机限制等技术实现了这一点,共同显著提高了应用程序的安全性。
如何使用表单与服务器操作
在 React 中,你可以使用 <form>
元素中的 action
属性来调用操作。该操作将自动接收包含捕获数据的原生 FormData 对象。
例如:
// 服务器组件 export default function Page() { // 操作 async function create(formData: FormData) { 'use server'; // 修改数据的逻辑... } // 使用 "action" 属性调用操作 return ( <form action={create}> {/* 表单内容 */} </form> ); }
在服务器组件中调用服务器操作的一个优点是渐进增强——即使客户端禁用 JavaScript,表单仍然可以工作。
Next.js 与服务器操作
服务器操作还与 Next.js 的 缓存(caching) 深度集成。当通过服务器操作提交表单时,你不仅可以使用操作来修改数据,还可以使用如 revalidatePath
和 revalidateTag
等 API 来重新验证相关的缓存。
现在是时候进行测验了!
使用服务器操作的一个好处是什么?
A. 改善 SEO。
B. 渐进增强。
C. 更快的网站。
D. 数据加密。
让我们看看所有内容如何协同工作!
创建发票
以下是创建新发票的步骤:
- 创建一个表单来捕获用户输入。
- 创建一个服务器操作并从表单中调用它。
- 在服务器操作内部,从
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: '发票', href: '/dashboard/invoices' }, { label: '创建发票', href: '/dashboard/invoices/create', active: true, }, ]} /> <Form customers={customers} /> </main> ); }
你的页面是一个服务器组件,它获取 customers
并将其传递给 <Form>
组件。为了节省时间,我们已经为你创建了 <Form>
组件。
导航到 <Form>
组件,你会看到表单:
- 有一个
<select>
元素(下拉框),列出 customers 。 - 有一个
type="number"
的<input>
元素用于 amount。 - 有两个
type="radio"
的<input>
元素用于状态。 - 有一个
type="submit"
的按钮。
在 http://localhost:3000/dashboard/invoices/create 上,你应该会看到以下 UI:
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}> {/* 表单内容 */} </form> ); }
值得知道:在 HTML 中,你会将 URL 传递给
action
属性。这个 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'), }; // 测试一下 console.log(rawFormData); }
提示:如果你处理的表单字段很多,你可能会考虑使用 entries() 方法结合 JavaScript 的 Object.fromEntries()。 例如
const formDataEntries = Object.fromEntries(formData.entries()); console.log(formDataEntries);
4. 验证和准备数据
在将表单数据发送到数据库之前,您需要确保其格式和类型正确。如课程中早期所述,您的发票表格期望的数据格式如下:
// /app/lib/definitions.ts export type Invoice = { id: string; // 将在数据库中创建 customer_id: string; amount: number; // 存储为分 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 并定义一个与表单对象形状匹配的 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 { 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 具有一个 客户端路由缓存(a Client-side Router Cache),它在用户的浏览器中存储路由片段一段时间。加上 预取(prefetching),此缓存确保用户可以快速在路由之间导航,同时减少对服务器的请求数量。
由于您正在更新发票路由中显示的数据,您需要清除此缓存并触发对服务器的新请求。您可以使用 Next.js 的 revalidatePath 函数来完成此操作:
// /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) { 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'); redirect('/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
路由。 - 您应在表格顶部看到新发票。
更新发票(invoice)
更新发票表单类似于创建发票表单,唯一的区别是您需要传递发票的 id
以更新数据库中的记录。让我们看看如何获取和传递发票 id
。
您需要执行以下步骤来更新发票:
- 创建一个带有发票
id
的动态路由段。 - 从页面参数中读取发票
id
。 - 从数据库中获取特定的发票。
- 用发票数据预填充表单。
- 更新数据库中的发票数据。
1. 创建带有发票 id 的动态路由段
Next.js 允许您创建 动态路由段(Dynamic Route Segments),当您不知道确切的段名并想根据数据创建路由时。这可以是博客文章标题、产品页面等。您可以通过将文件夹名称用方括号括起来来创建动态路由段。例如 [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({ params }: { params: { id: string } }) { const id = params.id; // ... }
注意,它与 /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 { 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(), ]); // ... }
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(), ]); // ... }
你可能会看到一个暂时的 TypeScript 错误,因为 invoice
可能是 undefined
。暂时不用担心这个问题,你将在下一章节中处理它,添加错误处理。
很好!现在测试一下是否一切正常。访问 http://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 // 传递 id 作为参数不适用 <form action={updateInvoice(id)}>
相反,你可以使用 JavaScript 的 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 // 使用 Zod 更新期望的类型 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
传递给服务器操作:
// /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
将用户重定向到新页面。
你还可以阅读更多关于 服务器操作的安全性(security with Server Actions) 以获取更多学习内容。