在 Web 开发中,所有来自外部的数据都不可信——这是安全编码的第一条铁律。前端表单校验只能提升用户体验,真正保护数据的防线必须在服务端。没有参数校验的接口,就像没有门禁的机房:异常类型、非法值、恶意注入都可能长驱直入,轻则产生脏数据,重则导致服务崩溃。
Node.js 生态提供了一类优雅的解决方案:声明式校验。开发者只需描述数据的“合法形状”,库就会自动完成校验、类型转换和错误提示,而不用手写大量 if/else 或 typeof 判断。
目前社区中最主流的选择就是 Joi 和 Zod。下面我们分别介绍两者的核心用法、设计理念的差异,以及在实际项目中的落地实践。
为什么需要声明式校验?
手工校验一个接口的输入往往是这样:
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位' });
}
// ... 更多判断
});
这种写法有三个明显问题:
- 重复劳动:每个接口都要写一整套判断,逻辑高度相似却无法复用。
- 可读性差:业务逻辑和校验逻辑混杂在一起,难以维护。
- 错误信息不统一:每个开发者写的错误格式可能不一样,前端处理起来很痛苦。
声明式校验的核心思想是:定义数据结构(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: ... }),例如当role为admin时才要求某些字段。 - 自定义校验:
.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);
});
同理,可以扩展为校验 query、params 或多个部分的组合 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();
};
}
这样的设计让校验规则与业务代码完全解耦,修改校验逻辑不会触碰核心流程,测试也变得更容易。
常见陷阱与最佳实践
- 始终使用校验后的数据(
value/result.data):
Joi 和 Zod 都可能对输入值进行转换(如字符串转数字),使用原始 req.body 可能导致类型不一致。
- 不要相信来自客户的任何数据:
即使前端已经校验,攻击者可以直接构造 HTTP 请求,所以后端校验是底线。
- 错误信息不要暴露系统细节:
避免返回堆栈信息或 SQL 错误,应该统一格式,如 { field: 'email', message: '格式不正确' },方便前端映射到具体表单控件。
- 合理设置
stripUnknown(Joi)或 Zod 默认行为:
Zod 默认会忽略未知属性,除非你使用 .strict();Joi 默认允许未知属性,可通过 allowUnknown: false 禁止。根据安全策略调整。
- 复杂对象校验时,注意递归 schema 的性能:
大规模嵌套数组校验可能带来性能开销,如果遇到瓶颈,可以考虑分层校验或使用 passthrough 减少嵌套。
- 将 Schema 定义集中管理:
在 schemas/ 目录下统一维护所有接口的校验 Schema,既方便复用,也方便自动化生成 API 文档(如配合 Swagger)。
参数校验是接口健壮性的基石。选择一种声明式校验方案,并严格执行“所有输入必须先校验后处理”的纪律,可以排除一大类隐蔽的 Bug 和安全漏洞。在 Joi 的沉稳与 Zod 的灵动之间,根据团队技术栈和偏好做出选择,很快你就会觉得手写 if (!req.body.name) 的日子已经一去不复返了。