在开发过程中,我们可以通过TypeScript来对我们的代码来进行类型校验来防止我们的代码出现类型错误,但是运行时的数据我们没法通过TS类型来处理,当遇到表单等动态数据需要校验时,就需要用到运行时类型校验工具了。

JoiZod是两款常见的运行时类型校验工具,下面我们来对比一下两款工具的使用差异。

Joi

Joi 是一款强大的数据验证库,最初为 Hapi.js 框架开发,后广泛应用于各类 JavaScript 项目。它以简洁、直观的语法描述数据结构与验证规则,使数据校验轻松实现。

基本使用

  1. 创建验证模式

通过组合 Joi 提供的类型和规则构建验证模式。例如,验证用户名和密码:

import Joi from 'joi';
const schema = Joi.object({
  username: Joi.string().alphanum().min(3).max(30).required(),
  password: Joi.string().pattern(new RegExp('^(a-zA-Z0-9){3,30}$'))
});

上述代码中,username必须为 3 - 30 位的字母数字组合且必填;password需匹配指定正则表达式。

  1. 验证数据

使用validate方法验证数据,返回包含错误信息和验证后数据的对象:

const { error, value } = schema.validate({ username: 'abc', password: '123' });
if (error) {
  console.error(error.details);
} else {
  console.log(value);
}

若验证通过,error为undefined;否则,error包含详细错误信息。

常用API

基础类型校验

  • Joi.string(): 字符串类型校验
  • Joi.number(): 数字类型校验
  • Joi.boolean(): 布尔类型校验
  • Joi.object(): 对象类型校验
  • Joi.array(): 数组类型校验
  • Joi.date(): 日期类型校验
  • Joi.null(): null 类型校验
  • Joi.any(): 任意类型校验

字符串常用约束

  • .min(length): 最小长度限制
  • .max(length): 最大长度限制
  • .required(): 设为必填项
  • .email(): 校验邮箱格式
  • .pattern(regExp): 正则匹配校验
  • .alphanum(): 仅允许字母和数字
  • .trim(): 自动去除首尾空格
  • .valid(...values): 枚举值校验(必须是指定值之一)
  • .invalid(...values): 排除指定值

数字常用约束

  • .min(value): 最小值限制
  • .max(value): 最大值限制
  • .integer(): 必须为整数
  • .positive(): 必须为正数
  • .negative(): 必须为负数
  • .precision(digits): 限制小数位数

对象常用约束

  • .keys(schema): 定义对象字段及校验规则
  • .requiredKeys(...keys): 指定必填字段
  • .optionalKeys(...keys): 指定可选字段
  • .unknown(false): 禁止出现未定义的字段
  • .when(condition, options): 条件校验(根据其他字段值动态调整规则)

数组常用约束

  • .items(schema): 定义数组元素的校验规则
  • .min(length): 数组最小长度
  • .max(length): 数组最大长度
  • .unique(): 数组元素必须唯一
  • .has(schema): 数组必须包含符合指定规则的元素

校验与错误处理

  • .validate(value): 执行校验,返回包含errorvalue的对象
  • .validateAsync(value): 异步校验(支持自定义异步规则)
  • .messages(customMessages): 自定义错误提示信息
  • error.details: 校验失败时的详细错误信息数组

代码实战

例如在页面中集成 Joi 进行表单验证。假设创建一个用户注册表单:

import React, { useState } from'react';
import Joi from 'joi';

const userSchema = Joi.object({
  username: Joi
    .string()
    .alphanum()
    .min(3)
    .max(30)
    .required()
    .messages({
      'string.base': '用户名必须是字符串',
      'string.empty': '用户名不能为空',
      'string.min': '用户名至少需要3个字符',
      'string.max': '用户名最多30个字符',
      'string.alphanum': '用户名只能包含字母和数字'
    }),
  email: Joi
    .string()
    .email()
    .required()
    .messages({
      'string.base': '邮箱必须是字符串',
      'string.empty': '邮箱不能为空',
      'string.email': '邮箱格式不正确'
    }),
  password: Joi
    .string()
    .pattern(new RegExp('^(?=.*[a - z])(?=.*[A - Z])(?=.*[0 - 9]).{8,}$'))
    .required()
    .messages({
      'string.base': '密码必须是字符串',
      'string.empty': '密码不能为空',
      'string.pattern.base': '密码至少8个字符,且包含大小写字母和数字'
    }),
});

const RegisterForm = () => {
  const [formData, setFormData] = useState({ username: '', email: '', password: '' });
  const [errors, setErrors] = useState({});

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData({...formData, [name]: value });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    const result = await userSchema.validate(formData);
    if (result.error) {
      const errorObj = {};
      result.error.details.forEach(detail => {
        errorObj[detail.path[0]] = detail.message;
      });
      setErrors(errorObj);
    } else {
      console.log('表单提交成功:', result.value);
      setErrors({});
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>用户名:</label>
      <input type="text" name="username" onChange={handleChange} />
      {errors.username && <span style={{ color:'red' }}>{errors.username}</span>}
      <br />
      <label>邮箱:</label>
      <input type="email" name="email" onChange={handleChange} />
      {errors.email && <span style={{ color:'red' }}>{errors.email}</span>}
      <br />
      <label>密码:</label>
      <input type="password" name="password" onChange={handleChange} />
      {errors.password && <span style={{ color:'red' }}>{errors.password}</span>}
      <br />
      <button type="submit">提交</button>
    </form>
  );
};

export default RegisterForm;

上述代码中,每次输入框内容改变时更新formData。提交表单时,用userSchema验证formData,若有错误,将错误信息存储在errors对象中并展示在对应输入框下方。

Zod

Zod 是 TypeScript-first 的数据验证库,也能在 JavaScript 项目中良好使用。它基于 TypeScript 类型系统构建,提供类型安全且简洁的验证语法。

基本使用

  1. 创建验证模式

与 Joi 类似,通过组合 Zod 的类型和方法定义验证模式:

import { z } from 'zod';

const schema = z.object({
  username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9]+$/),
  password: z.string().min(8).regex(/^(?=.*[a - z])(?=.*[A - Z])(?=.*[0 - 9])/)
});

这里username是 3 - 30 位字母数字组合,password至少 8 位且包含大小写字母和数字。

  1. 验证数据

使用parse或safeParse方法验证数据:

const data = { username: 'abc', password: '123' };
try {
  const result = schema.parse(data);
  console.log(result);
} catch (error) {
  console.error(error);
}

parse方法验证失败时抛出错误,safeParse则返回包含是否成功及错误信息的对象,适合需更灵活处理错误的场景。

常用API

基础类型校验

  • z.string(): 字符串类型校验
  • z.number(): 数字类型校验
  • z.boolean(): 布尔类型校验
  • z.object(): 对象类型校验
  • z.array(): 数组类型校验
  • z.date(): 日期类型校验
  • z.null(): null 类型校验
  • z.unknown(): 未知类型校验

字符串常用约束

  • .min(length, message?): 最小长度限制
  • .max(length, message?): 最大长度限制
  • .email(message?): 校验邮箱格式
  • .regex(regExp, message?): 正则匹配校验
  • .trim(): 自动去除首尾空格
  • .includes(substring, message?): 必须包含指定子串
  • .enum(values): 枚举值校验(如z.enum(['a', 'b'])

数字常用约束

  • .min(value, message?): 最小值限制
  • .max(value, message?): 最大值限制
  • .int(message?): 必须为整数
  • .positive(message?): 必须为正数
  • .negative(message?): 必须为负数
  • .multipleOf(value, message?): 必须是指定值的倍数

对象常用约束

  • .shape(fields): 定义对象字段及校验规则(如z.object({ name: z.string() })
  • .required(): 设为必填项(针对对象字段)
  • .optional(): 设为可选项(针对对象字段)
  • .strict(): 严格模式(禁止类型自动转换)
  • .passthrough(): 允许传递未定义的字段
  • .refine(condition, options): 自定义校验逻辑

数组常用约束

  • .element(schema): 定义数组元素的校验规则
  • .min(length, message?): 数组最小长度
  • .max(length, message?): 数组最大长度
  • .nonempty(message?): 数组不能为空
  • .unique(message?): 数组元素必须唯一

类型转换与组合

  • .optional(): 类型设为可选(可能为undefined
  • .nullable(): 类型设为可空(可能为null
  • .default(value): 设置默认值
  • .or(schema): 联合类型(如z.string().or(z.number())
  • .and(schema): 交叉类型
  • .transform(fn): 数据转换(校验后修改值)

校验与错误处理

  • .parse(value): 执行校验,失败时抛出错误
  • .safeParse(value): 安全校验,返回{ success: boolean, data?: T, error?: ZodError }
  • .parseAsync(value): 异步校验(支持异步refine
  • z.infer<typeof schema>: 从 schema 推导 TypeScript 类型

代码实战

在页面上使用 Zod 验证表单。以用户登录表单为例:

import React, { useState } from'react';
import { z } from 'zod';

const loginSchema = z.object({
  username: z
    .string()
    .min(3, { message: '用户名至少需要3个字符' })
    .max(12, { message: '用户名最多12个字符' })
    // .nonempty({ message: '用户名不能为空' })
    .regex(/^[a-zA-Z0-9]+$/, { message: '用户名只能包含字母和数字' }),
  password: z
    .string()
    .min(8, { message: '密码至少8个字符' })
    .max(16, { message: '密码不能超过16个字符' })
    .regex(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/, { message: '密码至少8个字符,且包含大小写字母和数字' }),
});

const LoginForm = () => {
  const [formData, setFormData] = useState({ username: '', password: '' });
  const [errors, setErrors] = useState({});

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData({...formData, [name]: value });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    const result = loginSchema.safeParse(formData);
    if (!result.success) {
      const errorObj = {};
      result.error.issues.forEach(issue => {
        errorObj[issue.path[0]] = issue.message;
      });
      setErrors(errorObj);
    } else {
      console.log('表单提交成功:', result.data);
      setErrors({});
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>用户名:</label>
      <input type="text" name="username" onChange={handleChange} />
      {errors.username && <span style={{ color:'red' }}>{errors.username}</span>}
      <br />
      <label>密码:</label>
      <input type="password" name="password" onChange={handleChange} />
      {errors.password && <span style={{ color:'red' }}>{errors.password}</span>}
      <br />
      <button type="submit">提交</button>
    </form>
  );
};

export default LoginForm;

该表单中,每次输入改变更新formData。提交时用loginSchema的safeParse方法验证,若失败,将错误信息存入errors并展示在对应输入框旁。

差异对比

语法风格

  • Joi:语法更接近自然语言,描述规则直观,对习惯 JavaScript 传统语法开发者友好。如Joi.string().alphanum().min(3).max(30).required()定义用户名规则。

  • Zod:基于 TypeScript 类型系统,语法简洁紧凑,类型标注感强,熟悉 TypeScript 的开发者易上手。如z.string().min(3).max(30).regex(/^[a-zA-Z0-9]+$/)定义用户名规则。

类型支持

  • Joi:原生 JavaScript 库,虽无内置 TypeScript 支持,但可通过社区类型定义文件(如@types/joi)在 TypeScript 项目中使用,不过类型推断和检查可能不如 Zod 直接和精确。

  • Zod:专为 TypeScript 设计,紧密结合 TypeScript 类型系统,定义验证模式时自动推导类型,在 TypeScript 项目中提供出色类型安全和智能提示,降低类型相关错误风险。

错误处理

  • Joi:validate方法返回包含error和value的对象,error是ValidationError对象,details属性包含详细错误信息数组,需手动遍历处理,如:
const { error, value } = schema.validate({ username: '', password: '123' });
if (error) {
  error.details.forEach(detail => {
    console.error(detail.message);
  });
}
  • Zod:parse方法验证失败抛错,safeParse方法返回包含success、data(验证后数据)和error(ZodError对象)的对象,ZodError的issues属性是错误信息数组,处理更灵活直观,如:
const result = schema.safeParse({ username: '', password: '123' });
if (!result.success) {
  result.error.issues.forEach(issue => {
    console.error(issue.message);
  });
}

生态与社区

  • Joi:历史悠久,社区成熟,广泛应用于 Node.js 后端项目,有丰富插件和资源,在 Express、Hapi 等框架中常作数据验证工具。但在纯前端 React 项目中,可能因后端导向特性,使用便捷性逊于部分前端专属校验库。

  • Zod:较新,专注前端和 TypeScript 生态,在 React、Vue 等前端框架中受青睐,尤其 TypeScript 项目。其社区活跃,文档完善,不断更新迭代,积极适配前端开发新趋势和需求。


前端小白