人人都会AI编程

14.2 参数校验:Joi / Zod 声明式校验方案

更新时间:2026-07-11

在 Web 开发中,所有来自外部的数据都不可信——这是安全编码的第一条铁律。前端表单校验只能提升用户体验,真正保护数据的防线必须在服务端。没有参数校验的接口,就像没有门禁的机房:异常类型、非法值、恶意注入都可能长驱直入,轻则产生脏数据,重则导致服务崩溃。

Node.js 生态提供了一类优雅的解决方案:声明式校验。开发者只需描述数据的“合法形状”,库就会自动完成校验、类型转换和错误提示,而不用手写大量 if/elsetypeof 判断。

目前社区中最主流的选择就是 JoiZod。下面我们分别介绍两者的核心用法、设计理念的差异,以及在实际项目中的落地实践。

为什么需要声明式校验?

手工校验一个接口的输入往往是这样:

app.post('/register', (req, res) => {
  const { username, password, email } = req.body;
  if (!username || typeof username !== 'string') {
    return res.status(400).json({ error: '用户名不能为空且必须为字符串' });
  }
  if (!password || password.length < 8) {
    return res.status(400).json({ error: '密码至少8位' });
  }
  // ... 更多判断
});

这种写法有三个明显问题:

  1. 重复劳动:每个接口都要写一整套判断,逻辑高度相似却无法复用。
  2. 可读性差:业务逻辑和校验逻辑混杂在一起,难以维护。
  3. 错误信息不统一:每个开发者写的错误格式可能不一样,前端处理起来很痛苦。

声明式校验的核心思想是:定义数据结构(Schema),交给库去校验,并返回标准化的错误结果。这样一来,接口逻辑变得干净,校验规则一目了然,还能生成 TypeScript 类型,实现编译期和运行时的双重保障。

Joi:久经考验的校验“重型武器”

Joi 是 hapi.js 生态中孵化出来的校验库,至今已有十余年历史,在 Express/Koa/NestJS 等框架中被广泛使用。它的特点是 API 链式调用,功能极为详尽,自定义能力强。

安装

npm install joi

定义 Schema
Joi 提供了各类方法来构建校验规则,从基础类型到复杂条件组合都覆盖:

const Joi = require('joi');

const registerSchema = Joi.object({
  username: Joi.string()
    .alphanum()            // 只允许字母数字
    .min(3)
    .max(30)
    .required(),

  password: Joi.string()
    .pattern(new RegExp('^[a-zA-Z0-9]{8,30}$'))
    .required(),

  repeat_password: Joi.ref('password'), // 指向 password 的值

  email: Joi.string()
    .email({ minDomainSegments: 2 })
    .required(),

  age: Joi.number()
    .integer()
    .min(18)
    .max(120)
    .optional(),
});

Joi.object() 表示校验一个对象,每个字段用对应类型的链式方法描述规则。Joi.ref 可以建立字段间的关联。

执行校验

const { error, value } = registerSchema.validate(req.body, {
  abortEarly: false,      // 返回所有错误,而不是遇到第一个就停止
  allowUnknown: false,    // 不允许未定义的字段
  stripUnknown: true,     // 自动剔除未定义字段
});

if (error) {
  return res.status(400).json({
    code: 400,
    message: '参数校验失败',
    details: error.details.map(d => ({
      field: d.path.join('.'),
      message: d.message,
    })),
  });
}
// 校验通过后,value 是经过转换和过滤后的干净数据
// 后续使用 value 而不是 req.body

常用校验方法

  • 类型强制转换:Joi.number() 会将字符串 "123" 自动转为数字 123。
  • 默认值:.default('default value')
  • 正则匹配:.pattern(regex)
  • 枚举值:.valid('a', 'b', 'c')
  • 条件校验:.when('field', { is: ..., then: ... }),例如当 roleadmin 时才要求某些字段。
  • 自定义校验:.custom((value, helpers) => { ... })

Joi 很适合规则复杂的场景,比如支付网关、管理后台,它们往往需要大量跨字段条件判断和业务逻辑紧密结合的校验。但 Joi 的包体积较大(约 150KB+),如果对包大小敏感,可以考虑按需引入或在 Lambda 等场景下替换。

Zod:TypeScript 原生优先的“轻量新秀”

Zod 是近年崛起的校验库,设计哲学与 Joi 不同:它以 TypeScript 的类型系统为核心,从 Schema 可以直接推导出 TypeScript 类型,无需重复声明。Zod 的 API 函数式风格更浓,整体更加轻量。

安装

npm install zod

定义 Schema

import { z } from 'zod';

const registerSchema = z.object({
  username: z.string()
    .min(3, '用户名至少3个字符')
    .max(30, '用户名不能超过30个字符')
    .regex(/^[a-zA-Z0-9]+$/, '用户名只能包含字母和数字'),

  password: z.string()
    .min(8, '密码至少8位')
    .max(30),

  confirmPassword: z.string(),

  email: z.string().email('邮箱格式不正确'),

  age: z.number().int().min(18).max(120).optional(),
}).refine(data => data.password === data.confirmPassword, {
  message: '两次密码不一致',
  path: ['confirmPassword'], // 指定错误关联的字段
});

z.object() 返回一个 Zod 对象,字段描述与 Joi 相似,但 Zod 的校验方法通常接受自定义错误消息字符串。refine 用于实现跨字段的复杂规则。

类型推导

type RegisterInput = z.infer<typeof registerSchema>;
// 直接获得与 Schema 完全一致的类型定义
// 无需手动写 interface

这消除了类型与校验逻辑之间的同步风险:修改 Schema,类型自动更新,编译器会指出所有不匹配的代码。

执行校验

const result = registerSchema.safeParse(req.body);
if (!result.success) {
  return res.status(400).json({
    code: 400,
    message: '参数校验失败',
    details: result.error.issues.map(issue => ({
      field: issue.path.join('.'),
      message: issue.message,
    })),
  });
}
// 校验通过后,result.data 的类型是 RegisterInput
// 可以直接使用,具备完整的类型提示

Zod 的 safeParse 返回一个结果对象,可以优雅地分支处理,避免 try-catch。也能用 parse() 直接抛出 ZodError,适合在统一错误处理中间件中捕获。

高级特性

  • 联合与交叉类型:z.union([A, B])z.intersection(A, B)
  • 字面量类型:z.literal('hello')
  • 枚举:z.enum(['a', 'b'])
  • 转换:.transform(val => val.trim())
  • 异步校验:.refine(async (val) => await checkExists(val)),不过需要配合 parseAsync
  • 递归结构:z.lazy(() => CategorySchema)

Joi vs Zod:选型建议

| 维度 | Joi | Zod |
|------|-----|-----|
| 包体积 | ~150kB+(gzip 约 50kB) | ~12kB(gzip 约 4kB) |
| TypeScript 集成 | 需要额外安装 @types/joi,类型推导不如 Zod 自然 | 一等支持,Schema → 类型无缝衍生 |
| API 风格 | 链式调用,方法名偏传统 | 函数式,贴近 TypeScript 习惯 |
| 功能丰富度 | 非常完备,包含日期、二进制等内置类型,自定义能力强 | 核心功能完善,复杂场景通过 refine/superRefine 实现 |
| 社区与生态 | 成熟,资料多,NestJS 官方默认校验库之一 | 增长迅速,被 tRPC、Next.js 社区广泛采用 |
| 错误消息 | 默认英文,可通过配置调整 | 支持直接在方法中传入字符串 |
| 自定义校验 | .custom() | .refine() / .superRefine() |

选择 Joi 的场景

  • 团队已有大量 Joi Schema 积累,历史项目延续。
  • 校验规则极度复杂(如根据配置动态生成规则)。
  • 不需要严格的 TypeScript 类型推导,或以 JavaScript 为主的项目。

选择 Zod 的场景

  • 项目使用 TypeScript,且希望用同一套 Schema 同时获得类型和校验。
  • 追求更小的打包体积(适用于前端、Serverless、库开发)。
  • 团队偏向现代 API 风格,喜欢函数式链式调用。

在实际项目中,两者都能胜任绝大多数校验需求,不必过度纠结。如果你的项目属于 NestJS 生态,class-validator 也是常见选项;但若需要跨框架、跨运行时(如前端也进行相同校验),Zod 凭借轻量和类型共享优势往往更胜一筹。

实战:将校验集成到 Express 中间件

无论选择哪个库,都应当将校验逻辑从控制器中抽离,形成可复用的校验中间件:

// validate.ts (Zod 版本)
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } 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({
        code: 400,
        message: '参数校验失败',
        details: result.error.issues,
      });
    }
    req.body = result.data; // 替换为经过清洗的数据
    next();
  };
}

// 路由中使用
app.post('/register', validate(registerSchema), (req, res) => {
  // req.body 类型已安全且干净
  const user = await userService.create(req.body);
  res.json(user);
});

同理,可以扩展为校验 queryparams 或多个部分的组合 Schema:

const searchSchema = z.object({
  query: z.object({
    keyword: z.string().min(1),
    page: z.string().optional().transform(Number).pipe(z.number().int().positive()),
  }),
  // 如果需要也能校验 params
});

// 中间件工厂稍作改造
export function validate(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    const parsed = schema.safeParse({ body: req.body, query: req.query, params: req.params });
    if (!parsed.success) { /* ... */ }
    Object.assign(req, parsed.data); // 覆盖对应字段
    next();
  };
}

这样的设计让校验规则与业务代码完全解耦,修改校验逻辑不会触碰核心流程,测试也变得更容易。

常见陷阱与最佳实践

  1. 始终使用校验后的数据(value / result.data

Joi 和 Zod 都可能对输入值进行转换(如字符串转数字),使用原始 req.body 可能导致类型不一致。

  1. 不要相信来自客户的任何数据

即使前端已经校验,攻击者可以直接构造 HTTP 请求,所以后端校验是底线。

  1. 错误信息不要暴露系统细节

避免返回堆栈信息或 SQL 错误,应该统一格式,如 { field: 'email', message: '格式不正确' },方便前端映射到具体表单控件。

  1. 合理设置 stripUnknown(Joi)或 Zod 默认行为

Zod 默认会忽略未知属性,除非你使用 .strict();Joi 默认允许未知属性,可通过 allowUnknown: false 禁止。根据安全策略调整。

  1. 复杂对象校验时,注意递归 schema 的性能

大规模嵌套数组校验可能带来性能开销,如果遇到瓶颈,可以考虑分层校验或使用 passthrough 减少嵌套。

  1. 将 Schema 定义集中管理

schemas/ 目录下统一维护所有接口的校验 Schema,既方便复用,也方便自动化生成 API 文档(如配合 Swagger)。

参数校验是接口健壮性的基石。选择一种声明式校验方案,并严格执行“所有输入必须先校验后处理”的纪律,可以排除一大类隐蔽的 Bug 和安全漏洞。在 Joi 的沉稳与 Zod 的灵动之间,根据团队技术栈和偏好做出选择,很快你就会觉得手写 if (!req.body.name) 的日子已经一去不复返了。