前面讲的 Clerk、Crowdin、Storybook 都可以不学,但 Zod 几乎是开发 Next.js 项目的必学内容,因为大部分的全栈项目都会有数据校验的场景。而 Next.js 官方文档推荐的正是 Zod。
目前 Zod GitHub 31.5k Star,Npm 周均下载量 784W,几乎是前端做数据校验的第一选择。
本篇带大家快速上手 Zod。
Zod 是一个 TypeScript 优先(TypeScript-first)的模式声明(schema declaration)和验证库(validation library)。
第一次听到这个介绍可能会“不明觉厉”,但其实很简单,举个简单的例子:
import { z } from "zod";
// 模式声明
const schema = z.string();
// 数据校验
schema.parse("tuna"); // => "tuna"
schema.parse(12); // => throws ZodError
这就是一个基本的模式声明和数据验证的例子。那什么是 TypeScript 优先呢?
简单来说,就是和 TypeScript 搭配使用,效果更佳。Zod 的目的在于消除重复的类型声明。使用 Zod,你只需声明一次验证器(validator),Zod 就会自动推断出静态 TypeScript 类型。细看 Zod 的 API,你会发现 Zod 与 TypeScript 的类型系统几乎是一对一的映射。
import { z } from "zod";
// 模式声明
const User = z.object({
username: z.string(),
});
// 数据校验
User.parse({ username: "Ludwig" });
// 提取推断类型
type User = z.infer<typeof User>;
// { username: string }
注意:但这并不是说使用 Zod 就一定要使用 TypeScript,Zod 也可用于纯 JavaScript。
那你可能就好奇了,不都是数据校验,我都有 TypeScript 了,用 Zod 干嘛?
简单来说,TypeScript 是静态类型检查,但 Zod 不仅能在编译时提供类型检查,还能在运行时进行数据校验。这样就可以从源头上防止数据不合法而导致的错误,提高应用的稳定性。
举个例子,我们调用接口,获取返回的数据并进行处理:
export async function GET() {
const res = await fetch("/api/product");
const data = await res.json();
const showPrice = data.price.toFixed(2);
return Response.json({ showPrice });
}
在这段代码中,data 肯定会被推断为 any,因为 data 是运行时返回的数据,TypeScript 并不知道:
我们当然可以补全类型声明:
type Product = {
price: number;
};
export async function GET() {
const res = await fetch("/api/product");
const data = await res.json() as Product;
const showPrice = data.price.toFixed(2);
return Response.json({ showPrice });
}
现在 price 字段声明了数字类型,如果我们使用了字符串的方法, TypeScript 就会报错。
但问题在于,即便我们不使用,但接口的返回数据类型突然改了呢?比如本来是 Number 类型,后端改为了 String 类型?因为 String 类型没有 toFixed 方法,那这段代码运行的时候就会报错。
为了防止运行时产生问题,我们还需要做判断,比如:
type Product = {
price: number;
};
export async function GET() {
const res = await fetch("/api/product");
const data = await res.json() as Product;
if (data && data.price && typeof data.price == 'number') {
const showPrice = data.price.toFixed(2);
return Response.json({ showPrice });
} else {
return Response.json({ success: false });
}
}
如果涉及的字段众多,每个字段都写一段校验,代码很快就会变得臃肿难以维护。
而 Zod 正好可以解决这一问题,使用 Zod 后,代码改为:
import { z } from 'zod';
const schema = z.object({
price: z.number()
});
export async function GET() {
const res = await fetch("/api/product");
const data = await res.json();
const parsedData = schema.safeParse(data)
if (parsedData.success) {
const showPrice = parsedData.data.price.toFixed(2);
return Response.json({ showPrice });
} else {
return Response.json({ success: false });
}
}
整体代码更加简洁优雅,而且你也不需要再写类型声明:
先对接口返回的数据进行校验,通过后再进行后续操作,从源头上防止数据不合法而导致的错误,提高应用的稳定性,而且还能帮助 TypeScript 进行推断,使用起来非常方便。
那具体如何学习 Zod 呢?就我个人看法,学习 Zod 的最好方法就是看 Zod 的官方文档:
内容并不算多, 20 分钟就可以看个大概。首要目的是了解 Zod 有哪些功能,具体要用的时候边查文档边学习。
如果文档看不下去,这是一个 30 分钟学习 Zod 的 Youtube 视频。
具体 Zod 在 Next.js 中如何使用呢?我们先举个简单的使用示例。
运行 npx create-next-app@latest
初始化项目。
修改 app/page.js
,代码如下:
'use client'
import { validate } from './actions';
import { useFormState } from 'react-dom'
export default function Page() {
const [state, formAction] = useFormState(validate, {
message: ''
})
return (
<section className="p-2">
<form action={formAction} className="mb-2">
<label htmlFor="name" className="block text-sm font-medium leading-6 text-gray-900">
Name:
</label>
<input
id="name"
name="name"
type="text"
required
className="block w-full rounded-md border-0 p-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 mb-2"
/>
<button
type="submit"
className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
Login
</button>
</form>
<p aria-live="polite">
{state?.message}
</p>
</section>
);
}
新建 app/actions.js
,代码如下:
'use server'
import { z } from "zod";
const schema = z.object({
name: z.string({
required_error: "请输入昵称",
invalid_type_error: "昵称必须是字符串",
}).min(2, { message: "昵称最少需要 2 个字" }).max(10, { message: '昵称最多 10 个字'})
});
export async function validate(prevState, formData) {
const validatedFields = schema.safeParse({
name: formData.get('name'),
})
if (!validatedFields.success) {
return {
success: false,
message: validatedFields.error.issues[0].message
}
}
return {
success: true,
message: '设置成功'
}
}
浏览器效果如下:
我们知道,在实际开发中,前后端数据校验都是必须的,前端校验是为了避免浪费后端资源,后端校验是因为不能相信来自客户端的数据。所以往往同样的校验,前后端都需要做一次。这就需要将 Schema 抽离出来,方便前后端使用。
新建 app/todo/page.js
,代码如下:
import { findTodos } from './actions';
import Form from './form';
export default async function Page() {
const todos = await findTodos()
return (
<section className="p-2">
<Form />
<ul>
{todos.map((todo) => {
return <li>{todo}</li>
})}
</ul>
</section>
);
}
这里我们将 Form 抽离为单独的组件,是因为 Form 需要使用客户端组件,本着尽可能减少客户端组件范围的原则,所以抽离成了单独的组件。
新建 app/todo/form.js
,代码如下:
'use client'
import { useRef } from 'react';
import toast from 'react-hot-toast';
import { addTodo } from './actions';
import { TodoSchema } from "./types";
export default function Form() {
const formRef = useRef(null);
const clientAction = async (formData) => {
const submitTodo = {
content: formData.get('todo')
}
// 客户端校验
const result = TodoSchema.safeParse(submitTodo)
if (!result.success) {
toast.error(result.error.issues[0].message)
return;
}
// 提交给服务端
const response = await addTodo(result.data)
if (response?.error) {
toast.error(response.error)
return;
}
formRef.current?.reset()
}
return (
<form action={clientAction} className="mb-2" ref={formRef}>
<label htmlFor="todo" className="block text-sm font-medium leading-6 text-gray-900">
待办事项:
</label>
<input
id="todo"
name="todo"
type="text"
required
className="block w-full rounded-md border-0 p-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 mb-2"
/>
<button
type="submit"
className="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
添加
</button>
</form>
);
}
这段代码并不复杂,唯独要注意一点,那就是我们提交给服务端的数据,并不是我们自己构建的 submitTodo
,而是校验后的 result.data
,这是不一样的。举个例子:
// Schema 只定义了 content 字段
const TodoSchema = z.object({
content: z.string()
})
// 校验的时候多提交了字段
const submitTodo = {
content: formData.get('todo'),
other: '111'
}
// 数据校验成功
const result = TodoSchema.safeParse(submitTodo)
// 但 result.data 只会包含 content 字段
console.log(result.data) // -> {content: '123'}
再举个例子:
// coerce 是为了进行强制转换原始类型
const TodoSchema = z.object({
content: z.coerce.string()
})
// 数据是数字类型
const submitTodo = {
content: 123
}
// 数据校验成功
const result = TodoSchema.safeParse(submitTodo)
// content 字段会转为字符串类型
console.log(result.data) // -> {content: '123'}
简而言之,作为习惯,尽可能使用校验后的数据。
新建 app/todo/actions.js
,代码如下:
"use server"
import { revalidatePath } from "next/cache";
import { TodoSchema } from "./types";
const todos = [];
export async function addTodo(todo) {
// 服务端校验
const result = TodoSchema.safeParse(todo)
if (!result.success) {
return {
error: result.error.issues[0].message
}
}
// 模拟数据库操作
todos.push(result.data.content)
revalidatePath("/todos")
}
export async function findTodos() {
return todos
}
新建 app/todo/types.ts
,代码如下:
import { z } from 'zod';
export const TodoSchema = z.object({
content: z.string().trim().min(2, { message: '最少需要输入 2 个字符'}).max(10, { message: '最多输入 10 个字符'})
})
export type Todo = z.infer<typeof TodoSchema>
此时浏览器效果如下:
注意:当数据为空进行提交的时候,走的是浏览器本身的非空校验,当数据不为空提交的时候,才是 Zod 的数据校验。
至此我们就完成了一个前后端校验的例子,当然这个例子还可以做的更完善一点,比如: