跳到主要内容

63-实战篇 Zod 与数据校验

前言

前面讲的 Clerk、Crowdin、Storybook 都可以不学,但 Zod 几乎是开发 Next.js 项目的必学内容,因为大部分的全栈项目都会有数据校验的场景。而 Next.js 官方文档推荐的正是 Zod

目前 Zod GitHub 31.5k Star,Npm 周均下载量 784W,几乎是前端做数据校验的第一选择。

本篇带大家快速上手 Zod。

Zod 介绍

1. 基础介绍

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。

2. 运行时校验

那你可能就好奇了,不都是数据校验,我都有 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 并不知道:

image.png

我们当然可以补全类型声明:

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 });
}
}

整体代码更加简洁优雅,而且你也不需要再写类型声明:

image.png

先对接口返回的数据进行校验,通过后再进行后续操作,从源头上防止数据不合法而导致的错误,提高应用的稳定性,而且还能帮助 TypeScript 进行推断,使用起来非常方便。

3. 如何学习 Zod

那具体如何学习 Zod 呢?就我个人看法,学习 Zod 的最好方法就是看 Zod 的官方文档:

  1. 英文:https://zod.dev/
  2. 中文:https://zod.dev/README_ZH

内容并不算多, 20 分钟就可以看个大概。首要目的是了解 Zod 有哪些功能,具体要用的时候边查文档边学习。

如果文档看不下去,这是一个 30 分钟学习 Zod 的 Youtube 视频

Next.js + Zod

具体 Zod 在 Next.js 中如何使用呢?我们先举个简单的使用示例。

1. 纯后端数据校验

运行 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: '设置成功'
}
}

浏览器效果如下:

17.gif

2. 前后端数据校验

我们知道,在实际开发中,前后端数据校验都是必须的,前端校验是为了避免浪费后端资源,后端校验是因为不能相信来自客户端的数据。所以往往同样的校验,前后端都需要做一次。这就需要将 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>

此时浏览器效果如下:

18.gif 注意:当数据为空进行提交的时候,走的是浏览器本身的非空校验,当数据不为空提交的时候,才是 Zod 的数据校验。

至此我们就完成了一个前后端校验的例子,当然这个例子还可以做的更完善一点,比如:

  1. 将 actions.js、type.ts 抽离到单独的文件夹下,使用 alias 地址引入
  2. 使用 useFormStatus 添加按钮状态
  3. 使用 useOptimistic 实现乐观更新