首页 / 文章 / 在 React 前端与 Node 后端之间共享同一个 Zod Schema

在 React 前端与 Node 后端之间共享同一个 Zod Schema

了解如何通过一个 Zod schema 同时验证 React 表单、API 响应、Express 请求体以及环境变量,并生成对应的 TypeScript 类型。

1536 词

验证功能应该存在于每个应用程序中,但团队往往只是零散地添加它——前端用一个库,后端用另一个库,同样的规则还要复制粘贴到多个地方。Zod之所以深受 JavaScript 和 TypeScript 开发者的青睐,正是因为它避免了这种混乱:你只需编写一次架构定义,该定义既能用于检查数据,又能生成对应的 TypeScript 类型,从而在浏览器和服务器上以完全相同的方式使用。

1. 什么是 Zod?

Zod 是一个从一开始就为 TypeScript 而设计的架构库。你只需描述一次数据结构,Zod 就会利用该描述在运行时检查值自动推导出 TypeScript 类型——无需单独编写接口,也不会出现与验证规则不同步的风险。

import { z } from 'zod';
const UserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
});
type User = z.infer<typeof UserSchema>;
// { name: string; email: string; age?: number }

这样的单一架构能够同时实现三重功能:它描述数据的结构、在运行时对其进行验证,同时还为编辑器和编译器提供所需的静态类型信息。

2. 为何 Zod 比其他方案更优

其最突出的优势在于自动类型推断。像 Yup 或 Joi 这样的库通常要求你在手动编写的 TypeScript 接口之外再维护一个验证架构,指望随着代码库的变化两者不会出现偏差。而 Zod 完全消除了这种风险:类型直接从架构中推导出来,因此无需再保持两者同步。

Zod还体积轻巧且无需外部依赖,因此无论是在注重包大小的前端项目中,还是在Node.js服务中,使用起来都同样便捷。其可链式组合的API意味着,即便是对复杂数据的验证——如嵌套对象、联合类型、相互依赖的字段——也能保持清晰易读,而不会变成一堆杂乱的临时辅助函数。

3. 在React应用中使用Zod

3.1 使用React Hook Form进行表单验证

Zod可通过@hookform/resolvers包直接集成到React Hook Form中。

npm install zod react-hook-form @hookform/resolvers
// components/SignupForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const SignupSchema = z.object({
  name: z.string().min(2, 'Name is too short'),
  email: z.string().email('Invalid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});
type SignupData = z.infer<typeof SignupSchema>;
export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupData>({
    resolver: zodResolver(SignupSchema),
  });
  const onSubmit = (data: SignupData) => {
    console.log('Valid data:', data);
  };
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} placeholder="Name" />
      {errors.name && <p>{errors.name.message}</p>}
      <input {...register('email')} placeholder="Email" />
      {errors.email && <p>{errors.email.message}</p>}
      <input type="password" {...register('password')} placeholder="Password" />
      {errors.password && <p>{errors.password.message}</p>}
      <button type="submit">Sign Up</button>
    </form>
  );
}

无需手动跟踪错误状态,也无需维护重复的类型声明——一个统一的架构即可同时处理验证、为每个字段提供错误信息,并定义所提交data对象的TypeScript类型。

3.2 验证API响应

Zod在应用程序的接收端同样非常有用——例如,用于检查从API返回的数据是否确实符合预期,因为仅依靠编译时类型无法确保这一点。

import { z } from 'zod';
const PostSchema = z.object({
  id: z.number(),
  title: z.string(),
  body: z.string(),
});
const PostsResponseSchema = z.array(PostSchema);
async function fetchPosts() {
  const res = await fetch('/api/posts');
  const json = await res.json();
  const result = PostsResponseSchema.safeParse(json);
  if (!result.success) {
    console.error(result.error.flatten());
    throw new Error('Invalid API response shape');
  }
  return result.data; // fully typed Post[]
}

这种方法能够在格式错误或意外的响应导致用户界面出现无声故障之前将其捕获。

4. 在 Node.js / Express 后端中使用 Zod

4.1 验证请求体

npm install zod express
// schemas/user-schema.ts
import { z } from 'zod';
export const CreateUserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
});
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
// middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { ZodSchema } from 'zod';
export function validate(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({ errors: result.error.flatten() });
    }
    req.body = result.data;
    next();
  };
}
// routes/users.ts
import { Router } from 'express';
import { validate } from '../middleware/validate';
import { CreateUserSchema } from '../schemas/user-schema';
const router = Router();
router.post('/users', validate(CreateUserSchema), (req, res) => {
  // req.body is now guaranteed to match CreateUserInput
  const { name, email, age } = req.body;
  res.status(201).json({ name, email, age });
});
export default router;

这种配置为每个路由提供了统一的声明式验证步骤,错误处理被集中处理,而无需在各个处理函数中重复使用内联的 if 检查。

4.2 验证环境变量

Zod一个被低估但非常强大的用途是在应用启动时检查process.env,这样不良的配置就会立即导致失败,而不会在之后引发令人困惑的错误。

// config/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  NODE_ENV: z.enum(['development', 'production', 'test']),
});
export const env = EnvSchema.parse(process.env);

如果缺少任何必需的变量或其格式不正确,进程会立即崩溃并显示易于理解的错误信息——这比在数据库调用中出现的神秘故障要容易诊断得多。

5. 真正的优势:整个技术栈共享同一套架构

由于Zod架构只是TypeScript值,因此你可以将它们放在共享的包中——或者单体仓库内的共享文件夹中——并在客户端和服务器上重复使用完全相同的架构

/packages
  /shared
    /schemas
      user-schema.ts   <-- used by both React app and Express API
  /web (React/Next.js)
  /api (Node/Express)
// packages/shared/schemas/user-schema.ts
import { z } from 'zod';
export const CreateUserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().int().positive().optional(),
});
export type CreateUserInput = z.infer<typeof CreateUserSchema>;

React 应用依赖该架构在表单提交前进行验证。Express API 也使用完全相同的架构来校验传入的数据。当架构发生变化时——例如出现新的必填字段——这两层会同步更新,TypeScript 会立即指出那些尚未适配新结构的代码。这有效避免了客户端与服务器端验证随时间逐渐脱节的各类问题。

6. 最佳实践

  • 在失败是正常且预期中的情况(如表单输入、第三方 API 响应)时,使用 safeParse;而将会抛出异常的 parse 保留给那些确实永远不应出现无效情况的场景,例如在启动时检查的环境变量。
  • 当同时拥有前端和后端时,应将共享的架构保存在同一个包中,这样就不必维护同一规则的两份副本。
  • 在验证过程中直接使用.transform()来清理数据——如去除空白字符、转换数据类型——而非事后再进行单独的规范化处理。
  • 对于已有架构支持的任何内容,优先使用z.infer而非手动编写的接口,这样类型定义与验证逻辑就不会出现不一致。
  • 在API错误响应中返回error.flatten()error.format(),以便前端代码能够轻松地将每个错误对应到相应的表单字段。
  • 7. 结论

    Zod 不仅仅是一个普通的验证库——它彻底改变了验证与类型定义之间的关系。通过直接从运行时架构生成 TypeScript 类型,它消除了类型定义与验证规则出现分歧的整个问题。再加上其极小的体积、可组合的设计,以及无论在浏览器还是 Node 环境中都能保持一致的行为表现,Zod 显然非常适合用于基于 React 和 Node.js 构建的全栈 TypeScript 项目。

    下一步操作:

    • 如果需要直接从架构生成 OpenAPI 文档,可以了解一下 zod-to-openapi
    • 若要构建涉及多个字段的自定义验证逻辑,可探索 .refine().superRefine() 函数
    • 还可以试试 tRPC,它基于 Zod 架构原生实现,能为整个 API 提供端到端的类型安全保障

    相关阅读

  • TypeScript 6的隐性优势与资深开发者的JavaScript使用习惯 — 了解TypeScript 6中那些常被忽视的功能,如显式的资源管理机制和const类型参数,以及资深工程师日常使用的JavaScript惯用技巧。
  • req-guard-lite:专为Express设计的轻量级TypeScript限流工具 — 了解这款无需额外依赖的轻量级Express限流器的工作原理,从内存中的默认设置到基于Redis的扩展功能以及自定义键生成方式。