不要信任你的后端
zod 是什么?
官网上的解释说:
Zod 是一个以 TypeScript 为首的模式声明和验证库。我使用术语 "schema" 来广义地指任何数据类型,从简单的
字符串
到复杂的嵌套对象。
第一次使用 zod 是因为 trpc 在官网中介绍通过 zod 来进行接口校验。对于用惯了 TypeScript 的人来说,第一反应应该是:我都有 TypeScript 了,为什么还要一个库来做校验?
举一个例子,我们有一个名为 /api/get-product
的接口,使用 fetch 来获取:
javascript
fetch('/api/get-product').then(res => res.json()).then((res) => {
// res 被推断为 any
})
这里的 res 被推断为 any,因为 res 是运行时产生的, TypeScript 并不知道 res 是什么。
我们当然可以为它补全类型声明,比如:
typescript
type Product = {
detail: {
name: string;
price: number;
};
}
// 为 res 加上类型声明
fetch('/api/get-product').then(res => res.json()).then((res: Product) => {
console.log(res.detail.price.toFixed(2)) // 👍
})
这段代码看似没什么问题,但在现实世界中,提供接口的人可能和前端是两个团队,如果有一天,你的后端团队突然把类型改了,price
改成了字符串类型,你的代码会怎么样呢?
Boom!🤯
TypeError: res.detail.price.toFixed is not a function
你收到了一个运行时报错!
用可选链进行防御式编程?
TypeScript 最终还是会被编译成为 javascript,它只能进行编译时检查,运行时的报错它就无能为力了。
经过上面的问题,你开始不信任后端,我们来进行防御式编程!
有一招很好用的方法叫做可选链,比如这样:
javascript
fetch('/api/get-product').then(res => res.json()).then((res: Product) => {
console.log(res?.detail?.price?.toFixed(2)) // 可行吗?
})
可选链可以避免某些字段为空,但是当字段类型改变时,显得有些无能为力。正确的做法是,把 res 的类型声明为 unknown,然后依照 TypeScript 的报错做严格的类型检查,比如这样:
js
fetch('/api/get-product').then(res => res.json()).then((res: unknown) => {
if (typeof res === 'object' && res !== null && 'detail' in res) {
if (typeof res.detail === 'object' && res.detail !== null && 'price' in res.detail) {
if (typeof res.detail.price === 'number') {
console.log(res.detail.price.toFixed(2))
}
}
}
})
🤯
看起来很健壮了,但是这段代码实在是又臭又长!
zod 就是用来解决这种场景,让我使用 zod 来进行运行时校验!
使用 zod 进行接口校验
zod 使用 schema 来定义验证规则,我们创建一个 schema 来表示上面的接口:
ts
const productSchema = z.object({
detail: z.object({
name: z.string(),
price: z.number(),
})
})
通过工具方法 infer
可以将 schema 转化为 TypeScript 类型声明:
ts
type Product = z.infer<typeof productSchema>
使用 parse 方法来进行校验,我们改写上面的代码:
ts
fetch('/api/get-product').then(res => res.json()).then((res) => {
const product = productSchema.parse(res)
console.log(product.detail.price.toFixed(2)) // 👍
})
如果类型不匹配,这个时候 zod 会报错,比起上面的 TypeError: res.detail.price.toFixed is not a function
,zod 的报错会更明确:
... Expected number, received string
如果在遇到异常时不想中断代码,可以用 safeParse
做更精细的流程控制:
ts
fetch('/api/get-product').then(res => res.json()).then((res) => {
const product = productSchema.safeParse(res)
if (product.success) {
console.log(product.data.detail.name)
console.log(product.data.detail.price)
} else {
console.error(product.error)
}
})
代码优雅了很多!
以上是 zod 最常用的 API,除了接口校验,让我们看看还有哪些场景适合用到 zod。
表单校验
表单校验也是使用 zod 的主要场景,用户的输入需要在运行时进行检查,zod 内置了一些方法可以对数据进行更精细的校验,比如:
- email: 邮箱校验;
- min / max: 校验最大最小值;
- message: 使用验证方法时,你可以传递一个附加参数,以提供自定义错误信息;
- refine: 自定义验证函数,做精细化控制。
比如下面登录相关的 schema:
css
const signUpSchema = z.object({
email: z.string().email(),
password: z.string().min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
这段代码的描述了这样的校验规则:
signUpSchema 定义了一个对象,该对象包含三个属性:email,password 和 confirmPassword。
- email:字符串,并且必须是一个有效的电子邮件地址;
- password:字符串,但是它必须至少包含6个字符。如果密码长度小于6个字符,将返回错误消息'Password must be at least 6 characters';
- confirmPassword:字符串;
- refine 方法用于添加一个自定义验证。确保 password 和 confirmPassword 两个字段的值是相同的。如果这两个字段的值不匹配,将返回错误消息 'Passwords do not match',并指出错误发生在confirmPassword 字段。
URL 和 ENV 校验
URL 和 ENV 和上面的例子很类似,一个区别是,URL 和 ENV 在默认情况下是字符串,zod 提供了一种方便的方法 zod.coerce
来强制转换原始类型,比如如下 ENV schema:
ts
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'production']),
PORT: z.coerce.number(),
HOST: z.string(),
})
type Env = z.infer<typeof EnvSchema>
这里的 PORT
被转换为了 number。
包体积太大?试试 valibot
以上介绍了 zod 的大部分使用场景。
有一问题是,zod 的官网介绍 zod 是一个小巧的库,大概 8kb。但是,一般情况下你也用不着 zod 的全部特性,引入一个 8kb 的库是值得的吗?🤔
如果你特别在意包体积的话,或许可以试试 valibot?
看看 valibot 对自己的介绍:
Valibot 的功能与 zod 非常相似。最大的区别是我们的API的 模块化设计 以及通过 tree-shaking 和代码分割将捆绑尺寸降低到最小的能力。根据模式,Valibot可以将捆绑尺寸降低到95%,而与ZOD相比。特别是对于客户端验证表单,这可能是一个很大的优势。
我们改写上面的表单校验:
ts
import * as v from 'valibot';
const Schema = v.object({
email: v.string([v.email()]),
password: v.string([v.minLength(6, 'Password must be at least 6 characters')]),
confirmPassword: v.string(),
}, [v.custom(({ password, confirmPassword }) => password === confirmPassword, 'Passwords do not match')]);
const result = v.parse(Schema, {
email: 'jane@example.com',
password: '12345678',
confirmPassword: '12345678',
});
console.log(result);
你可以将这段代码贴在 playground 中验证。
从 api 设计上来讲,valibot 使用了按需引入的 api 设计模型,所以能做到使用 tree-shaking 降低代码的包体积。
你更喜欢哪一种 api 设计呢?
感谢阅读本文,欢迎讨论~