又是被 Next.js 新概念洗礼的一天。

我们知道,Next.js 最核心的特性便是 支持静态生成(SSG)和服务端渲染(SSG),这也就意味着我们可以以部署 Node 服务的方式,将其部署在服务器上,用请求后端接口类似的形式来请求页面文件。换句话说,我们其实可以直接把 Next.js 看成一个特殊的 Node 后端服务。

既然是在服务端进行运行,那么它在数据库的查询方式上自然和一般的 SPA 客户端进行查询有所区别。

我们先简单分析一下一般 React SPA 项目的前后端交互:

  1. 前后端分离开发,后端无论用什么语言编写,最终只需要提供一个 API Endpoint (URL) 给前端。
  2. 前端二次封装 axios,或者直接使用 fetch,自己手动封装对应 URL 的异步请求函数,根据 API Endpoint 的不同依次进行封装,并定义需要传入的参数、方法等。
  3. 前端在需要调用接口的位置调用这些封装好的接口函数获取数据,展示页面。

这样是一套完整的前后端分离接口交互的编写。很显然,这样的交互、从数据库中拿数据的行为是以页面为单位的,当用户重新在这个页面上发生相同的交互行为时,对应的 HTTP 请求会 100% 完整地复刻一遍。

而在 Next.js 中,基于其自身的特殊性,提供了一个与数据库直接进行交互的特殊方式:Server Actions

# Server Actions 是?

Server Actions 直译为 服务端行为,是 Next.js 中的一个特殊概念,用于在服务端直接进行数据库查询等操作,而不是在客户端进行异步请求。

下面贴一下官方的一些解释

React Server Actions allow you to run asynchronous code directly on the server. They eliminate the need to create API endpoints to mutate your data. Instead, you write asynchronous functions that execute on the server and can be invoked from your Client or Server Components.

React Server Actions 允许您直接在服务器上运行异步代码。您无需创建 API 端点来更改数据。相反,您可以编写在服务器上执行的异步函数,并可从客户端或服务器组件中调用。

Security is a top priority for web applications, as they can be vulnerable to various threats. This is where Server Actions come in. They offer an effective security solution, protecting against different types of attacks, securing your data, and ensuring authorized access. Server Actions achieve this through techniques like POST requests, encrypted closures, strict input checks, error message hashing, and host restrictions, all working together to significantly enhance your app's safety.

安全性是网络应用程序的重中之重,因为它们很容易受到各种威胁。这就是服务器操作的用武之地。它们提供了一种有效的安全解决方案,可抵御各种类型的攻击、保护数据安全并确保授权访问。Server Actions 通过 POST 请求、加密关闭、严格输入检查、错误信息散列和主机限制等技术来实现这一目标,所有这些技术共同作用,大大提高了应用程序的安全性。

因此简单来说,所谓的 Server Actions 实际上就是一个在 Next.js 中的一个 普通异步函数,只不过这个异步函数可以直接操作数据库而已。这个函数与 React Server Components 深度集成,可以看成是组件本身的一部分。

这个异步函数的执行单纯在服务端进行,而 不以接口请求的形式获取数据,因此 无法在网络调试中看到接口的请求,可以理解为这个异步函数是在服务端直接执行的。

也正是因此,不将请求的数据暴露出来,能够最大程度上保证数据的安全性。

Server Actions 还与 Next.js 缓存深度集成。通过其提交表单时,不仅可以使用该动作更改数据,还可以使用 revalidatePathrevalidateTag 等 Next API 刷新相关页面的缓存,以确保在每次数据更新之后重新访问该页面时能够获取最新的数据。

# 与传统 API 的比较

我们可以试着总结一下 Server Action 和传统 API Endpoints 的优缺点:

# Server Actions

优点:

  1. 简化代码结构

    • 将处理逻辑和组件逻辑放在一起,减少代码分散,提高可读性。例如一般处理点击按钮触发请求,我们可以直接往 onClick 中传递 Server Action 异步函数即可,无需调用 API Endpoint。
  2. 直接与组件交互

    • Server Actions 直接在组件中调用,减少了不必要的中间层,简化了数据流。
  3. 减少网络请求

    • 通过 Server Actions 直接在服务器上处理数据,不需要通过 HTTP 请求来获取数据,减少了网络延迟。
  4. 自动处理错误

    • Server Actions 可以自动处理常见的错误,如网络错误或数据库连接错误,使得代码更加健壮。

缺点:

  1. 难以复用

    • 由于 Server Actions 紧密耦合在组件中,复用性较差,不如 API Endpoints 容易在多个组件或项目中共享。
  2. 测试难度大

    • 测试 Server Actions 可能比测试 API Endpoints 更加复杂,因为它们嵌入在组件中,需要模拟更多的上下文。不过 Next 官方提供的 console 工具可以直接在 Chrome 的 Dev tool 里面进行输出。
  3. 可扩展性

    • 当项目变大时,将所有逻辑放在组件中可能会导致代码难以维护和扩展,需要提前组织好代码的逻辑。

# 传统 API Endpoints

优点:

  1. 松散耦合

    • API Endpoints 将业务逻辑与前端组件分离,提高了代码的模块化和复用性。
  2. 易于测试

    • API Endpoints 独立于组件,便于进行单元测试和集成测试。可以与各类 API 测试工具相集成,适合前后端分离、大型团队的合作开发。
  3. 灵活性

    • 可以使用各种中间件和框架(如 Express, Koa 等)来扩展和处理复杂的逻辑。
  4. 可扩展性

    • 更容易扩展和维护,适合大型应用程序的开发。

缺点:

  1. 额外的网络开销

    • 每次数据请求都需要通过 HTTP 请求,增加了网络延迟,尤其在高频请求的情况下影响性能。需要使用防抖、节流等方式处理请求。
  2. 复杂性

    • 需要额外的配置和设置来处理 API 请求和响应,增加了项目的复杂性。
  3. 代码分散

    • 数据处理逻辑与组件逻辑分开,可能导致代码分散,降低可读性。

适用场景

  • Server Actions

    • 适合小型项目或简单的数据交互场景。
    • 当需要快速开发、减少代码复杂性时,Server Actions 是一个不错的选择。
    • 适用于处理简单逻辑和不频繁的数据操作。
  • API Endpoints

    • 适合中大型项目或复杂的数据交互场景。
    • 需要高复用性、易测试和可扩展性时,选择 API Endpoints 更为合适。
    • 适用于需要处理复杂业务逻辑、多端共享 API 的场景。

# Server Action 实例

上面我们简单讨论了 Server Actions 的定义以及其与一般 API Endpoint 的区别,接下来我们看一个实际使用 Server Action 与数据库进行交互的例子。

此处的例子来源于 Next 官方教程,需要的同学可以去官网进行查阅。此处需要实现的目标是对数据库 Invoices(发票)数据的 增、改、删 ,也就是直接修改数据库数据。

我们可以看一下具体的代码,并对它进行解析:

"use server"; // Server Actions 只在服务端运行
import { z } from "zod";
import { sql } from "@vercel/postgres";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
//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 const updateInvoice = async (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 const deleteInvoice = async (id: string) => {
  await sql`DELETE FROM invoices WHERE id = ${id}`;
  revalidatePath("/dashboard/invoices");
};

这段代码是一个完整的 actions.ts 文件。对相同对象的数据库操作可以用单独的文件进行存放。

首先我们看一下导入。这里使用了 zod 库用于 TS 数据验证, @vercel/postgres 用于数据库操作, next/cachenext/navigation 用于缓存和导航控制。定义了一个 FormSchema 来验证发票表单数据的结构。

const CreateInvoice = FormSchema.omit({ id: true, date: true });

CreateInvoice 是一个从 FormSchema 中去除 iddate 字段的验证器,用于创建发票时的数据验证。

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 函数处理发票创建:

  1. formData 获取并验证数据。
  2. 将金额转换为分(cents)。
  3. 获取当前日期。
  4. 使用 SQL 插入语句将数据插入数据库。
  5. 使用 revalidatePath 重新生成 /dashboard/invoices 路由的页面,以便显示最新数据。
  6. 使用 redirect 重定向到 /dashboard/invoices

其余两函数对 Invoice 的操作大同小异,三个函数分别实现了增、改、删的操作。

那么,对应具体该怎么在组件中使用 Server Actions 呢?

我们对应的来看一下提交表单的内容:

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}>
      {/* ~ 此处省略表单的具体内容~*/}
      <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 插入到数据库中。

最终实现的请求效果如下图所示:

Server-Action提交表单演示

可见,具体的请求、返回数据都和原本的 API 调用方法有所区别。

对应的,我们来看看更新 Invoice 的调用代码:

"use client";
import { CustomerField, InvoiceForm } from "@/app/lib/definitions";
import {
  CheckIcon,
  ClockIcon,
  CurrencyDollarIcon,
  UserCircleIcon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
import { Button } from "@/app/ui/button";
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}>
      <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 h-[18px] w-[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 h-[18px] w-[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="h-4 w-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="h-4 w-4" />
                </label>
              </div>
              <div className="flex items-center">
                <input
                  id="paid"
                  name="status"
                  type="radio"
                  value="paid"
                  defaultChecked={invoice.status === "paid"}
                  className="h-4 w-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="h-4 w-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 需要额外自己创建一个场景出来,自己实际进行操作才能够进行测试与调试,相对比较繁琐。