首页 / 文章

在 Server Actions、createServerFn 和 tRPC 中使用同一份邀请表单

有三种方式可构建邀请表单:Next.js Server Actions、TanStack createServerFn 以及 tRPC,本文将从类型、打包体积、无 JS 运行模式以及 Zod 错误处理等方面对它们进行比较。

发布者:REACTAPP.TOP阅读约 7 分钟1626 词

来源: One Form, Three Backends: Server Action, createServerFn, tRPC.

三种后端,一个邮箱字段

以三种方式连接同一个邀请字段,然后比较真正重要的因素:数据传输中的类型、套餐的收费情况,以及处理程序抛出异常或JavaScript被禁用时的反应。

用户界面极为简单。只有一个email输入框,点击提交即可。成功时按钮下方会显示一条提示,失败时也会显示一条提示。这就是整个产品的界面。

处理程序包括:Next Server Action、TanStack的createServerFn以及tRPC突变函数。它们共享相同的Zod模式和数据副本。应用结构仍是那四个路由,另外还包含一个Start树结构,这样createServerFn就不会显得多余。那些宣称某套技术是“2026年标准”的宣言对表单并无帮助。表单只关心错误是否以可显示的字符串形式出现、类型在数据传输过程中是否保持不变,以及在没有客户端JavaScript的情况下页面是否仍能正常运行。

每套技术实际能提供的功能

在 Next 中,用 "use server" 标记一个函数,并将表单的 action 属性指向该函数——可选地通过 useActionState 来处理待处理和结果状态。

在 TanStack Start 中,createServerFn 会生成一个带有验证功能的类型化可调用函数。从原生表单提交中调用它需要自行编写适配器,因为它默认不会作为渐进式表单操作出现。

在 tRPC 中,你需要定义一个变异处理程序,并从客户端调用它。该设计并不支持无需 JavaScript 即可运行。

那些已发布的契约为这项实验奠定了框架。

随处复用的架构

export const inviteSchema = z.object({
  email: z.string().email(),
});
export type InviteInput = z.infer<typeof inviteSchema>;

一个架构,三种处理方式。如果某个框架强制使用相同规则的不同实现形式,那将会增加成本并体现在性能评估中。

服务器操作路径

"use server";

import { inviteSchema } from "./schema";

export async function invite(prev: { error?: string } | null, formData: FormData) {
  const parsed = inviteSchema.safeParse({
    email: formData.get("email"),
  });
  if (!parsed.success) return { error: parsed.error.issues[0].message };
  await db.invites.create(parsed.data.email);
  return { error: undefined, ok: true };
}
"use client";
import { useActionState } from "react";
import { invite } from "./actions";

export function InviteForm() {
  const [state, action, pending] = useActionState(invite, null);
  return (
    <form action={action}>
      <input name="email" type="email" required />
      <button disabled={pending}>Invite</button>
      {state?.error ? <p>{state.error}</p> : null}
    </form>
  );
}

即使禁用了 JavaScript,表单仍然可以提交。这就是 Action contract,在本实验中得到了验证。

createServerFn 路径

export const inviteFn = createServerFn({ method: "POST" })
  .inputValidator(inviteSchema)
  .handler(async ({ data }) => {
    await db.invites.create(data.email);
    return { ok: true as const };
  });

在启动时,表单会从 onSubmit 调用 inviteFn({ data: { email } })。如果没有额外的适配器,这条路径就需要 JavaScript。通过创建一个虚假的渐进增强封装层并假装它随框架一同提供,可以欺骗比较结果。

tRPC 路径

invite: publicProcedure.input(inviteSchema).mutation(async ({ input }) => {
  await db.invites.create(input.email);
  return { ok: true as const };
}),

客户端的用法是 api.invite.useMutation()。input.email 的类型定义非常完善。没有 JavaScript 的话,按钮就毫无用处。

三者的评分情况

类型。 tRPC 和 createServerFn 可以通过 Zod 自动推断输入类型,无需额外的客户端类型定义。服务器端操作接收 FormData 并依赖 safeParse 进行解析。虽然安全,但与带类型的客户端调用并不相同。

打包。 操作模块的体积几乎没有增加。tRPC 已经包含了客户端/提供端所需的功能,这些本就是 API 接口必须具备的。而 createServerFn 则需要使用 Start 的函数式客户端。现有的 tRPC 能够降低数据修改的成本,但为单个邮件新建的 tRPC 并不能做到这一点。

异常处理。 当强制让 db.invites.create 失败时:

  • 操作层面:返回的值会被存入 useActionState,并在按钮下方显示。未被捕获的异常会一直传递到 Next 的错误处理机制中,直到被妥善处理。
  • createServerFn:对应的承诺会被拒绝,可在 onSubmit 中对其进行处理。
  • tRPC:在变异操作中使用isError;映射方式相同。
  • 关闭JS后。该操作有效,另外两种则无效。这是协议差异而非优劣之分。那些无需JS即可正常运行的营销设置页面应使用Actions。已经依赖JS的仪表板则可忽略这一维度。

    架构之外的考量

    耗费时间的是适配器,而非Zod。

    错误格式曾多次变化,最终统一为{ error?: string }。tRPC更倾向于使用error.message,只需进行一次映射即可。切勿让三种不同格式的错误信息出现在按钮下方的段落中。

    认证方式存在差异:Actions依赖文档请求的cookie;tRPC使用过程上下文;createServerFn则使用Start的请求。这些方式都可能是正确的,只有其中一种才是“已提交的表单”。

    操作成功后的缓存刷新次数也增加了三倍:动作 updateTag("invites")、tRPC 的 utils.invites.list.invalidate(),以及启动加载器失效功能。同一句话,三种表达方式。

    何时使用哪种方案

    当控件为 Next 框架中的真实表单且仍需避免使用 JS 时,建议使用服务器端动作。若需要内联复制功能,请用 useActionState 封装代码、返回错误信息,并避免在边界之外抛出异常。

    若已使用 Start 框架且调用方为类型化的客户端,建议使用 createServerFn。不要仅仅为了某个表单而采用 Start 框架。

    当 API 是面向移动端或合作伙伴的产品时,建议使用 tRPC。不要仅仅为了发送一封邮件就安装 tRPC。

    除非正在对它们进行性能测试,否则不要在一个应用中同时使用这三种方案。

    明确限制

    仅在字段为真实命名的输入控件时,“禁用JS处理操作”功能才会生效。仅存在于React中的状态无法通过该测试。

    实验中检测的是异常抛出、类型问题以及禁用JS后的提交情况,而非总处理时间。如果在你承诺功能正常后,禁用JS后按钮仍无反应,则说明该页面选择的后端方案不合适。

    执行三次提交操作:启用客户端脚本、禁用客户端脚本,以及模拟数据库故障。保留那些在按钮下方仍能显示状态行的路径——这些就是证据。

    三种邀请路径,三种错误表现

    在设置邀请表单中,同一个无效邮箱导致了三种不同的界面表现:useActionState使操作项被下划线标出,createServerFn抛出异常并被捕获到通用错误处理机制中,而tRPC则将Zod验证错误存储在error.data.zodError字段且未进行映射处理。三者延迟大致相同(约40毫秒)。问题根源在于产品设计的差异。

    仅使用 Toast 的 Invalid 包装器会删除字段标识。永久的解决方案是使用一个映射器:

    function fieldErrors(err: unknown): Record<string, string> {
      if (err && typeof err === "object" && "issues" in err) {
        const issues = (err as { issues: { path: (string|number)[]; message: string }[] }).issues;
        return Object.fromEntries(issues.map((i) => [String(i.path[0]), i.message]));
      }
      return { form: "Could not send invite" };
    }
    

    每个后端在呈现给用户界面之前都会经过该映射器处理。无效的邮箱地址始终会显示下划线。将操作保留在“Next”设置页面中,createServerFn 放在“Start experiment”页面,worker mobile 已经支持 tRPC。只要多个后端共享相同的架构和映射器,则没有问题;但若错误类型不同则不行。

    复现方法

    先提交空内容、nope,然后再提交有效的地址。捕获 JSON 数据或操作状态。如果消息的路径不同,那就相当于发布了三种不同的产品。

    可行示例:一个架构,三个条目

    export const Invite = z.object({
      email: z.string().email(),
      role: z.enum(["admin", "member"]),
    });
    export type Invite = z.infer<typeof Invite>;
    

    服务器操作:

    "use server";
    export async function inviteAction(_prev: unknown, form: FormData) {
      const parsed = Invite.safeParse({
        email: form.get("email"),
        role: form.get("role"),
      });
      if (!parsed.success) return { errors: fieldErrors(parsed.error) };
      await sendInvite(parsed.data);
      return { errors: {} };
    }
    

    tRPC:

    invite: publicProcedure.input(Invite).mutation(({ input }) => sendInvite(input)),
    

    createServerFn:

    export const inviteFn = createServerFn({ method: "POST" })
      .validator(Invite.parse)
      .handler(({ data }) => sendInvite(data));
    

    UI始终读取errors.email。无法生成该键的后端不会被集成到此表单中。

    给下一位工程师的指导

    模式定义放在一个文件中,映射器也放在一个文件中。只有以该映射器为结尾的传输方式才被允许使用,仅显示提示信息的错误则不被支持。在Action路径上保留invite设置,而在tRPC中保持mobile设置。即便本周的文章赞扬了GraphQL,也无需强行添加第四种传输方式。

    实验环节指令

    在记录结果之前先记录工具链版本:

    node -v
    pnpm exec tsc -v
    pnpm exec next --version
    

    然后启动应用程序:

    pnpm exec next dev
    

    依次访问/、/invoices、/invoices/1、/settings,然后再返回。保留DevTools的日志记录。执行类型检查:

    pnpm exec tsc --noEmit --pretty false
    echo $?
    

    退出码为0仅表示可以查看运行时状态,而非产品质量的证明。在您的机器上重新运行JS开启/关闭/抛出异常的测试——温度、内存以及Chrome标签页都会影响执行时间,其影响程度远超过框架的次要更新。

    请保留一行简短的“修复失败”记录(尝试了X,仍出现Y问题),这样笔记本才能作为证据使用,而非宣传资料。

    故障日志与检查清单

    tRPC需要的是error.data.zodError.fieldErrors.email[0],而Actions需要的是state.errors.email。最初抛出的是原始的ZodError,需统一为errors.email: string格式;其他任何情况都属于映射器故障。

    曾出现过一个额外的fetch('/api/invite')路由,后来通过添加指向inviteAction的注释将其删除。注释比额外的映射器更有效。

    在添加传输层之前:

    • 导入共享的架构,不要直接复制
  • 失败时会以字符串形式返回 errors.email
  • 成功的路径只会发送一封邀请函,不会为每种传输方式各发一份
  • 输入 nope 时,用户看到的字段仍会显示下划线
  • 没有仅通过提示框隐藏路径的备用方案
  • 如果不对任何选项打勾,那么这种更改就相当于另一个产品,而非另一种传输方式。

    来自实际使用情况的说明

    由于存在三种客户端:Next 设置、开始实验功能以及移动端,该邀请设置对应了三个后端。用户需要输入电子邮件地址。若缺少下划线,后续处理人员发现无效数据时就会生成“显示为已通过”的工单。需注意实际发送的邀请与预期发送的邀请之间的差异。映射器代表产品本身,而传输方式则相当于其基础设施。当再次出现使用 GraphQL 的诱惑时,应重新审视这三种错误处理方式并避免采用。

    该实验的价值在于对比矩阵,而非胜出者标识。需记录在禁用JS后哪些路径仍能正常运行、哪些会在没有映射器兜底机制的情况下返回基于字段键的错误,以及哪些已与您正在使用的客户端匹配。每当有人提议再增加一个邀请端点时,都应重新查看该矩阵。