数据变更
在上一章中,您使用 URL 搜索参数和 Next.js API 实现了搜索和分页功能。现在让我们继续完善发票页面,添加创建、更新和删除发票的功能!
什么是服务端操作 (Server Actions)?
React 服务端操作允许您直接在服务器上运行异步代码。它们消除了创建 API 端点来变更数据的需要。相反,您可以编写在服务器上执行的异步函数,这些函数可以从客户端或服务端组件调用。
安全性是 Web 应用程序的首要任务,因为它们容易受到各种威胁。这正是服务端操作的用武之地。它们包含加密闭包、严格的输入检查、错误消息哈希、主机限制等功能——所有这些共同作用,显著提升您的应用程序安全性。
在表单中使用服务端操作
在 React 中,您可以使用 <form>
元素中的 action
属性来调用操作。该操作会自动接收原生的 FormData 对象,其中包含捕获的数据。
例如:
在服务端组件中调用服务端操作的一个优势是渐进增强——即使客户端尚未加载 JavaScript,表单也能正常工作。例如,在较慢的网络连接下。
Next.js 与服务端操作
服务端操作还与 Next.js 缓存深度集成。当通过服务端操作提交表单时,您不仅可以使用该操作来变更数据,还可以使用 revalidatePath
和 revalidateTag
等 API 重新验证相关缓存。
让我们看看它们如何协同工作!
创建发票
以下是创建新发票的步骤:
- 创建一个表单来捕获用户输入。
- 创建一个服务端操作并从表单中调用它。
- 在服务端操作中,从
formData
对象提取数据。 - 验证并准备要插入数据库的数据。
- 插入数据并处理任何错误。
- 重新验证缓存并将用户重定向回发票页面。
1. 创建新路由和表单
首先,在 /invoices
文件夹中,添加一个名为 /create
的新路由段,并在其中创建一个 page.tsx
文件:

您将使用此路由来创建新发票。在 page.tsx
文件中粘贴以下代码,然后花些时间研究它:
您的页面是一个服务端组件,它获取 customers
并将其传递给 <Form>
组件。为了节省时间,我们已经为您创建了 <Form>
组件。
导航到 <Form>
组件,您会看到该表单:
- 有一个包含客户列表的
<select>
(下拉)元素。 - 有一个用于金额的
<input>
元素,类型为type="number"
。 - 有两个用于状态的
<input>
元素,类型为type="radio"
。 - 有一个类型为
type="submit"
的按钮。
在 http://localhost:3000/dashboard/invoices/create 上,您应该看到以下 UI:

2. 创建服务端操作
很好,现在让我们创建一个服务端操作,当表单提交时将调用它。
导航到您的 lib/
目录并创建一个名为 actions.ts
的新文件。在此文件的顶部,添加 React 的 use server
指令:
通过添加 'use server'
,您将文件中的所有导出函数标记为服务端操作。然后可以在客户端和服务端组件中导入和使用这些服务端函数。此文件中未使用的任何函数将自动从最终应用程序包中删除。
您还可以通过将 "use server"
添加到操作内部来直接在服务端组件中编写服务端操作。但对于本课程,我们将它们全部组织在一个单独的文件中。我们建议为您的操作使用单独的文件。
在 actions.ts
文件中,创建一个新的异步函数,该函数接受 formData
:
然后,在您的 <Form>
组件中,从 actions.ts
文件导入 createInvoice
。向 <form>
元素添加 action
属性,并调用 createInvoice
操作。
须知:在 HTML 中,您会将 URL 传递给
action
属性。此 URL 是表单数据应提交到的目标(通常是 API 端点)。然而,在 React 中,
action
属性被视为一个特殊属性——这意味着 React 在其基础上构建,允许调用操作。在幕后,服务端操作会创建一个
POST
API 端点。这就是为什么在使用服务端操作时不需要手动创建 API 端点。
3. 从 formData
提取数据
回到您的 actions.ts
文件,您需要提取 formData
的值,可以使用几种方法。对于此示例,让我们使用 .get(name)
方法。
提示:如果您正在处理具有许多字段的表单,您可能需要考虑使用
entries()
方法与 JavaScript 的Object.fromEntries()
。
要检查一切是否正确连接,请尝试使用表单。提交后,您应该会在终端(而不是浏览器)中看到您刚刚输入到表单中的数据。
现在您的数据以对象的形式存在,处理起来会容易得多。
4. 验证和准备数据
在将表单数据发送到数据库之前,您需要确保其格式正确且类型正确。如果您记得课程前面的内容,您的发票表期望以下格式的数据:
到目前为止,您只有来自表单的 customer_id
、amount
和 status
。
类型验证和强制转换
验证表单数据与数据库中预期的类型是否一致非常重要。例如,如果您在操作中添加 console.log
:
您会注意到 amount
的类型是 string
而不是 number
。这是因为 type="number"
的 input
元素实际上返回的是字符串,而不是数字!
要处理类型验证,您有几个选择。虽然您可以手动验证类型,但使用类型验证库可以节省时间和精力。对于您的示例,我们将使用 Zod,这是一个 TypeScript 优先的验证库,可以简化此任务。
在您的 actions.ts
文件中,导入 Zod 并定义一个与表单对象形状匹配的模式。此模式将在将 formData
保存到数据库之前对其进行验证。
amount
字段专门设置为强制(更改)从字符串到数字,同时验证其类型。
然后,您可以将 rawFormData
传递给 CreateInvoice
以验证类型:
以分为单位存储值
通常,良好的做法是在数据库中以分为单位存储货币值,以消除 JavaScript 浮点错误并确保更高的准确性。
让我们将金额转换为分:
创建新日期
最后,让我们为发票的创建日期创建一个格式为 "YYYY-MM-DD" 的新日期:
5. 将数据插入数据库
现在您拥有了数据库所需的所有值,可以创建一个 SQL 查询来将新发票插入数据库并传递变量:
目前,我们没有处理任何错误。我们将在下一章讨论这个问题。现在,让我们继续下一步。
6. 重新验证与重定向
Next.js 有一个客户端路由缓存 (client-side router cache),它会将路由片段临时存储在用户的浏览器中。结合预取 (prefetching) 功能,这个缓存机制能确保用户在路由间快速导航,同时减少向服务器发出的请求数量。
由于您正在更新发票路由中显示的数据,您需要清除这个缓存并触发向服务器的新请求。可以通过 Next.js 的 revalidatePath
函数实现:
数据库更新后,/dashboard/invoices
路径将被重新验证,并从服务器获取最新数据。
此时,您还需要将用户重定向回 /dashboard/invoices
页面。可以使用 Next.js 的 redirect
函数实现:
恭喜!您已经实现了第一个服务器操作 (Server Action)。通过添加新发票来测试它,如果一切正常:
- 提交后应重定向到
/dashboard/invoices
路由 - 应在表格顶部看到新发票
更新发票
更新发票表单与创建发票表单类似,不同之处在于您需要传递发票 id
来更新数据库中的记录。让我们看看如何获取和传递发票 id
。
以下是更新发票的步骤:
- 使用发票
id
创建新的动态路由段 (dynamic route segment) - 从页面参数 (page params) 中读取发票
id
- 从数据库中获取特定发票
- 用发票数据预填充表单
- 在数据库中更新发票数据
1. 使用发票 id
创建动态路由段
当您不知道确切的段名称并希望基于数据创建路由时,Next.js 允许您创建动态路由段 (Dynamic Route Segments)。这适用于博客文章标题、产品页面等场景。您可以通过将文件夹名称用方括号包裹来创建动态路由段,例如 [id]
、[post]
或 [slug]
。
在您的 /invoices
文件夹中,创建一个名为 [id]
的新动态路由,然后在其中创建一个名为 edit
的路由并添加 page.tsx
文件。文件结构应如下所示:
![包含嵌套 [id] 文件夹和其中 edit 文件夹的 Invoices 文件夹](https://h8DxKfmAPhn8O0p3.public.blob.vercel-storage.com/learn/light/edit-invoice-route.png)
在您的 <Table>
组件中,注意有一个 <UpdateInvoice />
按钮,它从表格记录中接收发票的 id
。
导航到您的 <UpdateInvoice />
组件,并更新 Link
的 href
以接受 id
属性。您可以使用模板字面量链接到动态路由段:
2. 从页面参数中读取发票 id
回到您的 <Page>
组件,粘贴以下代码:
注意它与您的 /create
发票页面类似,但它导入的是不同的表单(来自 edit-form.tsx
文件)。这个表单应该用客户名称、发票金额和状态的 defaultValue
预填充。为了预填充表单字段,您需要使用 id
获取特定的发票。
除了 searchParams
外,页面组件还接受一个名为 params
的属性,您可以用它来访问 id
。更新您的 <Page>
组件以接收这个属性:
3. 获取特定发票
然后:
- 导入一个名为
fetchInvoiceById
的新函数,并将id
作为参数传递 - 导入
fetchCustomers
以获取下拉菜单的客户名称
您可以使用 Promise.all
并行获取发票和客户数据:
您会在终端中看到关于 invoice
属性的临时 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
作为参数传递:
相反,您可以使用 JavaScript 的 bind
将 id
传递给服务器操作。这将确保传递给服务器操作的所有值都被编码。
注意: 在表单中使用隐藏的输入字段也可以(例如
<input type="hidden" name="id" value={invoice.id} />
)。但是,这些值会以纯文本形式出现在 HTML 源代码中,对于敏感数据来说并不理想。
然后,在您的 actions.ts
文件中,创建一个新的操作 updateInvoice
:
与 createInvoice
操作类似,这里您:
- 从
formData
中提取数据 - 使用 Zod 验证类型
- 将金额转换为分
- 将变量传递给 SQL 查询
- 调用
revalidatePath
清除客户端缓存并发出新的服务器请求 - 调用
redirect
将用户重定向到发票页面
通过编辑发票来测试它。提交表单后,您应该被重定向到发票页面,并且发票应该已更新。
删除发票
要使用服务器操作删除发票,请将删除按钮包裹在 <form>
元素中,并使用 bind
将 id
传递给服务器操作:
在您的 actions.ts
文件中,创建一个名为 deleteInvoice
的新操作。
由于此操作是在 /dashboard/invoices
路径上调用的,您不需要调用 redirect
。调用 revalidatePath
将触发新的服务器请求并重新渲染表格。
延伸阅读
在本章中,您学习了如何使用服务器操作来变更数据。您还学习了如何使用 revalidatePath
API 重新验证 Next.js 缓存,以及使用 redirect
将用户重定向到新页面。
您还可以阅读更多关于服务器操作的安全性以进行进一步学习。