React 与 后端共享的验证逻辑:利用 Zod 实现前端表单校验与后端 API 校验的完全同构

各位同学,晚上好!我是你们的老朋友,今天要跟大家聊聊一个痛彻心扉的话题——验证

如果在座的各位开发人员这辈子没在“校验逻辑”上掉过头发,那你绝对是个新手,或者你的人生太顺遂了。想象一下,你正在开发一个注册页面,你在前端写了校验规则:密码不能少于6位,邮箱格式要对。用户填错了,你弹个窗提示。用户点了“注册”,你的前端代码一秒钟发起了请求,然后呢?

然后后端收到数据了,后端的代码里是不是还得再写一遍校验?如果后端没写,或者写错了,或者后端写的规则和前端不一致(比如前端说6位,后端说8位),结果会怎么样?没错,垃圾数据进入数据库。这就是我们要面对的“验证地狱”。

今天,我们要用一种极其优雅、极其现代的方式来终结这种地狱。我们要请出今天的男主角——Zod。我们要打造一种前后端完全同构的验证体系。

第一部分:前端校验是门面,后端校验是保镖

首先,我们要搞清楚一个哲学问题:为什么我们需要校验?

以前我们觉得校验是为了“用户体验”。用户填错了,前端立马报错,别让他提交。这没错,这是“保镖”的角色。但还有一层更重要的角色是“保安”,那就是后端。

如果只靠前端校验,你可以把它想象成你在街上卖烤红薯,你在摊位上放了个大牌子:“烤红薯五块一斤,一口一个”。这叫前端校验。然后有一群黑客骑着摩托车冲过来,把箱子里的红薯换成了石头,然后收了你五块。这时候你的牌子就没用了。

后端校验就是那个摄像头。不管你怎么换石头,不管你车技多好,摄像头都在那拍着呢。所以,后端校验是必须的,而且必须和前端规则一致。

这就是我们今天要解决的问题:如何让前端和后端共享这一套“真理”般的规则?

第二部分:Zod 是什么?它不仅仅是个库

在 Zod 出现之前,我们用什么?Joi?Yup?它们很棒,但它们是“JS 原生”的。它们虽然能验证,但很难优雅地生成 TypeScript 类型。而在现代开发中,我们离不开 TypeScript,我们不需要写两套类型定义。

Zod 的出现,简直就是前端界的“瑞士军刀”,也是后端界的“神来之笔”。它的核心哲学非常简单:Schema is truth(模式即真理)。

Zod 的作者是 @colinhacks。他是个天才,他发现我们一直在重复做一件事:定义数据长什么样,然后验证它。 所以 Zod 允许你定义一个 Schema,这个 Schema 既是验证器,又是类型定义器。

第三部分:Schema 的定义——把“规则”变成代码

让我们从最基础的开始。假设我们要定义一个用户注册的 Schema。

在以前的代码里,你可能需要写:

  1. 前端:一个 interface User
  2. 前端:yup.string().email() 这样的验证逻辑。
  3. 后端:一个 interface User
  4. 后端:Joi.object({ email: Joi.string().email() })

这简直就是维护噩梦。现在,用 Zod,我们只需要写一次

import { z } from "zod";

// 1. 定义你的 Schema,这就像是在画蓝图
// Zod 会帮你自动推导出类型
export const registerUserSchema = z.object({
  username: z
    .string({
      required_error: "用户名不能为空",
      invalid_type_error: "用户名必须是字符串",
    })
    .min(3, "用户名太短了,至少3个字符")
    .max(20, "用户名太长了,受不了了")
    .trim() // 去除首尾空格,这是好习惯

  // 这里是一个难点:Zod 默认生成的类型是 `z.infer` 的类型
  // 但我们希望去掉 `z` 前缀,像普通对象一样使用
  // 下文我会详细讲怎么处理
  // email: z.string().email("邮箱格式不对"), 
});

注意上面的 .min(3, ...).max(20, ...)。这是链式调用,非常直观。Zod 支持 .string().number().date() 等基础类型,还支持 .optional()(可选字段)、.nullable()(可空字段)。

这就是我们所谓的“同构”的基石。你看,前端和后端读到的代码结构是一样的。这就好比你们两个人背靠背,手里拿的是同一份乐谱,你弹琴,我打鼓,节奏完美同步。

第四部分:前端集成——React Hook Form 的完美伴侣

好了,蓝图画好了。现在我们要把它用到 React 前端。最流行的表单库是什么?当然是 React Hook Form。它轻量、快,但它的强项在于处理状态,而不在于校验。

以前我们怎么配合?写一堆 onChange 事件,自己写逻辑。现在有了 Zod,它推出了一个插件叫 zodResolver。这简直是“神仙打架”级别的配合。

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

// 再次定义 Schema,确保真理只有一份
const userFormSchema = z.object({
  username: z.string().min(3),
  password: z.string().min(6),
  email: z.string().email(),
});

// 将 Schema 转换为标准的 TypeScript 类型
type UserFormData = z.infer<typeof userFormSchema>;

export function RegistrationForm() {
  const {
    register, 
    handleSubmit, 
    formState: { errors } // Zod 会自动把错误信息填到这里
  } = useForm<UserFormData>({
    resolver: zodResolver(userFormSchema), // 哇!一行代码,搞定验证逻辑
  });

  const onSubmit = async (data: UserFormData) => {
    // data 已经是验证过的数据了!
    console.log("提交成功的数据:", data);

    // 发起 API 请求
    // await api.register(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>用户名</label>
        <input {...register("username")} />
        {/* 这里的 errors.username.message 就是 Zod 定义的错误信息 */}
        {errors.username && <span>{errors.username.message}</span>}
      </div>

      <div>
        <label>邮箱</label>
        <input {...register("email")} />
        {errors.email && <span>{errors.email.message}</span>}
      </div>

      <button type="submit">注册</button>
    </form>
  );
}

看到这里,是不是觉得很爽?你没有写任何手动的 if (value.length < 3),没有写任何正则表达式。Zod 内部帮你处理了一切。

而且,Zod 的错误信息格式非常统一,这让 UI 的错误展示变得极其简单。你不需要写不同的校验逻辑去渲染不同的错误 UI。

第五部分:后端集成——Express 里的安全网

好了,前端有了,用户也填了。我们怎么在 Node.js 后端里用 Zod?

假设我们用的是 Express。我们需要一个中间件来处理这个 Schema。

import { z } from "zod";
import { Request, Response, NextFunction } from "express";

// 定义 Schema
const createPostSchema = z.object({
  title: z.string().min(5, "标题太短了,至少5个字"),
  content: z.string().min(10, "内容太短了,至少10个字"),
  tags: z.array(z.string()).min(1, "至少要选一个标签"),
});

// 定义中间件函数
const validateBody = (schema: z.ZodSchema) => {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      // .parse() 方法会尝试解析请求体
      // 如果解析成功,它会将结果赋值给 req.body
      // 这意味着,你可以直接在后续的业务逻辑里使用 req.body
      req.body = await schema.parseAsync(req.body);
      next(); // 通过验证,放行
    } catch (err) {
      // 如果解析失败,Zod 会抛出一个 ZodError
      // 我们只需要把这个错误格式化成 JSON 返回给前端即可
      res.status(400).json(err);
    }
  };
};

// 在路由中使用
app.post("/api/posts", validateBody(createPostSchema), async (req, res) => {
  // 在这里,req.body 已经被 Zod 严格验证过了!
  // 你可以直接拿 title, content, tags 来存数据库
  const { title, content, tags } = req.body; 

  // ...
});

这里有个细节:req.body = await schema.parseAsync(req.body);。注意是 parseAsync,因为 Zod 里的某些校验(比如日期解析、异步转换)可能是异步的。这行代码做了一件事:数据清洗。如果用户传了个字符串给数字字段,Zod 会尝试转换;如果转换失败,直接报错。

这就是后端安全的基石。没有任何脏数据能直接溜进你的数据库。

第六部分:高级玩法——类型推断的魔法

回到开头,我们提到一个问题:Zod 生成的类型是 z.infer<typeof schema>,这很啰嗦。

有没有办法让 TS 直接识别出这个 Schema 对应的普通对象类型?

有的!虽然标准库的 z.infer 是这么定义的,但在实际项目中,我们通常会使用工具函数或者 TS 的映射类型。

import { z } from "zod";

// 原始 Schema
const userSchema = z.object({
  name: z.string(),
  age: z.number(),
});

// 1. 标准用法
type User = z.infer<typeof userSchema>;

// 2. 如果你真的很讨厌 z.infer,你可以用 TS 的映射类型技巧
// 这样你就可以直接把 userSchema 当作类型用
type SchemaToType<T extends z.ZodTypeAny> = T extends z.ZodTypeAny ? z.infer<T> : never;

// 使用示例:
// type User2 = SchemaToType<typeof userSchema>;
// 这样看起来还是很怪,其实为了配合 React Hook Form,通常直接用 z.infer 即可,
// 或者封装一个 util 函数。

// 封装一个函数,把 Zod schema 包装成 TS 类型导出
export const getZodType = <T extends z.ZodTypeAny>(schema: T) => z.infer<T>;
export type User2 = getZodType(userSchema);

// 现在,User2 就是一个普通的 TS 接口 { name: string, age: number }

这种同构性意味着什么?

意味着当你修改了 Zod Schema 里的规则(比如把 age 改为必填),你的前端 useForm 会自动报错,提示你“age 必填”,你的后端接口类型也会自动更新。你不需要手动去同步两边的类型定义。这叫“单一数据源原则”的极致体现。

第七部分:嵌套对象与数组——复杂结构的征服

现实世界的表单从来都不是扁平的。你有个地址,里面分省市区;你有个购物车,里面是个数组。Zod 处理这些简直像呼吸一样自然。

场景: 一个用户资料编辑页,包含地址信息。

const userProfileSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  // 嵌套对象
  address: z.object({
    street: z.string().min(5),
    city: z.string(),
    zipCode: z.string().regex(/^d{5}$/, "邮编格式不对"), // 正则校验
  }),
  // 数组
  // items: z.array(z.object({ id: z.string(), qty: z.number() }))
});

在前端怎么处理?

// React Hook Form 遇到嵌套对象非常给力
// 只需要在字段名里加个点就行
<input {...register("address.street")} />
<input {...register("address.zipCode")} />

// Zod 会自动帮你解析嵌套对象,errors 结构也是对应的:
// errors.address.street.message
// errors.address.zipCode.message

在后端怎么处理?

// 完全一样!Express 中间件不需要改任何代码
// req.body.address 会自动是一个对象
// req.body.items 会自动是一个数组
app.post("/profile", validateBody(userProfileSchema), (req, res) => {
  const address = req.body.address; // 这是一个对象
  const zipCode = req.body.address.zipCode; // 这是字符串
});

你看,这种一致性是如此令人舒适。你不需要在写 req.body.address.city 的时候去查文档确认这是不是对象,因为 Zod 的 Schema 就在那里,它就是文档。

第八部分:自定义校验——你无法预测的疯狂

有时候,Zod 的内置校验器满足不了你。比如你要校验一个密码,要求“必须包含大写字母、小写字母、数字,且不能包含空格”。

Zod 自带了 .refine().superRefine()。这两个函数是神技,它们允许你写任意的 JavaScript 逻辑来校验数据。

const passwordSchema = z.string()
  .min(8, "密码太短")
  .regex(/[A-Z]/, "需要大写字母")
  .regex(/[0-9]/, "需要数字");

// 自定义逻辑
const strongPasswordSchema = z.string().refine((val) => {
  return /[A-Z]/.test(val) && /[0-9]/.test(val) && !/s/.test(val);
}, {
  message: "密码太弱了,快去学学怎么设置密码吧"
});

// 或者使用更灵活的 superRefine,因为它能让你访问当前的数据上下文
const orderSchema = z.object({
  total: z.number(),
  discount: z.number().optional(),
}).superRefine((data, ctx) => {
  // 这里你可以访问整个 data 对象
  // 如果 total 大于 1000,且 discount 存在,就报错
  if (data.total > 1000 && data.discount) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "大额订单不能使用折扣",
    });
  }
});

这个 superRefine 是处理复杂业务逻辑的神器。它保证了你的后端代码拥有和前端一样的“智能”。前端如果验证通过,说明用户确实没在胡闹;后端如果验证通过,说明你逻辑严密。

第九部分:Enum 与 Union——处理有限的选择

前端经常会有下拉框,比如“性别:男/女/保密”。后端也经常有这些枚举值。维护两套字符串列表(前端数组和后端 Enum)是重复造轮子。

Zod 有 z.enum()

const genderSchema = z.enum(["MALE", "FEMALE", "SECRET"]);

// 后端类型:
// "MALE" | "FEMALE" | "SECRET"

// 前端处理:
const genderOptions = [
  { label: "男", value: "MALE" },
  { label: "女", value: "FEMALE" },
  { label: "保密", value: "SECRET" },
];

这有什么用?
当你在后端写代码时,TypeScript 会给你代码提示。你不会手误把 “MALE” 拼写成 “MalE” 或者 “MALL”。这是静态类型检查的终极胜利。

第十部分:实战场景构建——从零开始的博客系统

为了让大家更清楚,我们来构建一个极简的博客系统,从注册到发帖。

1. Schema 定义文件(schemas.ts

这是我们的“宪法”。

import { z } from "zod";

// 注册 Schema
export const registerSchema = z.object({
  username: z.string().min(3, "用户名太短").max(20, "太长了"),
  password: z.string().min(6, "密码太弱了"),
  email: z.string().email("邮箱地址不对劲"),
});

// 登录 Schema
export const loginSchema = z.object({
  email: z.string().email(),
  password: z.string().min(1, "不能没有密码"),
});

// 发布文章 Schema
export const createPostSchema = z.object({
  title: z.string().min(5, "标题太短了,打动不了读者"),
  content: z.string().min(20, "文章太短了,没干货"),
  tags: z.array(z.string()).min(1, "至少选一个标签"),
  published: z.boolean().optional().default(false),
});

2. 前端组件(RegisterForm.tsx

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { registerSchema, type UserFormData } from "./schemas";

export const RegisterForm = () => {
  const { register, handleSubmit, formState: { errors } } = useForm<UserFormData>({
    resolver: zodResolver(registerSchema),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log("API CALL", data))}>
      <input {...register("username")} placeholder="用户名" />
      {errors.username && <p className="error">{errors.username.message}</p>}

      <input type="email" {...register("email")} placeholder="邮箱" />
      {errors.email && <p className="error">{errors.email.message}</p>}

      <input type="password" {...register("password")} placeholder="密码" />
      {errors.password && <p className="error">{errors.password.message}</p>}

      <button type="submit">立即注册</button>
    </form>
  );
};

3. 后端接口(authRoutes.ts

import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod"; // 注意:后端通常不需要这个库,但我们可以复用逻辑
// 这里假设我们用 express-validator 或者手动解析
// 但为了展示 Zod,我们直接用 Zod 解析
import express from "express";

const router = express.Router();

const registerSchema = z.object({
  username: z.string().min(3),
  password: z.string().min(6),
  email: z.string().email(),
});

router.post("/register", async (req, res) => {
  try {
    // 解析
    const body = registerSchema.parse(req.body); 
    // body 现在是 { username: string, password: string, email: string }

    // 模拟数据库存储
    console.log("Saving user:", body);

    res.json({ message: "注册成功", user: body });
  } catch (err) {
    res.status(400).json({ message: "注册失败", errors: err });
  }
});

export default router;

你看,前端的 UserFormData 和后端解析出来的 body 类型是完全一致的。这就是同构的魔法。

第十一部分:错误处理的艺术

Zod 的错误对象非常详细。它不只是一句“你错了”,它会告诉你哪里错了

const schema = z.object({
  username: z.string(),
  age: z.number(),
  email: z.string().email(),
});

const result = schema.safeParse({
  username: "Alice", // 正确
  age: "18",         // 错误,字符串给数字用了
  email: "not-an-email" // 错误,格式不对
});

if (!result.success) {
  console.log(result.error.errors);
  /*
  [
    {
      path: ['age'],
      message: "Expected number, received string",
      code: "invalid_type",
      ...
    },
    {
      path: ['email'],
      message: "Invalid email",
      code: "invalid_string",
      ...
    }
  ]
  */
}

前端可以利用这个路径信息来精确地定位 UI 上的错误元素。比如 result.error.errors[0].path['age'],你就可以直接给那个输入框加上红色的边框。这比传统的“把所有错误列在表单下面”的体验要好得多。

第十二部分:关于转换

有时候,我们需要在验证的同时,做一些数据清洗。比如把用户输入的 ” hello ” 变成 “hello”,或者把字符串 “123” 变成数字 123。

Zod 提供了 .transform()

const schema = z.object({
  count: z.string().transform((val, ctx) => {
    const num = parseInt(val, 10);
    if (isNaN(num)) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "必须是个数字",
      });
      return z.NEVER; // 终止转换
    }
    return num;
  })
});

这种“转换即验证”的逻辑,非常适合在 API 层面使用。前端发过来的数据,你直接用 .parse() 解析,解析出来的数据就是完全干净的,可以直接进数据库,直接进业务逻辑。你再也不需要写 parseInt(req.body.count) 然后判断是不是 NaN 了。

第十三部分:性能与大小

可能有人会问,Zod 这么好,会不会很重?会不会影响首屏加载?

答案是:完全不会。

Zod 是纯 JS 实现的,没有依赖,没有运行时开销。它的核心逻辑是基于正则表达式和 AST 的。虽然它在运行时确实做了一些检查,但这种检查是非常轻量级的,而且现代浏览器的解析速度非常快。

更重要的是,如果你把 Zod 和 React Hook Form 搭配使用,Zod 的解析逻辑只在 handleSubmit 触发的那一刻运行一次。这比在每次 onChange 的时候都运行正则表达式要高效得多。

第十四部分:总结与思考

回到最初的话题。为什么我们要追求前端和后端验证逻辑的同构?

  1. 防御深度: 前端是第一道防线,用于提升 UX。后端是最后一道防线,用于保障安全。如果这两道防线用的武器不一样,黑客就能找到漏洞。
  2. 开发效率: 修改一个字段,改一个地方,两边都生效。
  3. 类型安全: TypeScript 的类型提示贯穿始终,减少 bug。

Zod 让我们摆脱了“粘贴粘贴代码”的工匠时代,进入了“声明式编程”的时代。我们声明了数据长什么样,然后工具链自动帮我们生成验证器、类型定义和错误信息。

这就像你买了一把自动步枪,而 Zod 就是那个上膛的机械师。你只需要把目标(Schema)指给他看,剩下的交给子弹。

未来展望:
现在很多框架(如 Next.js, Nuxt, NestJS)都已经深度集成了 Zod。NestJS 甚至可以直接在 DTO 类上用装饰器配合 Zod,让后端开发也像前端一样优雅。

所以,下次当你看到同事在两个文件里写两个 if (value.length < 3) 的时候,请轻声地把 Zod 的代码推给他。告诉他:“兄弟,放过你自己吧,用 Zod 吧。”

祝大家代码无 Bug,验证不报错!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注