各位同学,晚上好!我是你们的老朋友,今天要跟大家聊聊一个痛彻心扉的话题——验证。
如果在座的各位开发人员这辈子没在“校验逻辑”上掉过头发,那你绝对是个新手,或者你的人生太顺遂了。想象一下,你正在开发一个注册页面,你在前端写了校验规则:密码不能少于6位,邮箱格式要对。用户填错了,你弹个窗提示。用户点了“注册”,你的前端代码一秒钟发起了请求,然后呢?
然后后端收到数据了,后端的代码里是不是还得再写一遍校验?如果后端没写,或者写错了,或者后端写的规则和前端不一致(比如前端说6位,后端说8位),结果会怎么样?没错,垃圾数据进入数据库。这就是我们要面对的“验证地狱”。
今天,我们要用一种极其优雅、极其现代的方式来终结这种地狱。我们要请出今天的男主角——Zod。我们要打造一种前后端完全同构的验证体系。
第一部分:前端校验是门面,后端校验是保镖
首先,我们要搞清楚一个哲学问题:为什么我们需要校验?
以前我们觉得校验是为了“用户体验”。用户填错了,前端立马报错,别让他提交。这没错,这是“保镖”的角色。但还有一层更重要的角色是“保安”,那就是后端。
如果只靠前端校验,你可以把它想象成你在街上卖烤红薯,你在摊位上放了个大牌子:“烤红薯五块一斤,一口一个”。这叫前端校验。然后有一群黑客骑着摩托车冲过来,把箱子里的红薯换成了石头,然后收了你五块。这时候你的牌子就没用了。
后端校验就是那个摄像头。不管你怎么换石头,不管你车技多好,摄像头都在那拍着呢。所以,后端校验是必须的,而且必须和前端规则一致。
这就是我们今天要解决的问题:如何让前端和后端共享这一套“真理”般的规则?
第二部分:Zod 是什么?它不仅仅是个库
在 Zod 出现之前,我们用什么?Joi?Yup?它们很棒,但它们是“JS 原生”的。它们虽然能验证,但很难优雅地生成 TypeScript 类型。而在现代开发中,我们离不开 TypeScript,我们不需要写两套类型定义。
Zod 的出现,简直就是前端界的“瑞士军刀”,也是后端界的“神来之笔”。它的核心哲学非常简单:Schema is truth(模式即真理)。
Zod 的作者是 @colinhacks。他是个天才,他发现我们一直在重复做一件事:定义数据长什么样,然后验证它。 所以 Zod 允许你定义一个 Schema,这个 Schema 既是验证器,又是类型定义器。
第三部分:Schema 的定义——把“规则”变成代码
让我们从最基础的开始。假设我们要定义一个用户注册的 Schema。
在以前的代码里,你可能需要写:
- 前端:一个
interface User。 - 前端:
yup.string().email()这样的验证逻辑。 - 后端:一个
interface User。 - 后端:
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 的时候都运行正则表达式要高效得多。
第十四部分:总结与思考
回到最初的话题。为什么我们要追求前端和后端验证逻辑的同构?
- 防御深度: 前端是第一道防线,用于提升 UX。后端是最后一道防线,用于保障安全。如果这两道防线用的武器不一样,黑客就能找到漏洞。
- 开发效率: 修改一个字段,改一个地方,两边都生效。
- 类型安全: TypeScript 的类型提示贯穿始终,减少 bug。
Zod 让我们摆脱了“粘贴粘贴代码”的工匠时代,进入了“声明式编程”的时代。我们声明了数据长什么样,然后工具链自动帮我们生成验证器、类型定义和错误信息。
这就像你买了一把自动步枪,而 Zod 就是那个上膛的机械师。你只需要把目标(Schema)指给他看,剩下的交给子弹。
未来展望:
现在很多框架(如 Next.js, Nuxt, NestJS)都已经深度集成了 Zod。NestJS 甚至可以直接在 DTO 类上用装饰器配合 Zod,让后端开发也像前端一样优雅。
所以,下次当你看到同事在两个文件里写两个 if (value.length < 3) 的时候,请轻声地把 Zod 的代码推给他。告诉他:“兄弟,放过你自己吧,用 Zod 吧。”
祝大家代码无 Bug,验证不报错!