返回首页

Next.js 中的表单作为契约:Zod 和统一规则

本文解释了如何将 Next.js 中的表单视为界面、验证和服务器之间的契约,而非 UI 组件。使用 Zod、safeParse 和 flatten 可以统一规则、错误格式和状态,确保可预测的 UX 且客户端和服务器之间无差异。

如何使用 Zod 将 Next.js 中的表单转变为可靠的契约
Advertisement 728x90

Next.js 中的表单作为契约:Zod、fieldErrors 和客户端与服务器端的统一规则

表单不仅仅是一堆字段和按钮——它是 UI、验证逻辑和数据源之间的契约。如果没有明确定义这个契约,就会引发混乱:UI 认为数据有效,服务器却抛出错误,提示消息随机出现,表单行为不可预测。解决方案?使用 Zod、safeParse 和 flatten 来统一验证模式、错误格式和状态,确保客户端和服务器端规则完全一致。

没有契约表单为何崩溃

许多项目起步时很简单:输入、状态、提交。小表单没问题。但随着代码库增长,会出现三个关键的分歧点:

  • 验证规则不一致。客户端检查字符串长度,服务器却额外标记禁词或空格。用户看到绿色对勾,提交却失败。
  • 错误格式不匹配。有时是字符串,有时是对象,再有时是数组。UI 得猜响应结构。
  • 状态未定义。加载中、禁用、成功、formError——每个组件处理方式不同,导致用户体验支离破碎。

没有单一契约,每个表单都按自己的本地规则行事,无法扩展。

Google AdInline article slot

表单作为契约:关键要素

表单契约必须明确回答这些问题:

  • 什么数据才算有效?
  • 哪些错误针对特定字段(fieldErrors),哪些针对整体操作(formError)?
  • 加载状态如何处理?
  • 成功时返回什么?
  • 服务器能否返回不同格式的响应?
  • 客户端和服务器使用相同规则吗?

这些答案要在实现 UI 前敲定。在 Workbench 中,这种方法用 Zod 模式和一致响应形状标准化了所有表单。

一个 Zod 模式,双重用途

与其在客户端和服务器间复制验证逻辑,不如到处用单一 Zod 模式。笔记示例模式:

Google AdInline article slot
import { z } from "zod";

export const noteSchema = z.object({
  title: z
    .string()
    .trim()
    .min(3, "Enter minimum 3 simvola")
    .max(80, "Maximum 80 characters"),
  description: z
    .string()
    .trim()
    .max(500, "Maximum 500 characters")
    .optional()
    .or(z.literal("")),
}).refine(
  data => data.title.toLowerCase() !== "test",
  {
    path: ["title"],
    message: "Withlishkom tekhnicheskoe name for zametki",
  }
);

export type NoteInput = z.infer<typeof noteSchema>;

这个模式定义了有效值、转换(如 trim)和自定义规则(refine)。它成为验证的唯一真相来源。

使用 safeParse 安全验证

表单中用 safeParse 而非 parse 至关重要。parse 出错时抛异常——对用户体验有害,因为无效输入很常见。safeParse 返回易处理的结果:

export function validateNote(input: unknown) {
  return noteSchema.safeParse(input);
}

结果包含 success 标志,以及如需的 error 对象。这让 UI 和服务器逻辑能处理相同响应类型,无需 try/catch。

Google AdInline article slot

使用 flatten 统一错误

Zod 返回复杂错误对象。表单只需两层:字段错误和全局错误。flatten() 方法完美契合:

export function formatZodError(error: import("zod").ZodError) {
  const flat = error.flatten();

  return {
    fieldErrors: flat.fieldErrors,
    formError: flat.formErrors[0] ?? null,
  };
}

示例区别:

  • fieldErrors:标题字段不能为空、描述超过 500 字符。
  • formError:此 slug 的笔记已存在、服务器暂时不可用。

这种分离让用户体验可预测:用户清楚哪里需修正输入,何时重试或等待。

客户端和服务器端的统一契约

客户端上,Zod 模式支持提前验证和 UI 状态管理:

const clientResult = useMemo(() => {
  return noteSchema.pick({ title: true }).safeParse({ title });
}, [title]);

const titleError = clientResult.success
  ? null
  : clientResult.error.flatten().fieldErrors.title?.[0] ?? null;

const canSubmit = clientResult.success;

服务器上,同一模式充当最终防线:

export async function createNoteAction(raw: unknown) {
  const parsed = noteSchema.safeParse(raw);

  if (!parsed.success) {
    const flat = parsed.error.flatten();
    return {
      ok: false,
      fieldErrors: flat.fieldErrors,
      formError: flat.formErrors[0] ?? null,
    };
  }

  try {
    // save to database
    return {
      ok: true,
      fieldErrors: {},
      formError: null,
    };
  } catch {
    return {
      ok: false,
      fieldErrors: {},
      formError: "Not succeeded sokhranit zametku",
    };
  }
}

一条规则,不同角色:客户端管用户体验,服务器管安全。

标准表单状态形状

为避免碎片化,为所有表单定义单一类型:

export type FormState = {
  ok: boolean;
  fieldErrors: Record<string, string[] | undefined>;
  formError: string | null;
};

这个形状覆盖所有状态:成功、字段错误、通用错误。项目中每个表单都返回相同结构——简化 UI 处理。

何时使用 React Hook Form

React Hook Form (RHF) 强大,但并非万能。只在以下表单中使用:

  • 众多字段带依赖验证
  • 动态列表或嵌套结构
  • 失焦验证、已触碰/已脏状态
  • 带焦点管理的自定义控件
  • 需要渲染优化

对于 1–2 个字段的简单表单加基本验证,RHF 徒增复杂。如果 useMemo + Zod 派生状态能解决,就保持简单。

思维转变:从 UI 到契约

关键变化?别再把表单视为 UI 组件。表单是定义以下内容的契约:

  • 有效数据模式
  • 错误响应格式
  • 状态行为(加载中、成功、错误)
  • 客户端-服务器职责边界

契约先行,UI 实现就成机械活。最重要的是,项目中每个表单都按同一协议行事,行为可预测。

关键要点

  • 客户端和服务器用同一 Zod 模式——消除规则分歧。
  • 始终用 safeParse——返回易处理结果,而非异常。
  • 分离 fieldErrorsformError——提升用户体验,简化显示逻辑。
  • 定义标准 FormState 形状——所有表单返回相同结构。
  • 只在真正需要时加 React Hook Form——不是赶潮流,而是复杂场景。

— Editorial Team

Advertisement 728x90

继续阅读