Appearance
Joi验证库
概述
Joi是一个强大的数据验证库,最初为Node.js设计,现在也可以在浏览器中使用。Joi提供了描述性的API和丰富的验证规则,虽然在React生态中不如Yup和Zod流行,但在某些场景下仍是优秀的选择。本文将介绍Joi在React表单验证中的应用。
安装和基础配置
安装
bash
# npm
npm install joi @hookform/resolvers
# yarn
yarn add joi @hookform/resolvers
# pnpm
pnpm add joi @hookform/resolvers基础使用
jsx
import { useForm } from 'react-hook-form';
import { joiResolver } from '@hookform/resolvers/joi';
import Joi from 'joi';
// 定义schema
const schema = Joi.object({
username: Joi.string().min(3).required().messages({
'string.min': '用户名至少3个字符',
'any.required': '用户名不能为空',
}),
email: Joi.string().email({ tlds: { allow: false } }).required().messages({
'string.email': '邮箱格式不正确',
'any.required': '邮箱不能为空',
}),
password: Joi.string().min(8).required().messages({
'string.min': '密码至少8个字符',
'any.required': '密码不能为空',
}),
});
function BasicJoiForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: joiResolver(schema),
});
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('username')} />
{errors.username && <span>{errors.username.message}</span>}
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">提交</button>
</form>
);
}Joi基础类型
字符串验证
jsx
import Joi from 'joi';
const stringSchema = Joi.object({
// 基本字符串
basic: Joi.string(),
// 必填
required: Joi.string().required(),
// 长度限制
minLength: Joi.string().min(3),
maxLength: Joi.string().max(20),
exactLength: Joi.string().length(10),
// 邮箱
email: Joi.string().email({ tlds: { allow: false } }),
// URI
uri: Joi.string().uri(),
// 域名
domain: Joi.string().domain(),
// IP地址
ip: Joi.string().ip(),
// 正则表达式
pattern: Joi.string().pattern(/^[a-zA-Z0-9_]+$/),
// 字母数字
alphanum: Joi.string().alphanum(),
// 大小写
lowercase: Joi.string().lowercase(),
uppercase: Joi.string().uppercase(),
// trim
trimmed: Joi.string().trim(),
// 替换
replaced: Joi.string().replace(/\s+/g, ' '),
// 自定义错误消息
withMessages: Joi.string().min(3).messages({
'string.min': '至少3个字符',
'string.empty': '不能为空',
}),
});数字验证
jsx
import Joi from 'joi';
const numberSchema = Joi.object({
// 基本数字
basic: Joi.number(),
// 必填
required: Joi.number().required(),
// 范围
min: Joi.number().min(0),
max: Joi.number().max(100),
greater: Joi.number().greater(0),
less: Joi.number().less(100),
// 整数
integer: Joi.number().integer(),
// 正数/负数
positive: Joi.number().positive(),
negative: Joi.number().negative(),
// 精度
precision: Joi.number().precision(2), // 2位小数
// 倍数
multiple: Joi.number().multiple(5),
// 端口号
port: Joi.number().port(),
// 自定义消息
withMessages: Joi.number().min(18).messages({
'number.min': '必须年满18岁',
}),
});布尔值验证
jsx
import Joi from 'joi';
const booleanSchema = Joi.object({
// 基本布尔值
basic: Joi.boolean(),
// 必须为true
mustBeTrue: Joi.boolean().valid(true).required(),
// 敏感转换(将字符串转为布尔)
sensitive: Joi.boolean().sensitive(),
// 真值列表
truthy: Joi.boolean().truthy('yes', '1', 'true'),
// 假值列表
falsy: Joi.boolean().falsy('no', '0', 'false'),
});日期验证
jsx
import Joi from 'joi';
const dateSchema = Joi.object({
// 基本日期
basic: Joi.date(),
// 必填
required: Joi.date().required(),
// 最小/最大日期
minDate: Joi.date().min('now'),
maxDate: Joi.date().max('2025-12-31'),
// 大于/小于
greater: Joi.date().greater('now'),
less: Joi.date().less('2025-12-31'),
// ISO格式
iso: Joi.date().iso(),
// 时间戳
timestamp: Joi.date().timestamp('unix'), // 'javascript' | 'unix'
});对象和嵌套验证
嵌套对象
jsx
import Joi from 'joi';
const profileSchema = Joi.object({
personal: Joi.object({
firstName: Joi.string().required(),
lastName: Joi.string().required(),
dateOfBirth: Joi.date().required(),
}),
contact: Joi.object({
email: Joi.string().email({ tlds: { allow: false } }).required(),
phone: Joi.string().pattern(/^\d{11}$/).required(),
}),
address: Joi.object({
street: Joi.string().required(),
city: Joi.string().required(),
zipCode: Joi.string().length(6).required(),
}),
}).messages({
'any.required': '此字段不能为空',
});
// 在表单中使用
function NestedObjectForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: joiResolver(profileSchema),
});
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input {...register('personal.firstName')} placeholder="名" />
{errors.personal?.firstName && (
<span>{errors.personal.firstName.message}</span>
)}
<input {...register('contact.email')} placeholder="邮箱" />
{errors.contact?.email && (
<span>{errors.contact.email.message}</span>
)}
<button type="submit">提交</button>
</form>
);
}可选和必填
jsx
import Joi from 'joi';
const schema = Joi.object({
// 必填
required: Joi.string().required(),
// 可选
optional: Joi.string().optional(),
// 默认值
withDefault: Joi.string().default('默认值'),
// 允许null
allowNull: Joi.string().allow(null),
// 允许空字符串
allowEmpty: Joi.string().allow(''),
// 禁止
forbidden: Joi.forbidden(),
// 条件必填
conditionalRequired: Joi.when('accountType', {
is: 'business',
then: Joi.string().required(),
otherwise: Joi.string().optional(),
}),
});数组验证
数组Schema
jsx
import Joi from 'joi';
import { useFieldArray } from 'react-hook-form';
// 字符串数组
const tagsSchema = Joi.array()
.items(Joi.string())
.min(1)
.max(10)
.unique();
// 对象数组
const itemsSchema = Joi.array()
.items(
Joi.object({
name: Joi.string().required(),
quantity: Joi.number().integer().positive().required(),
price: Joi.number().positive().required(),
})
)
.min(1);
// 完整schema
const orderSchema = Joi.object({
customerName: Joi.string().required(),
tags: tagsSchema,
items: itemsSchema,
});
function ArrayValidationForm() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm({
resolver: joiResolver(orderSchema),
defaultValues: {
items: [{ name: '', quantity: 1, price: 0 }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input {...register('customerName')} placeholder="客户名称" />
{fields.map((field, index) => (
<div key={field.id}>
<input
{...register(`items.${index}.name`)}
placeholder="名称"
/>
{errors.items?.[index]?.name && (
<span>{errors.items[index].name.message}</span>
)}
<input
type="number"
{...register(`items.${index}.quantity`, {
valueAsNumber: true,
})}
/>
<button type="button" onClick={() => remove(index)}>
删除
</button>
</div>
))}
<button
type="button"
onClick={() => append({ name: '', quantity: 1, price: 0 })}
>
添加项目
</button>
<button type="submit">提交</button>
</form>
);
}数组约束
jsx
import Joi from 'joi';
const arrayConstraints = Joi.object({
// 最小/最大长度
minItems: Joi.array().min(1),
maxItems: Joi.array().max(10),
// 精确长度
exactLength: Joi.array().length(3),
// 唯一值
unique: Joi.array().unique(),
// 有序
ordered: Joi.array().ordered(
Joi.string(),
Joi.number(),
Joi.boolean()
),
// 稀疏数组
sparse: Joi.array().sparse(),
// 单一类型
single: Joi.array().single(),
});条件验证
when条件
jsx
import Joi from 'joi';
const conditionalSchema = Joi.object({
accountType: Joi.string().valid('personal', 'business').required(),
// 简单条件
companyName: Joi.when('accountType', {
is: 'business',
then: Joi.string().required(),
otherwise: Joi.optional(),
}),
// 多个条件
taxId: Joi.when('accountType', {
is: 'business',
then: Joi.string().required(),
}),
// 复杂条件
discount: Joi.when(Joi.object({
accountType: Joi.string().valid('business'),
isPremium: Joi.boolean().valid(true),
}).unknown(), {
then: Joi.number().min(10),
}),
// 引用其他字段
endDate: Joi.date().when('startDate', {
is: Joi.date().required(),
then: Joi.date().min(Joi.ref('startDate')),
}),
});
function ConditionalForm() {
const { register, watch, handleSubmit, formState: { errors } } = useForm({
resolver: joiResolver(conditionalSchema),
});
const accountType = watch('accountType');
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<select {...register('accountType')}>
<option value="">选择账户类型</option>
<option value="personal">个人</option>
<option value="business">企业</option>
</select>
{accountType === 'business' && (
<>
<input {...register('companyName')} placeholder="公司名称" />
{errors.companyName && <span>{errors.companyName.message}</span>}
</>
)}
<button type="submit">提交</button>
</form>
);
}alternatives替代方案
jsx
import Joi from 'joi';
const alternativesSchema = Joi.object({
// 联合类型
stringOrNumber: Joi.alternatives().try(
Joi.string(),
Joi.number()
),
// 条件替代
contactMethod: Joi.alternatives()
.conditional('contactType', {
switch: [
{ is: 'email', then: Joi.string().email() },
{ is: 'phone', then: Joi.string().pattern(/^\d{11}$/) },
{ is: 'address', then: Joi.string().min(10) },
],
}),
});自定义验证
custom方法
jsx
import Joi from 'joi';
const customSchema = Joi.object({
username: Joi.string()
.required()
.custom((value, helpers) => {
if (value === 'admin') {
return helpers.error('custom.notAdmin');
}
if (value.includes(' ')) {
return helpers.error('custom.noSpaces');
}
return value;
})
.messages({
'custom.notAdmin': '不能使用admin作为用户名',
'custom.noSpaces': '用户名不能包含空格',
}),
password: Joi.string()
.required()
.custom((value, helpers) => {
const hasUpper = /[A-Z]/.test(value);
const hasLower = /[a-z]/.test(value);
const hasNumber = /\d/.test(value);
const hasSpecial = /[!@#$%^&*]/.test(value);
if (!(hasUpper && hasLower && hasNumber && hasSpecial)) {
return helpers.error('custom.weakPassword');
}
return value;
})
.messages({
'custom.weakPassword': '密码必须包含大小写字母、数字和特殊字符',
}),
});external异步验证
jsx
import Joi from 'joi';
const asyncSchema = Joi.object({
username: Joi.string()
.required()
.external(async (value) => {
const response = await fetch(`/api/check-username?username=${value}`);
const data = await response.json();
if (!data.available) {
throw new Error('该用户名已被使用');
}
return value;
}),
email: Joi.string()
.email()
.external(async (value) => {
const response = await fetch(`/api/check-email?email=${value}`);
const data = await response.json();
if (!data.available) {
throw new Error('该邮箱已被注册');
}
return value;
}),
});
// 使用异步验证
function AsyncValidationForm() {
const {
register,
handleSubmit,
formState: { errors, isValidating },
} = useForm({
resolver: joiResolver(asyncSchema),
mode: 'onBlur',
});
return (
<form onSubmit={handleSubmit(data => console.log(data))}>
<input {...register('username')} />
{isValidating && <span>验证中...</span>}
{errors.username && <span>{errors.username.message}</span>}
<button type="submit">提交</button>
</form>
);
}引用和依赖
ref引用
jsx
import Joi from 'joi';
const refSchema = Joi.object({
password: Joi.string().min(8).required(),
// 引用password字段
confirmPassword: Joi.string()
.required()
.valid(Joi.ref('password'))
.messages({
'any.only': '密码不匹配',
}),
// 日期比较
startDate: Joi.date().required(),
endDate: Joi.date()
.required()
.min(Joi.ref('startDate'))
.messages({
'date.min': '结束日期不能早于开始日期',
}),
// 数字比较
minPrice: Joi.number().positive(),
maxPrice: Joi.number()
.positive()
.greater(Joi.ref('minPrice'))
.messages({
'number.greater': '最大价格必须大于最小价格',
}),
// 复杂引用
discountPercentage: Joi.number()
.min(0)
.max(Joi.ref('maxDiscount', {
adjust: (value) => value * 100,
})),
});错误消息定制
自定义消息
jsx
import Joi from 'joi';
// 全局消息
const schema = Joi.object({
username: Joi.string().min(3).max(20).required(),
email: Joi.string().email().required(),
age: Joi.number().min(18).max(100).required(),
}).messages({
'string.min': '{#label}至少{#limit}个字符',
'string.max': '{#label}最多{#limit}个字符',
'string.email': '请输入有效的邮箱地址',
'number.min': '{#label}不能小于{#limit}',
'number.max': '{#label}不能大于{#limit}',
'any.required': '{#label}不能为空',
});
// 字段级消息
const detailedSchema = Joi.object({
password: Joi.string()
.min(8)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.required()
.messages({
'string.min': '密码至少8个字符',
'string.pattern.base': '密码必须包含大小写字母和数字',
'any.required': '密码不能为空',
}),
});
// 自定义标签
const labeledSchema = Joi.object({
username: Joi.string()
.label('用户名')
.min(3)
.messages({
'string.min': '{#label}至少3个字符',
}),
email: Joi.string()
.label('邮箱地址')
.email()
.messages({
'string.email': '{#label}格式不正确',
}),
});实战案例
完整表单验证
jsx
import { useForm } from 'react-hook-form';
import { joiResolver } from '@hookform/resolvers/joi';
import Joi from 'joi';
// 定义schema
const schema = Joi.object({
personal: Joi.object({
firstName: Joi.string().required().label('名'),
lastName: Joi.string().required().label('姓'),
email: Joi.string()
.email({ tlds: { allow: false } })
.required()
.label('邮箱'),
phone: Joi.string()
.pattern(/^1[3-9]\d{9}$/)
.required()
.label('手机号'),
}),
account: Joi.object({
username: Joi.string()
.min(3)
.max(20)
.pattern(/^[a-zA-Z0-9_]+$/)
.required()
.label('用户名')
.messages({
'string.pattern.base': '只能包含字母、数字和下划线',
}),
password: Joi.string()
.min(8)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.required()
.label('密码')
.messages({
'string.pattern.base': '密码必须包含大小写字母和数字',
}),
confirmPassword: Joi.string()
.required()
.valid(Joi.ref('password'))
.label('确认密码')
.messages({
'any.only': '密码不匹配',
}),
}),
preferences: Joi.object({
newsletter: Joi.boolean().default(false),
language: Joi.string().valid('zh', 'en').default('zh'),
}),
agreeToTerms: Joi.boolean()
.valid(true)
.required()
.messages({
'any.only': '请同意服务条款',
}),
}).messages({
'any.required': '{#label}不能为空',
'string.min': '{#label}至少{#limit}个字符',
'string.max': '{#label}最多{#limit}个字符',
});
function CompleteForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm({
resolver: joiResolver(schema),
});
const onSubmit = async (data) => {
try {
await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
alert('注册成功!');
} catch (error) {
alert('注册失败: ' + error.message);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="complete-form">
<section>
<h3>个人信息</h3>
<input {...register('personal.firstName')} placeholder="名" />
{errors.personal?.firstName && (
<span className="error">{errors.personal.firstName.message}</span>
)}
<input {...register('personal.email')} placeholder="邮箱" />
{errors.personal?.email && (
<span className="error">{errors.personal.email.message}</span>
)}
</section>
<section>
<h3>账户信息</h3>
<input {...register('account.username')} placeholder="用户名" />
{errors.account?.username && (
<span className="error">{errors.account.username.message}</span>
)}
<input
type="password"
{...register('account.password')}
placeholder="密码"
/>
{errors.account?.password && (
<span className="error">{errors.account.password.message}</span>
)}
<input
type="password"
{...register('account.confirmPassword')}
placeholder="确认密码"
/>
{errors.account?.confirmPassword && (
<span className="error">{errors.account.confirmPassword.message}</span>
)}
</section>
<label>
<input type="checkbox" {...register('agreeToTerms')} />
我已阅读并同意服务条款
</label>
{errors.agreeToTerms && (
<span className="error">{errors.agreeToTerms.message}</span>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '注册中...' : '注册'}
</button>
</form>
);
}总结
Joi验证库要点:
- 功能丰富:提供全面的验证规则
- 描述性API:链式调用,易于阅读
- 条件验证:when和alternatives灵活控制
- 异步验证:external方法支持异步
- 引用字段:ref实现字段间验证
- 自定义消息:灵活的错误消息系统
Joi是功能强大的验证库,适合复杂的验证场景。
第四部分:Joi高级应用
4.1 高级Schema模式
javascript
import Joi from 'joi';
// 动态条件验证
const advancedConditionalSchema = Joi.object({
userType: Joi.string().valid('student', 'teacher', 'admin').required(),
// 基于userType的条件字段
studentId: Joi.when('userType', {
is: 'student',
then: Joi.string().pattern(/^S\d{6}$/).required().messages({
'string.pattern.base': '学生ID格式:S + 6位数字'
}),
otherwise: Joi.forbidden()
}),
teacherId: Joi.when('userType', {
is: 'teacher',
then: Joi.string().pattern(/^T\d{6}$/).required(),
otherwise: Joi.forbidden()
}),
// 多条件联合验证
adminLevel: Joi.when('userType', {
is: 'admin',
then: Joi.number().integer().min(1).max(5).required(),
otherwise: Joi.forbidden()
}),
privilege: Joi.when(
Joi.ref('userType'),
{
is: 'admin',
then: Joi.when(Joi.ref('adminLevel'), {
is: Joi.number().min(3),
then: Joi.string().required().messages({
'any.required': '高级管理员必须选择权限'
}),
otherwise: Joi.optional()
}),
otherwise: Joi.forbidden()
}
)
});
// Alternatives复杂分支
const polymorphicSchema = Joi.object({
type: Joi.string().valid('text', 'number', 'date', 'file').required(),
value: Joi.alternatives().conditional('type', {
switch: [
{
is: 'text',
then: Joi.string().min(1).max(1000)
},
{
is: 'number',
then: Joi.number().precision(2)
},
{
is: 'date',
then: Joi.date().max('now')
},
{
is: 'file',
then: Joi.object({
filename: Joi.string().required(),
size: Joi.number().max(10 * 1024 * 1024), // 10MB
mimeType: Joi.string().valid('image/jpeg', 'image/png', 'application/pdf')
})
}
],
otherwise: Joi.forbidden()
})
});
// 递归Schema
const recursiveSchema = Joi.object({
id: Joi.string().required(),
name: Joi.string().required(),
children: Joi.array().items(Joi.link('#node')).default([])
}).id('node');
const treeSchema = Joi.object({
root: recursiveSchema
});
// 动态数组验证
const dynamicListSchema = Joi.object({
items: Joi.array().items(
Joi.object({
type: Joi.string().valid('product', 'service').required(),
// 产品特定字段
sku: Joi.when('type', {
is: 'product',
then: Joi.string().pattern(/^[A-Z]{3}-\d{6}$/).required(),
otherwise: Joi.forbidden()
}),
quantity: Joi.when('type', {
is: 'product',
then: Joi.number().integer().min(1).max(9999).required(),
otherwise: Joi.forbidden()
}),
// 服务特定字段
duration: Joi.when('type', {
is: 'service',
then: Joi.number().min(0.5).max(24).required(),
otherwise: Joi.forbidden()
}),
hourlyRate: Joi.when('type', {
is: 'service',
then: Joi.number().precision(2).min(0).required(),
otherwise: Joi.forbidden()
}),
// 通用字段
price: Joi.number().precision(2).positive().required(),
description: Joi.string().max(500).optional()
})
).min(1).max(100).unique('sku')
});4.2 高级转换和预处理
javascript
// 自定义扩展方法
const customJoi = Joi.extend((joi) => ({
type: 'string',
base: joi.string(),
messages: {
'string.creditCard': '{{#label}} 必须是有效的信用卡号'
},
rules: {
creditCard: {
validate(value, helpers) {
// Luhn算法验证信用卡号
const digits = value.replace(/\D/g, '');
if (digits.length < 13 || digits.length > 19) {
return helpers.error('string.creditCard');
}
let sum = 0;
let isEven = false;
for (let i = digits.length - 1; i >= 0; i--) {
let digit = parseInt(digits[i]);
if (isEven) {
digit *= 2;
if (digit > 9) digit -= 9;
}
sum += digit;
isEven = !isEven;
}
if (sum % 10 !== 0) {
return helpers.error('string.creditCard');
}
return value;
}
}
}
}));
// 扩展自定义phone验证
const extendedJoi = customJoi.extend((joi) => ({
type: 'string',
base: joi.string(),
messages: {
'string.phone': '{{#label}} 必须是有效的手机号',
'string.phone.country': '{{#label}} 必须是{{#country}}的手机号'
},
rules: {
phone: {
method(country) {
return this.$_addRule({ name: 'phone', args: { country } });
},
args: [
{
name: 'country',
assert: joi.string().valid('CN', 'US', 'UK'),
message: '不支持的国家代码'
}
],
validate(value, helpers, args) {
const patterns = {
CN: /^1[3-9]\d{9}$/,
US: /^\+?1?\d{10}$/,
UK: /^\+?44\d{10}$/
};
const pattern = patterns[args.country || 'CN'];
if (!pattern.test(value)) {
return helpers.error('string.phone.country', { country: args.country });
}
return value;
}
}
}
}));
// 使用自定义扩展
const paymentSchema = Joi.object({
cardNumber: customJoi.string().creditCard().required(),
phone: extendedJoi.string().phone('CN').required()
});
// 预处理和转换
const transformSchema = Joi.object({
// 自动trim和转小写
username: Joi.string()
.trim()
.lowercase()
.alphanum()
.min(3)
.max(20)
.required(),
// 数值字符串转数字
price: Joi.number()
.custom((value, helpers) => {
if (typeof value === 'string') {
const cleaned = value.replace(/[^0-9.]/g, '');
return parseFloat(cleaned);
}
return value;
})
.precision(2)
.positive(),
// 日期字符串转Date对象
birthDate: Joi.date()
.custom((value, helpers) => {
if (typeof value === 'string') {
return new Date(value);
}
return value;
})
.max('now'),
// JSON字符串解析
metadata: Joi.string()
.custom((value, helpers) => {
try {
return JSON.parse(value);
} catch (error) {
return helpers.error('any.invalid');
}
})
.optional(),
// 标签字符串分割为数组
tags: Joi.alternatives()
.try(
Joi.array().items(Joi.string()),
Joi.string().custom((value, helpers) => {
return value.split(',').map(tag => tag.trim()).filter(Boolean);
})
)
.required()
});4.3 异步验证优化
javascript
// 防抖异步验证
const debounce = (fn, delay) => {
let timeoutId;
return function(...args) {
clearTimeout(timeoutId);
return new Promise((resolve, reject) => {
timeoutId = setTimeout(async () => {
try {
const result = await fn.apply(this, args);
resolve(result);
} catch (error) {
reject(error);
}
}, delay);
});
};
};
// 缓存异步验证结果
const createCachedValidator = (validator) => {
const cache = new Map();
const maxCacheSize = 100;
return async (value) => {
if (cache.has(value)) {
return cache.get(value);
}
const result = await validator(value);
cache.set(value, result);
if (cache.size > maxCacheSize) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey);
}
return result;
};
};
// 优化后的异步Schema
const optimizedAsyncSchema = Joi.object({
username: Joi.string()
.min(3)
.max(20)
.required()
.external(
createCachedValidator(
debounce(async (value) => {
const response = await fetch(`/api/check-username?username=${value}`);
const data = await response.json();
if (!data.available) {
throw new Error('用户名已被使用');
}
}, 500)
)
),
email: Joi.string()
.email()
.required()
.external(
createCachedValidator(async (value) => {
const response = await fetch(`/api/check-email?email=${value}`);
const data = await response.json();
if (!data.available) {
throw new Error('邮箱已被使用');
}
})
)
});
// 并行异步验证
const parallelAsyncSchema = Joi.object({
username: Joi.string().required(),
email: Joi.string().email().required()
}).external(async (value) => {
const [usernameCheck, emailCheck] = await Promise.all([
fetch(`/api/check-username?username=${value.username}`).then(r => r.json()),
fetch(`/api/check-email?email=${value.email}`).then(r => r.json())
]);
const errors = [];
if (!usernameCheck.available) {
errors.push({
message: '用户名已被使用',
path: ['username'],
type: 'external'
});
}
if (!emailCheck.available) {
errors.push({
message: '邮箱已被使用',
path: ['email'],
type: 'external'
});
}
if (errors.length > 0) {
throw new Joi.ValidationError('验证失败', errors, value);
}
});
// 条件异步验证
const conditionalAsyncSchema = Joi.object({
accountType: Joi.string().valid('email', 'phone').required(),
identifier: Joi.string()
.required()
.external(async (value, helpers) => {
const { accountType } = helpers.state.ancestors[0];
const endpoint = accountType === 'email'
? `/api/check-email?email=${value}`
: `/api/check-phone?phone=${value}`;
const response = await fetch(endpoint);
const data = await response.json();
if (!data.available) {
throw new Error(`${accountType === 'email' ? '邮箱' : '手机号'}已被使用`);
}
})
});4.4 错误处理和国际化
javascript
// 自定义错误消息
const customMessages = {
'any.required': '{{#label}}是必填项',
'any.invalid': '{{#label}}值无效',
'string.base': '{{#label}}必须是文本',
'string.empty': '{{#label}}不能为空',
'string.min': '{{#label}}至少{{#limit}}个字符',
'string.max': '{{#label}}最多{{#limit}}个字符',
'string.email': '{{#label}}必须是有效的邮箱',
'string.pattern.base': '{{#label}}格式不正确',
'number.base': '{{#label}}必须是数字',
'number.min': '{{#label}}最小值为{{#limit}}',
'number.max': '{{#label}}最大值为{{#limit}}',
'number.positive': '{{#label}}必须是正数',
'date.base': '{{#label}}必须是有效日期',
'date.max': '{{#label}}不能晚于{{#limit}}',
'array.min': '{{#label}}至少{{#limit}}项',
'array.max': '{{#label}}最多{{#limit}}项'
};
const schemaWithCustomMessages = Joi.object({
username: Joi.string().min(3).max(20).required().messages(customMessages),
email: Joi.string().email().required().messages(customMessages),
age: Joi.number().min(18).max(100).required().messages(customMessages)
});
// i18n国际化
import i18n from 'i18next';
const createI18nMessages = (t) => ({
'any.required': t('validation.required', { field: '{{#label}}' }),
'string.min': t('validation.string.min', {
field: '{{#label}}',
min: '{{#limit}}'
}),
'string.email': t('validation.string.email', { field: '{{#label}}' })
// ... 其他消息
});
function useJoiI18n() {
return useMemo(() => {
return createI18nMessages(i18n.t);
}, [i18n.language]);
}
// 错误格式化
function formatJoiError(error) {
if (!error.details) {
return { message: error.message };
}
return error.details.reduce((acc, detail) => {
const path = detail.path.join('.');
acc[path] = detail.message;
return acc;
}, {});
}
// 自定义错误处理器
class JoiErrorHandler {
constructor() {
this.errors = new Map();
}
handleError(error, context = {}) {
if (!error.isJoi) {
throw error;
}
error.details.forEach(detail => {
const path = detail.path.join('.');
const message = this.interpolateMessage(detail.message, {
...detail.context,
...context
});
this.errors.set(path, {
type: detail.type,
message,
value: detail.context.value
});
});
return this.errors;
}
interpolateMessage(template, context) {
return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
const cleanKey = key.trim().replace(/^#/, '');
return context[cleanKey] || match;
});
}
getError(path) {
return this.errors.get(path);
}
hasError(path) {
return this.errors.has(path);
}
clearError(path) {
this.errors.delete(path);
}
clearAll() {
this.errors.clear();
}
toObject() {
const result = {};
this.errors.forEach((error, path) => {
result[path] = error.message;
});
return result;
}
}4.5 性能优化策略
javascript
// Schema缓存
const schemaCache = new Map();
function getCachedSchema(key, creator) {
if (!schemaCache.has(key)) {
schemaCache.set(key, creator());
}
return schemaCache.get(key);
}
// 部分验证
async function validatePartial(schema, data, fields) {
const fieldsToValidate = {};
fields.forEach(field => {
if (field in data) {
fieldsToValidate[field] = data[field];
}
});
// 提取指定字段的Schema
const partialSchema = Joi.object(
Object.keys(schema.describe().keys)
.filter(key => fields.includes(key))
.reduce((acc, key) => {
acc[key] = schema.extract(key);
return acc;
}, {})
);
return partialSchema.validateAsync(fieldsToValidate, {
abortEarly: false
});
}
// 增量验证Hook
function useIncrementalJoiValidation(schema) {
const [errors, setErrors] = useState({});
const [validatedFields, setValidatedFields] = useState(new Set());
const validateField = useCallback(async (field, value) => {
try {
const fieldSchema = schema.extract(field);
await fieldSchema.validateAsync(value);
setErrors(prev => {
const next = { ...prev };
delete next[field];
return next;
});
setValidatedFields(prev => new Set([...prev, field]));
} catch (error) {
setErrors(prev => ({
...prev,
[field]: error.message
}));
setValidatedFields(prev => {
const next = new Set(prev);
next.delete(field);
return next;
});
}
}, [schema]);
return { errors, validatedFields, validateField };
}
// 智能验证模式
function useSmartJoiValidation(schema) {
const [mode, setMode] = useState('onSubmit');
const errorHistory = useRef({});
const validate = useCallback(async (data) => {
try {
await schema.validateAsync(data, { abortEarly: false });
errorHistory.current = {};
return { valid: true, errors: {} };
} catch (error) {
const errors = formatJoiError(error);
// 如果有错误,切换到实时验证
if (Object.keys(errors).length > 0 && mode === 'onSubmit') {
setMode('onChange');
}
errorHistory.current = errors;
return { valid: false, errors };
}
}, [schema, mode]);
const validateField = useCallback(async (field, value) => {
// 只对有错误历史的字段进行实时验证
if (!errorHistory.current[field] && mode === 'onSubmit') {
return;
}
try {
const fieldSchema = schema.extract(field);
await fieldSchema.validateAsync(value);
delete errorHistory.current[field];
} catch (error) {
errorHistory.current[field] = error.message;
}
}, [schema, mode]);
return { validate, validateField, mode };
}
// 延迟验证
const useDebouncedJoiValidation = (schema, delay = 300) => {
const [errors, setErrors] = useState({});
const timeoutRef = useRef();
const validate = useCallback((data) => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(async () => {
try {
await schema.validateAsync(data, { abortEarly: false });
setErrors({});
} catch (error) {
setErrors(formatJoiError(error));
}
}, delay);
}, [schema, delay]);
useEffect(() => {
return () => clearTimeout(timeoutRef.current);
}, []);
return { errors, validate };
};4.6 实战综合案例
javascript
// 复杂电商订单Schema
const orderSchema = Joi.object({
// 客户信息
customer: Joi.object({
name: Joi.string().trim().min(2).max(50).required().messages({
'string.min': '姓名至少2个字符',
'any.required': '姓名是必填项'
}),
email: Joi.string().email().lowercase().required(),
phone: extendedJoi.string().phone('CN').required()
}).required(),
// 送货地址
shippingAddress: Joi.object({
street: Joi.string().min(5).required(),
city: Joi.string().required(),
province: Joi.string().required(),
zipCode: Joi.string().pattern(/^\d{6}$/).required().messages({
'string.pattern.base': '邮编必须是6位数字'
}),
isDefault: Joi.boolean().default(false)
}).required(),
// 账单地址
billingAddress: Joi.alternatives().conditional(
Joi.ref('useSameAddress'),
{
is: true,
then: Joi.valid(Joi.ref('shippingAddress')),
otherwise: Joi.object({
street: Joi.string().min(5).required(),
city: Joi.string().required(),
province: Joi.string().required(),
zipCode: Joi.string().pattern(/^\d{6}$/).required()
})
}
),
useSameAddress: Joi.boolean().default(true),
// 订单项
items: Joi.array().items(
Joi.object({
productId: Joi.string().required(),
sku: Joi.string().pattern(/^[A-Z]{3}-\d{6}$/).required(),
quantity: Joi.number().integer().min(1).max(999).required(),
price: Joi.number().precision(2).positive().required(),
discount: Joi.number().min(0).max(100).default(0),
// 计算总价
total: Joi.number().custom((value, helpers) => {
const item = helpers.state.ancestors[0];
const calculated = item.price * item.quantity * (1 - item.discount / 100);
if (Math.abs(value - calculated) > 0.01) {
return helpers.error('any.invalid');
}
return value;
})
})
).min(1).max(50).unique('sku').required(),
// 支付方式
paymentMethod: Joi.object({
type: Joi.string().valid('credit_card', 'debit_card', 'alipay', 'wechat').required(),
// 信用卡特定字段
cardNumber: Joi.when('type', {
is: Joi.string().valid('credit_card', 'debit_card'),
then: customJoi.string().creditCard().required(),
otherwise: Joi.forbidden()
}),
cardHolderName: Joi.when('type', {
is: Joi.string().valid('credit_card', 'debit_card'),
then: Joi.string().required(),
otherwise: Joi.forbidden()
}),
expiryDate: Joi.when('type', {
is: Joi.string().valid('credit_card', 'debit_card'),
then: Joi.string().pattern(/^(0[1-9]|1[0-2])\/\d{2}$/).required(),
otherwise: Joi.forbidden()
}),
cvv: Joi.when('type', {
is: Joi.string().valid('credit_card', 'debit_card'),
then: Joi.string().pattern(/^\d{3,4}$/).required(),
otherwise: Joi.forbidden()
}),
// 第三方支付账号
accountId: Joi.when('type', {
is: Joi.string().valid('alipay', 'wechat'),
then: Joi.string().required(),
otherwise: Joi.forbidden()
})
}).required(),
// 优惠券
couponCode: Joi.string().pattern(/^[A-Z0-9]{8}$/).optional(),
// 备注
notes: Joi.string().max(500).optional(),
// 协议同意
agreedToTerms: Joi.boolean().valid(true).required().messages({
'any.only': '必须同意服务条款'
})
}).custom((value, helpers) => {
// 验证订单总额
const itemsTotal = value.items.reduce((sum, item) => sum + item.total, 0);
if (itemsTotal < 0.01) {
return helpers.error('any.invalid', {
message: '订单金额必须大于0'
});
}
return value;
});
// 使用完整Schema
function ComplexOrderForm() {
const { register, handleSubmit, watch, formState: { errors, isSubmitting } } = useForm({
resolver: joiResolver(orderSchema)
});
const useSameAddress = watch('useSameAddress');
const paymentType = watch('paymentMethod.type');
const onSubmit = async (data) => {
console.log('订单数据:', data);
// 提交订单
await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* 表单实现 */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '提交订单'}
</button>
</form>
);
}Joi最佳实践总结
核心技巧
1. Schema设计
✅ 使用alternatives处理多态
✅ when实现条件验证
✅ link支持递归结构
2. 扩展能力
✅ extend自定义类型
✅ custom自定义验证逻辑
✅ external异步验证
3. 性能优化
✅ Schema缓存复用
✅ 部分验证减少开销
✅ 防抖和缓存异步验证
4. 错误处理
✅ 自定义错误消息
✅ 详细的错误路径
✅ i18n国际化支持
5. 实战技巧
✅ 智能验证策略
✅ 增量验证优化
✅ 复杂业务逻辑封装Joi凭借其强大的功能和灵活性,特别适合需要复杂验证逻辑的大型应用。