likes
comments
collection
share

改造antd的form组件,配合zod库做数据校验,真的太优雅了。

作者站长头像
站长
· 阅读数 5

背景

最近在做一个个人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的可以先看下官方文档

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方法。

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-formshadcn/form配合zod实现例子中的功能,具体可以看一下代码中的注释。

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组件使用上面功能

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的检验方法,如果出错就把错误信息抛出去就行了。

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里再添加一个不能为空的规则,这时候红色的*就出来了

具体实现

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文件代码

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;

代码很简单,相信大家都能看明白,就不一一解释了,具体可以看代码中的注释。

使用案例

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的,如果想要指定类型,需要使用范型。

改造antd的form组件,配合zod库做数据校验,真的太优雅了。

改造antd的form组件,配合zod库做数据校验,真的太优雅了。

使用ZodForm的情况下,可以不用指定范型,也能有类型提示。

改造antd的form组件,配合zod库做数据校验,真的太优雅了。

如果想给某个字段设置允许为空,可以这样设置。

改造antd的form组件,配合zod库做数据校验,真的太优雅了。

改造antd的form组件,配合zod库做数据校验,真的太优雅了。

最后

不知道大家有没有更好的方式,欢迎大家在评论区留言讨论。