
Server Actions
Server Actions 是 Next.js 中的一个特殊概念,用于在服务端直接进行数据库查询等操作,而不是在客户端进行异步请求。本文将介绍 Server Actions 的基本概念和使用场景。
又是被 Next.js 新概念洗礼的一天。
我们知道,Next.js 最核心的特性便是 支持静态生成(SSG)和服务端渲染(SSG),这也就意味着我们可以以部署 Node 服务的方式,将其部署在服务器上,用请求后端接口类似的形式来请求页面文件。换句话说,我们其实可以直接把 Next.js 看成一个特殊的 Node 后端服务。
既然是在服务端进行运行,那么它在数据库的查询方式上自然和一般的 SPA 客户端进行查询有所区别。
我们先简单分析一下一般 React SPA 项目的前后端交互:
- 前后端分离开发,后端无论用什么语言编写,最终只需要提供一个 API Endpoint(URL) 给前端。
- 前端二次封装 axios,或者直接使用 fetch,自己手动封装对应 URL 的异步请求函数,根据 API Endpoint 的不同依次进行封装,并定义需要传入的参数、方法等。
- 前端在需要调用接口的位置调用这些封装好的接口函数获取数据,展示页面。
这样是一套完整的前后端分离接口交互的编写。很显然,这样的交互、从数据库中拿数据的行为是以页面为单位的,当用户重新在这个页面上发生相同的交互行为时,对应的 HTTP 请求会 100%完整地复刻一遍。
而在 Next.js 中,基于其自身的特殊性,提供了一个与数据库直接进行交互的特殊方式:Server Actions。
Server Actions 是?
Server Actions 直译为 服务端行为,是 Next.js 中的一个特殊概念,用于在服务端直接进行数据库查询等操作,而不是在客户端进行异步请求。
下面贴一下官方的一些解释:
因此简单来说,所谓的 Server Actions 实际上就是一个在 Next.js 中的一个 普通异步函数,只不过这个异步函数可以直接操作数据库而已。这个函数与 React Server Components 深度集成,可以看成是组件本身的一部分。
这个异步函数的执行单纯在服务端进行,而 不以接口请求的形式获取数据,因此 无法在网络调试中看到接口的请求,可以理解为这个异步函数是在服务端直接执行的。
也正是因此,不将请求的数据暴露出来,能够最大程度上保证数据的安全性。
Server Actions 还与 Next.js 缓存深度集成。通过其提交表单时,不仅可以使用该动作更改数据,还可以使用 revalidatePath
和 revalidateTag
等 Next API 刷新相关页面的缓存,以确保在每次数据更新之后重新访问该页面时能够获取最新的数据。
与传统 API 的比较
我们可以试着总结一下 Server Action 和传统 API Endpoints 的优缺点:
Server Actions
优点:
- 简化代码结构
- 将处理逻辑和组件逻辑放在一起,减少代码分散,提高可读性。例如一般处理点击按钮触发请求,我们可以直接往
onClick
中传递 Server Action 异步函数即可,无需调用 API Endpoint。
- 将处理逻辑和组件逻辑放在一起,减少代码分散,提高可读性。例如一般处理点击按钮触发请求,我们可以直接往
- 直接与组件交互
- Server Actions 直接在组件中调用,减少了不必要的中间层,简化了数据流。
- 减少网络请求
- 通过 Server Actions 直接在服务器上处理数据,不需要通过 HTTP 请求来获取数据,减少了网络延迟。
- 自动处理错误
- Server Actions 可以自动处理常见的错误,如网络错误或数据库连接错误,使得代码更加健壮。
缺点:
- 难以复用
- 由于 Server Actions 紧密耦合在组件中,复用性较差,不如 API Endpoints 容易在多个组件或项目中共享。
- 测试难度大
- 测试 Server Actions 可能比测试 API Endpoints 更加复杂,因为它们嵌入在组件中,需要模拟更多的上下文。不过 Next 官方提供的 console 工具可以直接在 Chrome 的 Dev tool 里面进行输出。
- 可扩展性
- 当项目变大时,将所有逻辑放在组件中可能会导致代码难以维护和扩展,需要提前组织好代码的逻辑。
传统 API Endpoints
优点:
- 松散耦合
- API Endpoints 将业务逻辑与前端组件分离,提高了代码的模块化和复用性。
- 易于测试
- API Endpoints 独立于组件,便于进行单元测试和集成测试。可以与各类 API 测试工具相集成,适合前后端分离、大型团队的合作开发。
- 灵活性
- 可以使用各种中间件和框架(如 Express, Koa 等)来扩展和处理复杂的逻辑。
- 可扩展性
- 更容易扩展和维护,适合大型应用程序的开发。
缺点:
- 额外的网络开销
- 每次数据请求都需要通过 HTTP 请求,增加了网络延迟,尤其在高频请求的情况下影响性能。需要使用防抖、节流等方式处理请求。
- 复杂性
- 需要额外的配置和设置来处理 API 请求和响应,增加了项目的复杂性。
- 代码分散
- 数据处理逻辑与组件逻辑分开,可能导致代码分散,降低可读性。
适用场景
- Server Actions
- 适合小型项目或简单的数据交互场景。
- 当需要快速开发、减少代码复杂性时,Server Actions 是一个不错的选择。
- 适用于处理简单逻辑和不频繁的数据操作。
- API Endpoints
- 适合中大型项目或复杂的数据交互场景。
- 需要高复用性、易测试和可扩展性时,选择 API Endpoints 更为合适。
- 适用于需要处理复杂业务逻辑、多端共享 API 的场景。
Server Action 实例
上面我们简单讨论了 Server Actions 的定义以及其与一般 API Endpoint 的区别,接下来我们看一个实际使用 Server Action 与数据库进行交互的例子。
此处的例子来源于Next 官方教程,需要的同学可以去官网进行查阅。此处需要实现的目标是对数据库 Invoices(发票)数据的 增、改、删 ,也就是直接修改数据库数据。
我们可以看一下具体的代码,并对它进行解析:
'use server' // Server Actions 只在服务端运行
import { sql } from '@vercel/postgres'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
// zod 是一个用于验证数据的库,它可以帮助我们定义数据的结构,并验证数据是否符合这个结构。
const FormSchema = z.object({
id: z.string(),
customerId: z.string(),
amount: z.coerce.number(), // coerce.number() 会将字符串转换为数字
status: z.enum(['pending', 'paid']),
date: z.string(),
})
const CreateInvoice = FormSchema.omit({ id: true, date: true })
export async function createInvoice(formData: FormData) {
// CreateInvoice.parse 方法用于验证 formData 中的数据是否符合 CreateInvoice 的结构
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] // 获取当前日期:YYYY-MM-DD
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`
revalidatePath('/dashboard/invoices') // revalidatePath 的作用是重新生成指定路由的页面,以便在下次访问时显示最新数据
redirect('/dashboard/invoices') // 重定向
}
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') // 重定向
}
export async function deleteInvoice(id: string) {
await sql`DELETE FROM invoices WHERE id = ${id}`
revalidatePath('/dashboard/invoices')
}
这段代码是一个完整的 actions.ts
文件。对相同对象的数据库操作可以用单独的文件进行存放。
首先我们看一下导入。这里使用了 zod 库用于 TS 数据验证,@vercel/postgres
用于数据库操作,next/cache
和 next/navigation
用于缓存和导航控制。定义了一个 FormSchema
来验证发票表单数据的结构。
const CreateInvoice = FormSchema.omit({ id: true, date: true })
CreateInvoice
是一个从 FormSchema
中去除 id
和 date
字段的验证器,用于创建发票时的数据验证。
export async function createInvoice(formData: FormData) {
// CreateInvoice.parse 方法用于验证 formData 中的数据是否符合 CreateInvoice 的结构
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] // 获取当前日期:YYYY-MM-DD
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`
revalidatePath('/dashboard/invoices') // 重新生成指定路由的页面
redirect('/dashboard/invoices') // 重定向
}
createInvoice
函数处理发票创建:
- 从
formData
获取并验证数据。 - 将金额转换为分(cents)。
- 获取当前日期。
- 使用 SQL 插入语句将数据插入数据库。
- 使用
revalidatePath
重新生成/dashboard/invoices
路由的页面,以便显示最新数据。 - 使用
redirect
重定向到/dashboard/invoices
。
其余两函数对 Invoice 的操作大同小异,三个函数分别实现了增、改、删的操作。
那么,对应具体该怎么在组件中使用 Server Actions 呢?
我们对应的来看一下提交表单的内容:
import { createInvoice } from '@/app/lib/actions'
import { CustomerField } from '@/app/lib/definitions'
import { Button } from '@/app/ui/button'
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline'
import Link from 'next/link'
export default function Form({ customers }: { customers: CustomerField[] }) {
return (
<form action={createInvoice}>
{/* ~此处省略表单的具体内容~ */}
<div className="mt-6 flex justify-end gap-4">
<Link
href="/dashboard/invoices"
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
>
Cancel
</Link>
{/* 在 form 标签内部,使用 type="submit" 的 button 标签会触发表单的提交事件。 */}
<Button type="submit">Create Invoice</Button>
</div>
</form>
)
}
此处的 createInvoice
函数就是上述分析过的函数。可以看到,此处的创建表单单纯使用 <form>
元素配合 <button type="submit">
进行表单提交,通过 action 属性将表单数据传给对应的位置。
但是我们知道,form 的 action 属性一般只能接受一个 url 字符串表示传给后端的地址,后端通过这个地址来接收表单数据进行相应的处理,而此处却传递了一个函数。
在 Next.js 中,对 <form>
的 action 属性进行了特殊处理,使其可以接受一个字符串或一个函数。如果使用纯 React,action 一般只接受一个字符串,也就是提交表单的 URL。
在 Next.js 中,如果 action 被传递为一个函数,那么这个函数可以自动接收 FormData
类型的对象并被调用,之后就自动的走 zod 验证流程、提出数据,使用 SQL 插入到数据库中。
最终实现的请求效果如下图所示:
可见,具体的请求、返回数据都和原本的 API 调用方法有所区别。
对应的,我们来看看更新 Invoice 的调用代码:
'use client'
import { updateInvoice } from '@/app/lib/actions'
import { CustomerField, InvoiceForm } from '@/app/lib/definitions'
import { Button } from '@/app/ui/button'
import {
CheckIcon,
ClockIcon,
CurrencyDollarIcon,
UserCircleIcon,
} from '@heroicons/react/24/outline'
import Link from 'next/link'
export default function EditInvoiceForm({
invoice,
customers,
}: {
invoice: InvoiceForm
customers: CustomerField[]
}) {
const updateInvoiceWithId = updateInvoice.bind(null, invoice.id)
return (
<form action={updateInvoiceWithId}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* Customer Name */}
<div className="mb-4">
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
Choose customer
</label>
<div className="relative">
<select
id="customer"
name="customerId"
className="peer block w-full cursor-pointer rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
defaultValue={invoice.customer_id}
>
<option value="" disabled>
Select a customer
</option>
{customers.map(customer => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
<UserCircleIcon className="pointer-events-none absolute left-3 top-1/2 size-[18px] -translate-y-1/2 text-gray-500" />
</div>
</div>
{/* Invoice Amount */}
<div className="mb-4">
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
Choose an amount
</label>
<div className="relative mt-2 rounded-md">
<div className="relative">
<input
id="amount"
name="amount"
type="number"
step="0.01"
defaultValue={invoice.amount}
placeholder="Enter USD amount"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm outline-2 placeholder:text-gray-500"
/>
<CurrencyDollarIcon className="pointer-events-none absolute left-3 top-1/2 size-[18px] -translate-y-1/2 text-gray-500 peer-focus:text-gray-900" />
</div>
</div>
</div>
{/* Invoice Status */}
<fieldset>
<legend className="mb-2 block text-sm font-medium">
Set the invoice status
</legend>
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
<div className="flex gap-4">
<div className="flex items-center">
<input
id="pending"
name="status"
type="radio"
value="pending"
defaultChecked={invoice.status === 'pending'}
className="size-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
/>
<label
htmlFor="pending"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
>
Pending
{' '}
<ClockIcon className="size-4" />
</label>
</div>
<div className="flex items-center">
<input
id="paid"
name="status"
type="radio"
value="paid"
defaultChecked={invoice.status === 'paid'}
className="size-4 cursor-pointer border-gray-300 bg-gray-100 text-gray-600 focus:ring-2"
/>
<label
htmlFor="paid"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
>
Paid
{' '}
<CheckIcon className="size-4" />
</label>
</div>
</div>
</div>
</fieldset>
</div>
<div className="mt-6 flex justify-end gap-4">
<Link
href="/dashboard/invoices"
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
>
Cancel
</Link>
<Button type="submit">Edit Invoice</Button>
</div>
</form>
)
}
可以看到,具体的逻辑和上面的例子几乎一模一样,主要的差别在于这个例子传给 action 的函数是用原本的 updateInvoice
通过 bind()
方法重新创建的,并且附带了一个初始参数:invoice.id
。
此处为什么需要 bind?
上述我们已经分析过了,Next 对 <form>
的 action 属性进行了特殊处理,使其能够接收一个以 FormData 为类型的对象为入参的函数。FormData 的数据是通过解析 <form>
标签内部的所有 <input>
、<select>
、<textarea>
等元素的值,通过 name 属性来构建 FormData 对象。
但是我们查看上面表单的具体内容,发现只有三个属性:customerId、amount、status,而如果需要更新 invoice,我们还需要传递一个其原本的 id。那么这个时候,就可以通过 bind 来传递额外的参数,此处是将 invoice.id 当作第一个参数传递给 updateInvoice 函数,使其能够正常接收。
总结一下?
此处只是单纯的将 Server Actions 的使用以及本质稍微进行了解析,并附上使用场景。虽然它很有用,但是 个人认为,如果是想维护一个大型的、规范的、涉及到很多与数据库复杂交互的应用时,还是采用 API Endpoints 更符合开发者的直觉。并且 API Endpoints 容易测试,使用 Server Actions 需要额外自己创建一个场景出来,自己实际进行操作才能够进行测试与调试,相对比较繁琐。