背景
最近在做一个个人next小项目,ui框架使用的是shadcn/ui,值得一提的是,使用这一套组合shadcn/form + react-hook-form + zod
,form表单操作太优雅了,开发体验很棒。
然而公司里的项目ui框架使用的是antd,对比上面那一套开发体验差了一些。然后我就想了一下,那么好的开发体验,能不能改造一下antd form表单实现shadcn/form一样的效果呢,后面勉强实现了,下面和大家分享一下。
对比
前言
下面通过一个例子来看一下两个框架的区别。
例子:实现一个注册页面,需要输入邮箱、密码和重复密码,要求重复密码和密码要一致。
shadcn/form + react-hook-form + zod
定义校验模型
这里使用zod先定义校验模型,不了解zod的可以先看下官方文档。
ts
const formSchema = z.object({
email: z.string().email({ message: '无效的邮箱格式' }),
password: z.string().min(6, { message: '密码至少 6 个字符' }),
confirm: z.string().min(6, { message: '密码至少 6 个字符' }),
})
实现两次密码匹配校验,这里可以使用zod里的refine方法。
ts
const formSchema = z.object({
email: z.string().email({ message: '无效的邮箱格式' }),
password: z.string().min(6, { message: '密码至少 6 个字符' }),
confirm: z.string().min(6, { message: '密码至少 6 个字符' }),
}).refine(data => {
return data.password === data.confirm
}, {
message: '两次密码不匹配',
path: ["confirm"],
})
refine里的方法返回true,则校验通过,返回false则校验失败。message是自定义的错误消息,path对应的是模型里的某个字段,把错误消息显示在这个字段上。
下面使用react-hook-form和shadcn/form配合zod实现例子中的功能,具体可以看一下代码中的注释。
tsx
export default function RegisterForm() {
// 初始化表单
const form = useForm<z.infer<typeof formSchema>>({
// 指定表单验证规则
resolver: zodResolver(formSchema),
// 验证模式,onChange表示输入框值变化时触发验证
mode: 'onChange',
})
// 提交表单
function onSubmit(values: z.infer<typeof formSchema>) {
// 这里的values就是表单的值,是经过验证后的值,是安全的,可以放心使用
console.log(values.password);
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>邮箱</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>密码</FormLabel>
<FormControl>
<Input type='password' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirm"
render={({ field }) => (
<FormItem>
<FormLabel>重复密码</FormLabel>
<FormControl>
<Input type='password' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Submit</Button>
</form>
</Form>
)
}
antd form
再看一下使用antd form组件使用上面功能
tsx
import React from 'react';
import { Form, Input } from 'antd';
type FormSchemaType = {
email: string;
password: string;
confirm: string;
}
const App: React.FC = () => {
const [form] = Form.useForm();
function onSubmit(values: FormSchemaType) {
console.log(values.password);
}
return (
<Form<FormSchemaType>
form={form}
name="dependencies"
autoComplete="off"
style={{ maxWidth: 600 }}
layout="vertical"
onFinish={onSubmit}
>
<Form.Item<FormSchemaType>
label="邮箱"
name="email"
rules={[
{ required: true, message: '邮箱不能为空' },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input />
</Form.Item>
<Form.Item<FormSchemaType>
label="密码"
name="password"
rules={[{ required: true, message: '密码不能为空' }]}
>
<Input />
</Form.Item>
<Form.Item<FormSchemaType>
label="重复密码"
name="confirm"
dependencies={['password']}
rules={[
{
required: true,
message: '重复密码不能为空',
},
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次密码不匹配'));
},
}),
]}
>
<Input />
</Form.Item>
</Form>
);
};
export default App;
上面代码中模型可以不用定义的,但是为了有代码提示,需要定义一个模型。
小结
可以看到,上面两种开发方式代码量差不多,但是有个地方可能会出问题,不知道大家在开发的过程中有没有发现。
看一下两个onSubmit方法,参数values对应表单的值,问题就出在values上面。
antd的定义的模型是和校验规则是分开的,这里面有可能会导致类型不安全,也就是说模型里定义的email是必填的,如果FormItem里的规则没有添加必填校验,那么email这个属性的值可能为空,别人用这个值的时候没有加为空判断,那么就可能会导致bug。虽说这种情况出现的概率不大,但还是有隐患,我们写代码要想办法把问题扼杀在摇篮之中。
而前面使用zod定义的模型,因为表单使用的校验规则就是zod定义的,所以它们是一致的,values的值可以保证和zod定义的一样,如果不一样,表单会校验失败,不会走onSubmit方法。
antd form + zod
前言
既然第一种方案那么好用,那能不能通过把antd的form组件和zod结合在一起呢,我在github上搜了一下,发现已经有人做了这个,库的名字叫做antd-zod。
看了一下源码,实现原理很简单,在antd form的validator方法里调用zod的检验方法,如果出错就把错误信息抛出去就行了。
tsx
import z from 'zod';
import { Form, Button, Input, InputNumber } from 'antd';
import { createSchemaFieldRule } from 'antd-zod';
// Create zod schema - base schema MUST be an object
const CustomFormValidationSchema = z.object({
fieldString: z.string(),
fieldNumber: z.number(),
});
// Create universal rule for Form.Item rules prop for EVERY schema field
const rule = createSchemaFieldRule(CustomFormValidationSchema);
// Set rule to Form.Item
const SimpleForm = () => {
return (
<Form>
<Form.Item label="String field" name="fieldString" rules={[rule]}>
<Input/>
</Form.Item>
<Form.Item label="Number field" name="fieldNumber" rules={[rule]}>
<InputNumber/>
</Form.Item>
<Button htmlType="submit">Submit</Button>
</Form>
);
};
上面是官方给出的例子,他的方案有两个我个人认为不太方便的地方。
第一是每个FormItem都要写一遍rules,还都是一样的代码。
第二是表单必填项没有显示红色的*。
结合这两个问题,我在这个库的基础上封装了一个ZodForm组件。
实现思路
封装一个ZodForm组件,在组件内部调用antd-zod的createSchemaFieldRule方法创建校验规则,然后遍历当前组件的children,使用cloneELement方法,把前面创建的规则rule注入到子组件的rules属性中,就行了,这里判断一下是不是必填的,如果要求不能为空,则往rules里再添加一个不能为空的规则,这时候红色的*就出来了
具体实现
tsx
import { Form, FormProps } from 'antd'
import React, { useMemo } from 'react';
import z from 'zod'
import { createSchemaFieldRule } from 'antd-zod';
import type { Rule } from 'antd/es/form';
import { isRequiredByFieldName } from './utils';
type Combine<T, U> = Omit<T, keyof U> & U;
function ZodForm<T extends z.ZodObject<any> | z.ZodEffects<z.ZodObject<any>>, K extends (values: z.infer<T>) => void>({
zodSchema,
onFinish,
children,
...props
}: Combine<FormProps, {
children?: React.ReactElement | React.ReactElement[],
zodSchema?: T
onFinish?: K,
}>) {
// 如果不传 zodSchema,则直接使用 antd 的 Form
if (!zodSchema) {
return (
<Form
{...props}
>
{children}
</Form>
)
}
const rule = useMemo(() => {
// 使用antd-zod库生成校验规则
return createSchemaFieldRule(zodSchema);
}, [zodSchema])
function renderChildren() {
return React.Children.map(children, (child) => {
if (!child) {
return child;
}
let name = child.props?.name;
if (name) {
if (!Array.isArray(name)) {
name = [child.props.name];
}
// 根据字段名,判断是否是必填
const required = isRequiredByFieldName(name, zodSchema!);
const rules: Rule[] = [rule];
if (required) {
rules.push({ required: true, message: '' });
}
return React.cloneElement(child as React.ReactElement, {
rules,
})
}
return child;
})
}
return (
<Form
{...props}
onFinish={onFinish}
>
{renderChildren()}
</Form>
)
}
export default ZodForm
utils.ts文件代码
ts
import z, { ZodTypeAny } from "zod";
export const isRequiredByFieldName = (paths: string[], schema: ZodTypeAny) => {
let shape: z.ZodObject<any, z.UnknownKeysParam, z.ZodTypeAny, {
[x: string]: any;
}, {
[x: string]: any;
}>;
if (isZodEffect(schema)) {
shape = schema._def.schema;
} else if (isZodObject(schema)) {
shape = schema;
} else {
throw new Error("schema is not ZodObject or ZodEffects");
}
paths.forEach((path: string) => {
if (shape) {
shape = shape?.shape[path]
}
});
// 如果是Optional类型,表示字段可为空
return !isZodOptional(shape)
}
const isZodEffect = (schema: unknown): schema is z.ZodEffects<any> =>
typeof schema === "object" &&
!!schema &&
!("shape" in schema) &&
"_def" in schema &&
typeof schema._def === "object" &&
!!schema._def &&
"schema" in schema._def;
const isZodOptional = (schema: unknown): schema is z.ZodOptional<any> =>
typeof schema === "object" && !!schema && "unwrap" in schema;
const isZodObject = (schema: unknown): schema is z.ZodObject<any> =>
typeof schema === "object" && !!schema && "shape" in schema;
代码很简单,相信大家都能看明白,就不一一解释了,具体可以看代码中的注释。
使用案例
tsx
import z from 'zod'
import { Form, Input, Button } from 'antd'
import ZodForm from './form'
const schema = z.object({
email: z.string({
required_error: '邮箱不能为空'
}).email({ message: '无效的邮箱格式' }),
password: z.string({
required_error: '密码不能为空'
}).min(6, { message: '密码至少 6 个字符' }),
confirm: z.string({
required_error: '重复密码不能为空'
}).min(6, { message: '密码至少 6 个字符' }),
}).refine(data => {
return data.password === data.confirm
}, {
message: '两次密码不匹配',
path: ["confirm"],
});
type FormSchemaType = z.infer<typeof schema>;
function App() {
const [form] = Form.useForm();
function onSubmit(values: FormSchemaType) {
// 这里的values可以放心的使用,因为经过zod检验通过了
console.log(values);
}
return (
<ZodForm
zodSchema={schema}
onFinish={onSubmit}
form={form}
layout="vertical"
>
<Form.Item<FormSchemaType>
label="邮箱"
name="email"
>
<Input />
</Form.Item>
<Form.Item<FormSchemaType>
label="密码"
name="password"
>
<Input />
</Form.Item>
<Form.Item<FormSchemaType>
label="重复密码"
name="confirm"
dependencies={['password']}
>
<Input />
</Form.Item>
<Button htmlType='submit'>提交</Button>
</ZodForm>
)
}
export default App
新封装的ZodForm和antd form用法基本一样,只是多了一个zodSchema属性,可以传递通过zod定义的模型。
还有一个提升开发体验的地方,antd的form组件,onFinish方法里的values参数默认是any的,如果想要指定类型,需要使用范型。
使用ZodForm的情况下,可以不用指定范型,也能有类型提示。
如果想给某个字段设置允许为空,可以这样设置。
最后
不知道大家有没有更好的方式,欢迎大家在评论区留言讨论。