React从0到1封装一个Form表单

简介: React从0到1封装一个Form表单

您好,如果喜欢我的文章,可以关注我的公众号「量子前端」,将不定期关注推送前端好文~

前言

博主最近在做组件库封装开发的工作,Form表单比较复杂,包含非受控表单和受控表单,特此记录一下。

组件完成页面

请添加图片描述

封装好的Form表单涵盖了布局、非受控、受控、校验、重置等功能。

非受控表单

首先我们先做一个架子,也就是简单的非受控表单,也就是生成基础布局,不做表单内容(状态)的处理,我是一共写了两个组件,分别为Form和Form.Item,基本的使用代码是这样的:

<Form layout={
   
   'vertical'} style={
   
   {
   
    width: '600px' }}>
  <Form.Item label="Username">
     <Input placeholder="Please enter your usename" width="200"></Input>
   </Form.Item>
   <Form.Item label="Post">
     <Input placeholder="Please enter your post" width="200"></Input>
   </Form.Item>
   <Form.Item wrapperTol={
   
   20}>
     <CheckBox checked={
   
   true}>I have read the manual</CheckBox>
   </Form.Item>
   <Form.Item wrapperTol={
   
   5}>
     <Button type="primary">Submit</Button>
   </Form.Item>
 </Form>

效果是这样的:

在这里插入图片描述

其实props也很好分了,Form的props用来做整体的一些控制,如这里的layout就是布局,以及style整体样式;Form.Item的props则对单行做处理。

先看一下最基本的架子吧:

Form.tsx:

return (
    <ctx.Provider value={
   
   providerList}>
      <div className="form" style={
   
   style} ref={
   
   formField || null}>
        {
   
   disabled && <div className="disabled" />}
        {
   
   children}
      </div>
    </ctx.Provider>
  );

Form把所有内部Dom渲染出来,并且把form的props通过react.createContext传递给所有的Form.Item,仅此而已。

FormItem.tsx:

return (
    <div className="form-item" style={
   
   propsStyle}>
      <div className="label" style={
   
   labelStyle}>
        {
   
   rules.length > 0 && (
          <svg fill="currentColor" viewBox="0 0 1024 1024" width="0.5em" height="0.5em">
            <path d="M583.338667 17.066667c18.773333 0 34.133333 15.36 34.133333 34.133333v349.013333l313.344-101.888a34.133333 34.133333 0 0 1 43.008 22.016l42.154667 129.706667a34.133333 34.133333 0 0 1-21.845334 43.178667l-315.733333 102.4 208.896 287.744a34.133333 34.133333 0 0 1-7.509333 47.786666l-110.421334 80.213334a34.133333 34.133333 0 0 1-47.786666-7.509334L505.685333 706.218667 288.426667 1005.226667a34.133333 34.133333 0 0 1-47.786667 7.509333l-110.421333-80.213333a34.133333 34.133333 0 0 1-7.509334-47.786667l214.186667-295.253333L29.013333 489.813333a34.133333 34.133333 0 0 1-22.016-43.008l42.154667-129.877333a34.133333 34.133333 0 0 1 43.008-22.016l320.512 104.106667L412.672 51.2c0-18.773333 15.36-34.133333 34.133333-34.133333h136.533334z"></path>
          </svg>
        )}
        {
   
   label || ''}
      </div>
      <div
        className={
   
   field || 'content'}
        style={
   
   Ctx.get('layout') === 'horizontal' ? {
   
    position: 'relative' } : {
   
   }}
      >
        {
   
   children}
        {
   
   disabled && <div className="form-item-disabled"></div>}
        {
   
   field && rules.length > 0 && <div className="hide-rule-label">{
   
   rules[0].message}</div>}
      </div>
    </div>
  );

可以看到,FormItem主要是根据rules、disabled在做处理,field可以先不看,这是受控表单相关的props,后面会说到。

const FormItem = (props: FormItemProps) => {
   
   
  const {
   
   
    children,
    style = {
   
   },
    label,
    wrapperCol = 0,
    wrapperTol = 0,
    field,
    rules = [],
    disabled = false,
  } = props;

  const [propsStyle, setPropsStyle] = useState({
   
   });
  const [labelStyle, setLabelStyle] = useState({
   
   });

  const Ctx = (function () {
   
   
    //创建一个ctx单例,防止组件内污染全局变量
    const c = useContext(ctx);
    return {
   
   
      get: (prop: string) => {
   
   
        return c[prop] || null;
      },
    };
  })();

  useEffect(() => {
   
   
    setPropsStyle({
   
    ...getPropsStyles(), ...style });
    setLabelStyle(getLabelPropsStyle());
  }, [props]);

  const getPropsStyles = useCallback(() => {
   
   
    //基于props,动态构建一个props style集合
    const formAttrs = new FormItemAttrs(wrapperCol, wrapperTol, Ctx.get('layout'));
    return formAttrs.getStyle();
  }, [wrapperCol, wrapperTol, Ctx.get('layout')]);
  const getLabelPropsStyle = useCallback(() => {
   
   
    //基于props,动态构建一个label props style集合
    const labelAttrs = new FormItemLabel(Ctx.get('layout'));
    return labelAttrs.getStyle();
  }, [Ctx.get('layout')]);

.......

}

逻辑部分的代码这是里使用react.useContext接受了Form的全局参数,因为一些布局相关是需要每个Item去配合的,所以这里写了两个useMemo的样式方法,内部的new FormItemAttrsz则是把样式相关的props传给一个类,具体的类是这样写的:

class FormItemAttrs {
   
   
  wrapperCol: number; //底部距离
  wrapperTol: number; //顶部距离
  layout: string; //表单布局形式

  constructor(wrapperCol: number, wrapperTol: number, layout: string) {
   
   
    this.wrapperCol = wrapperCol;
    this.wrapperTol = wrapperTol;
    this.layout = layout;
  }
  getStyle() {
   
   
    return {
   
   
      marginBottom: `${
     
     20 + this.wrapperCol}px`,
      marginTop: `${
     
     20 + this.wrapperTol}px`,
      ...this.formatLayout(),
    };
  }
  formatLayout() {
   
   
    let layoutStyle = {
   
   };
    switch (this.layout) {
   
   
      case 'horizontal':
        layoutStyle = {
   
   };
        break;
      case 'vertical':
        layoutStyle = {
   
   
          flexDirection: 'column',
          alignItems: 'flex-start',
        };
        break;
    }
    return layoutStyle;
  }
}

基于传来的各种props,做一个整体样式汇总,最后返回。
然后给Form加一个Item属性,内容就是Item组件:

import FormItem from './form-item';

...

Form.Item = FormItem;

export default Form;

就这样,一个简单的非受控表单就完成了,这样就会有一个问题,用户使用,每个FormItem的值只能用户自己控制,就像这样

export default function index1() {
   
   
  const [username, setUsername] = useState('小明');
  const changeVal = (e) => {
   
   
    setUsername(e.target.value);
  }

  return (
    <div>
      <Form layout={
   
   'vertical'} style={
   
   {
   
    width: '600px' }}>
        <Form.Item label="Username">
          <Input placeholder="Please enter your usename" width="200" value={
   
   username} onChange={
   
   (e) => changeVal(e)}></Input>
        </Form.Item>
        <Form.Item label="Post">
          <Input placeholder="Please enter your post" width="200"></Input>
        </Form.Item>
        <Form.Item wrapperTol={
   
   20}>
          <CheckBox checked={
   
   true}>I have read the manual</CheckBox>
        </Form.Item>
        <Form.Item wrapperTol={
   
   5}>
          <Button type="primary">Submit</Button>
        </Form.Item>
      </Form>
    </div>
  );
}

看到第一个Input,用户需要自己控制变量的改变,这样的Form其实就是展示性的作用了,并没有实际意义,接下来就来到了受控表单。

受控表单

受控表单大致思路是这样的:Form组件暴露给用户调用端一个方法集合,用户调用方法,如onSubmit、resetFields、useFormContext等可以提交、重置、校验表单,并且接受到Form处理完后的数据,比如哪些受控项校验失败、最终提交的结果是true or false等等,说白了就是把控制权完全的交给了Form组件,用户只需要在合适的时间做操作即可。

先看一段受控表单的代码:

export default function index1() {
   
   
  const form = Form.useForm(); //使用Form组件回传的hooks,调用组件内链方法
  const formRef = createRef(); //调用端设一个ref,保证单页面多表单唯一性

  const submit = () => {
   
   
    const submitParams = form.onSubmit(formRef);
    console.log(submitParams);
  };

  return (
    <div>
      <Form layout={
   
   'horizontal'} formField={
   
   formRef} style={
   
   {
   
    width: '600px' }}>
        <Form.Item
          label="Username"
          field="username"
          rules={
   
   [
            {
   
    required: true, message: '请输入用户名' },
            {
   
    maxLength: 10, message: '最大长度为10位' },
            {
   
    minLength: 3, message: '最小长度为3位' },
            {
   
    fn: (a: string) => a.includes('a'), message: '必须包含a' },
          ]}
        >
          <Input placeholder="Please enter your usename" width="200"></Input>
        </Form.Item>
        <Form.Item label="Post" field="post">
          <Input placeholder="Please enter your post" width="200"></Input>
        </Form.Item>
        <Form.Item label="Name" field="name" rules={
   
   [{
   
    required: true, message: '请输入名字' }]}>
          <Select option={
   
   option} width={
   
   200} placeholder={
   
   '请选择'} />
        </Form.Item>
        <Form.Item
          label="CreateTime"
          field="CreateTime"
          rules={
   
   [{
   
    required: true, message: '请输入名字' }]}
        >
          <TimePicker type="primary" showRange showClear />
        </Form.Item>
        <Form.Item wrapperTol={
   
   20}>
          <CheckBox checked={
   
   true}>I have read the manual</CheckBox>
        </Form.Item>
        <Form.Item wrapperTol={
   
   5}>
          <Button type="primary" handleClick={
   
   submit}>
            Submit
          </Button>
          <Button
            type="text"
            handleClick={
   
   () => form.resetFields(formRef)}
            style={
   
   {
   
    margin: '0 10px' }}
          >
            Reset
          </Button>
        </Form.Item>
      </Form>
    </div>
  );

对比非受控表单有这些新增点:

  1. 有些Form.Item多了field属性值,这就是给每一行做唯一标识的值,很重要;
  2. 有些Form.Item多了rules属性值,这是用于校验的,很重要;
  3. 使用createRef创建了一个ref,并传给了Form,这是让Form辨识指挥Form的是哪一个表单(以防单页面多表单混乱);
  4. Form.useForm,用于调用Form提供的一系列方法,上文所说到,并且传参都是上一点的ref;

而受控主要都是在Form中所做的。

先看一下Form.tsx中的关键代码:

  useEffect(() => {
   
   
    collectFormFns.onSubmit = onSubmit;
    collectFormFns.resetFields = resetFields;
    collectFormFns.validateFields = validateFields;
    collectFormFns.useFormContext = useFormContext;
    collectFormFns.formRef = formField;
  }, [fieldList]);

这就是回传给用户Form.useForm hook的方法集合,可以用来调用,而这些方法是怎么写出来的呢?Form在渲染的时候会根据children属性(所有FormItem)中收集各自prop值,并且判断,如果有field则该Item需要被控制,收集该Item的rules等参数。

const [fieldList, setFieldList] = useState<any>({
   
   });
...
useEffect(() => {
   
   
   const fieldL: any = {
   
   };
   children.forEach((child: any) => {
   
   
     if (child.props.field) {
   
   
       const key = child.props.field;
       fieldL[key] = {
   
   };
       fieldL[key].rules = child.props.rules || null;
     }
   });
   setFieldList(fieldL);
 }, []);

实现代码是这样的。

有了fieldList,其实接下来就是编写hook中的函数,并且在用户调用函数时收集FormItem实时的参数,代码如下:

const outputFormData = (ref: Ref<T> | null) => {
   
   
    //生成表体内容
    const returnField: any = {
   
   };
    let fieldType = '';
    for (var key in fieldList) {
   
   
      getDomVal((ref as any).current.querySelector(` .form-item .${
     
     key}`), key);
    }
    function getDomVal(dom: any, field: string) {
   
   
      if (dom?.childNodes.length === 0) {
   
   
        if (fieldType === 'input') {
   
   
          returnField[field] = dom.value;
        } else if (fieldType === 'select') {
   
   
          if (dom.parentNode.getAttribute('class') === 'placeholder') {
   
   
            returnField[field] = '';
          } else {
   
   
            returnField[field] = dom.parentNode.innerText;
          }
        }
        fieldType = '';
      } else {
   
   
        if (dom !== null) {
   
   
          if (fieldType === '') {
   
   
            switch (dom.getAttribute('class')) {
   
   
              case 'select':
                fieldType = 'select';
                break;
              case 'box':
                fieldType = 'input';
                break;
            }
          }
          getDomVal(dom.childNodes[0], field);
        }
      }
    }
    return returnField;
  }
  const onSubmit = (ref: Ref<T> | null) => {
   
   
    //表单提交
    const result = outputFormData(ref);
    const ruleResult = validateFields(result, ref);
    if (Object.keys(ruleResult).length > 0) {
   
   
      return {
   
    ...{
   
    submitResult: false }, ruleResult };
    }
    return {
   
    ...{
   
    submitResult: true }, result };
  };

  const validateFields = (resultField: any, ref: Ref<T> | null) => {
   
   
    //表单校验
    //表单校验
    if (resultField === undefined) {
   
   
      resultField = outputFormData(ref);
    }
    const resultRules: any = {
   
   };
    for (var key in resultField) {
   
   
      const field = fieldList[key];
      if (field.rules) {
   
   
        let isPass = true;
        const rules = fieldList[key].rules;
        rules.forEach((rule: ruleType) => {
   
   
          if (rule.required && resultField[key] == '' && isPass) {
   
   
            isPass = false;
            changeValidateText(` .form-item .${
     
     key}`, rule.message, key, ref);
          } else if (rule.maxLength && resultField[key].length > rule.maxLength && isPass) {
   
   
            isPass = false;
            changeValidateText(` .form-item .${
     
     key}`, rule.message, key, ref);
          } else if (rule.minLength && resultField[key].length < rule.minLength && isPass) {
   
   
            isPass = false;
            changeValidateText(` .form-item .${
     
     key}`, rule.message, key, ref);
          } else {
   
   
            if (rule.fn && !rule.fn(resultField[key])) {
   
   
              isPass = false;
              changeValidateText(` .form-item .${
     
     key}`, rule.message, key, ref);
            }
          }
          if (
            isPass &&
            (ref as any).current.querySelector(` .form-item .${
     
     key} .show-rule-label`)
          ) {
   
   
            (ref as any).current
              .querySelector(` .form-item .${
     
     key} .show-rule-label`)
              ?.setAttribute('class', 'hide-rule-label');
          }
        });
      }
    }
    function changeValidateText(
      className: string,
      text: string,
      field: string,
      ref: Ref<T | unknown> | null,
    ) {
   
   
      resultRules[field] = text;
      const hideDom = (ref as any).current.querySelector(
        `${
     
     className} .hide-rule-label`,
      ) as HTMLElement;
      const showDom = (ref as any).current.querySelector(
        `${
     
     className} .show-rule-label`,
      ) as HTMLElement;
      if (hideDom) {
   
   
        hideDom.innerText = text;
      } else {
   
   
        showDom.innerText = text;
      }
      hideDom?.setAttribute('class', 'show-rule-label');
    }
    return resultRules;
  };
  const resetFields = (ref: Ref<T | unknown> | null) => {
   
   
    //重置表单
    let fieldType = '';
    for (var key in fieldList) {
   
   
      getDomVal((ref as any).current.querySelector(` .form-item .${
     
     key}`), key);
    }
    function getDomVal(dom: any, field: string) {
   
   
      if (dom?.childNodes.length === 0) {
   
   
        if (fieldType === 'input') {
   
   
          dom.value = '';
        } else if (fieldType === 'select' && (ref as any).current.querySelector('.size') !== null) {
   
   
          ((ref as any).current.querySelector('.size') as HTMLElement).innerText = '请选择';
          (ref as any).current.querySelector('.size')?.setAttribute('class', 'placeholder');
        } else if (fieldType === 'datePicker') {
   
   
          const datePickerInputs = (ref as any).current.querySelectorAll('.rangePicker input');
          datePickerInputs[0].value = getNowTime(false).split(' ')[0];
          if (datePickerInputs.length === 2) {
   
   
            const endDay: Array<string | number> = getNowTime(false).split(' ')[0].split('-');
            endDay[1] = (Number(endDay[1]) + 1) as number;
            datePickerInputs[1].value = endDay.join('-');
          }
        }
        fieldType = '';
      } else {
   
   
        if (dom !== null) {
   
   
          if (fieldType === '') {
   
   
            switch (dom.getAttribute('class')) {
   
   
              case 'select':
                fieldType = 'select';
                break;
              case 'box':
                fieldType = 'input';
                break;
              case 'rangePicker':
                fieldType = 'datePicker';
            }
          }
          getDomVal(dom.childNodes[0], field);
        }
      }
    }
  };
  const useFormContext = (ref: Ref<T> | null) => {
   
   
    return outputFormData(ref);
  };

就这样,受控表单完成了,整个表单的交互性也很强了,就像图中这样:

在这里插入图片描述

源码

Form.tsx:

import React, {
   
    createContext, Ref, useEffect, useState } from 'react';
import FormItem from './form-item';
import {
   
    FormProps, ruleType } from './interface';
import './styles/index.module.less';
import {
   
    getNowTime } from '../_util/getNowTime';

export const ctx = createContext<any>({
   
   } as any); //顶层通信装置

export interface FormComponent {
   
   
  Item: typeof FormItem;
}
export interface FromRefFunctions {
   
   
  formRef: string;
  onSubmit: Function;
  resetFields: Function;
  validateFields: Function;
  useFormContext: Function;
}
export type fieldListType = {
   
   
  rules?: Array<any>;
  field?: string;
};
const collectFormFns: FromRefFunctions = {
   
   
  formRef: '',
  onSubmit: () => {
   
   },
  resetFields: () => {
   
   },
  validateFields: () => {
   
   },
  useFormContext: () => {
   
   },
};

const Form = <T,>(props: FormProps<T>) => {
   
   
  const {
   
    children, layout = 'horizontal', style, formField, disabled } = props;

  const [fieldList, setFieldList] = useState<any>({
   
   });

  //根组件状态管理,向下传入
  const providerList = {
   
   
    layout,
  };

  const outputFormData = (ref: Ref<T> | null) => {
   
   
    //生成表体内容
    const returnField: any = {
   
   };
    let fieldType = '';
    for (var key in fieldList) {
   
   
      getDomVal((ref as any).current.querySelector(` .form-item .${
     
     key}`), key);
    }
    function getDomVal(dom: any, field: string) {
   
   
      if (dom?.childNodes.length === 0) {
   
   
        if (fieldType === 'input') {
   
   
          returnField[field] = dom.value;
        } else if (fieldType === 'select') {
   
   
          if (dom.parentNode.getAttribute('class') === 'placeholder') {
   
   
            returnField[field] = '';
          } else {
   
   
            returnField[field] = dom.parentNode.innerText;
          }
        }
        fieldType = '';
      } else {
   
   
        if (dom !== null) {
   
   
          if (fieldType === '') {
   
   
            switch (dom.getAttribute('class')) {
   
   
              case 'select':
                fieldType = 'select';
                break;
              case 'box':
                fieldType = 'input';
                break;
            }
          }
          getDomVal(dom.childNodes[0], field);
        }
      }
    }
    return returnField;
  }
  const onSubmit = (ref: Ref<T> | null) => {
   
   
    //表单提交
    const result = outputFormData(ref);
    const ruleResult = validateFields(result, ref);
    if (Object.keys(ruleResult).length > 0) {
   
   
      return {
   
    ...{
   
    submitResult: false }, ruleResult };
    }
    return {
   
    ...{
   
    submitResult: true }, result };
  };

  const validateFields = (resultField: any, ref: Ref<T> | null) => {
   
   
    //表单校验
    //表单校验
    if (resultField === undefined) {
   
   
      resultField = outputFormData(ref);
    }
    const resultRules: any = {
   
   };
    for (var key in resultField) {
   
   
      const field = fieldList[key];
      if (field.rules) {
   
   
        let isPass = true;
        const rules = fieldList[key].rules;
        rules.forEach((rule: ruleType) => {
   
   
          if (rule.required && resultField[key] == '' && isPass) {
   
   
            isPass = false;
            changeValidateText(` .form-item .${
     
     key}`, rule.message, key, ref);
          } else if (rule.maxLength && resultField[key].length > rule.maxLength && isPass) {
   
   
            isPass = false;
            changeValidateText(` .form-item .${
     
     key}`, rule.message, key, ref);
          } else if (rule.minLength && resultField[key].length < rule.minLength && isPass) {
   
   
            isPass = false;
            changeValidateText(` .form-item .${
     
     key}`, rule.message, key, ref);
          } else {
   
   
            if (rule.fn && !rule.fn(resultField[key])) {
   
   
              isPass = false;
              changeValidateText(` .form-item .${
     
     key}`, rule.message, key, ref);
            }
          }
          if (
            isPass &&
            (ref as any).current.querySelector(` .form-item .${
     
     key} .show-rule-label`)
          ) {
   
   
            (ref as any).current
              .querySelector(` .form-item .${
     
     key} .show-rule-label`)
              ?.setAttribute('class', 'hide-rule-label');
          }
        });
      }
    }
    function changeValidateText(
      className: string,
      text: string,
      field: string,
      ref: Ref<T | unknown> | null,
    ) {
   
   
      resultRules[field] = text;
      const hideDom = (ref as any).current.querySelector(
        `${
     
     className} .hide-rule-label`,
      ) as HTMLElement;
      const showDom = (ref as any).current.querySelector(
        `${
     
     className} .show-rule-label`,
      ) as HTMLElement;
      if (hideDom) {
   
   
        hideDom.innerText = text;
      } else {
   
   
        showDom.innerText = text;
      }
      hideDom?.setAttribute('class', 'show-rule-label');
    }
    return resultRules;
  };
  const resetFields = (ref: Ref<T | unknown> | null) => {
   
   
    //重置表单
    let fieldType = '';
    for (var key in fieldList) {
   
   
      getDomVal((ref as any).current.querySelector(` .form-item .${
     
     key}`), key);
    }
    function getDomVal(dom: any, field: string) {
   
   
      if (dom?.childNodes.length === 0) {
   
   
        if (fieldType === 'input') {
   
   
          dom.value = '';
        } else if (fieldType === 'select' && (ref as any).current.querySelector('.size') !== null) {
   
   
          ((ref as any).current.querySelector('.size') as HTMLElement).innerText = '请选择';
          (ref as any).current.querySelector('.size')?.setAttribute('class', 'placeholder');
        } else if (fieldType === 'datePicker') {
   
   
          const datePickerInputs = (ref as any).current.querySelectorAll('.rangePicker input');
          datePickerInputs[0].value = getNowTime(false).split(' ')[0];
          if (datePickerInputs.length === 2) {
   
   
            const endDay: Array<string | number> = getNowTime(false).split(' ')[0].split('-');
            endDay[1] = (Number(endDay[1]) + 1) as number;
            datePickerInputs[1].value = endDay.join('-');
          }
        }
        fieldType = '';
      } else {
   
   
        if (dom !== null) {
   
   
          if (fieldType === '') {
   
   
            switch (dom.getAttribute('class')) {
   
   
              case 'select':
                fieldType = 'select';
                break;
              case 'box':
                fieldType = 'input';
                break;
              case 'rangePicker':
                fieldType = 'datePicker';
            }
          }
          getDomVal(dom.childNodes[0], field);
        }
      }
    }
  };
  const useFormContext = (ref: Ref<T> | null) => {
   
   
    return outputFormData(ref);
  };
  useEffect(() => {
   
   
    const fieldL: any = {
   
   };
    children.forEach((child: any) => {
   
   
      if (child.props.field) {
   
   
        const key = child.props.field;
        fieldL[key] = {
   
   };
        fieldL[key].rules = child.props.rules || null;
      }
    });
    setFieldList(fieldL);
  }, []);
  useEffect(() => {
   
   
    collectFormFns.onSubmit = onSubmit;
    collectFormFns.resetFields = resetFields;
    collectFormFns.validateFields = validateFields;
    collectFormFns.useFormContext = useFormContext;
    collectFormFns.formRef = formField;
  }, [fieldList]);

  return (
    <ctx.Provider value={
   
   providerList}>
      <div className="form" style={
   
   style} ref={
   
   formField || null}>
        {
   
   disabled && <div className="disabled" />}
        {
   
   children}
      </div>
    </ctx.Provider>
  );
};

Form.Item = FormItem;
Form.useForm = () => {
   
   
  return collectFormFns;
};

export default Form;

FormItem.tsx:

import React, {
   
    useEffect, useState, useCallback, useContext, createRef } from 'react';
import {
   
    FormItemProps } from './interface';
import {
   
    FormItemAttrs, FormItemLabel } from './classes';
import {
   
    ctx } from './index';
import './styles/form-item.module.less';

const FormItem = (props: FormItemProps) => {
   
   
  const {
   
   
    children,
    style = {
   
   },
    label,
    wrapperCol = 0,
    wrapperTol = 0,
    field,
    rules = [],
    disabled = false,
  } = props;

  const [propsStyle, setPropsStyle] = useState({
   
   });
  const [labelStyle, setLabelStyle] = useState({
   
   });

  const Ctx = (function () {
   
   
    //创建一个ctx单例,防止组件内污染全局变量
    const c = useContext(ctx);
    return {
   
   
      get: (prop: string) => {
   
   
        return c[prop] || null;
      },
    };
  })();

  useEffect(() => {
   
   
    setPropsStyle({
   
    ...getPropsStyles(), ...style });
    setLabelStyle(getLabelPropsStyle());
  }, [props]);

  const getPropsStyles = useCallback(() => {
   
   
    //基于props,动态构建一个props style集合
    const formAttrs = new FormItemAttrs(wrapperCol, wrapperTol, Ctx.get('layout'));
    return formAttrs.getStyle();
  }, [wrapperCol, wrapperTol, Ctx.get('layout')]);
  const getLabelPropsStyle = useCallback(() => {
   
   
    //基于props,动态构建一个label props style集合
    const labelAttrs = new FormItemLabel(Ctx.get('layout'));
    return labelAttrs.getStyle();
  }, [Ctx.get('layout')]);

  return (
    <div className="form-item" style={
   
   propsStyle}>
      <div className="label" style={
   
   labelStyle}>
        {
   
   rules.length > 0 && (
          <svg fill="currentColor" viewBox="0 0 1024 1024" width="0.5em" height="0.5em">
            <path d="M583.338667 17.066667c18.773333 0 34.133333 15.36 34.133333 34.133333v349.013333l313.344-101.888a34.133333 34.133333 0 0 1 43.008 22.016l42.154667 129.706667a34.133333 34.133333 0 0 1-21.845334 43.178667l-315.733333 102.4 208.896 287.744a34.133333 34.133333 0 0 1-7.509333 47.786666l-110.421334 80.213334a34.133333 34.133333 0 0 1-47.786666-7.509334L505.685333 706.218667 288.426667 1005.226667a34.133333 34.133333 0 0 1-47.786667 7.509333l-110.421333-80.213333a34.133333 34.133333 0 0 1-7.509334-47.786667l214.186667-295.253333L29.013333 489.813333a34.133333 34.133333 0 0 1-22.016-43.008l42.154667-129.877333a34.133333 34.133333 0 0 1 43.008-22.016l320.512 104.106667L412.672 51.2c0-18.773333 15.36-34.133333 34.133333-34.133333h136.533334z"></path>
          </svg>
        )}
        {
   
   label || ''}
      </div>
      <div
        className={
   
   field || 'content'}
        style={
   
   Ctx.get('layout') === 'horizontal' ? {
   
    position: 'relative' } : {
   
   }}
      >
        {
   
   children}
        {
   
   disabled && <div className="form-item-disabled"></div>}
        {
   
   field && rules.length > 0 && <div className="hide-rule-label">{
   
   rules[0].message}</div>}
      </div>
    </div>
  );
};

export default FormItem;

具体的引入方法博主就不贴了,有想了解和使用的可以看下github哈~

组件测试

使用jest测试上述组件文档每个场景的业务正确性,具体测试代码Form.test.tsx如下:

import React, {
   
    createRef } from 'react';
import Form from '../../Form/index';
import Input from '../../Input';
import CheckBox from '../../CheckBox';
import Button from '../../Button';
import Select from '../../Select';
import Enzyme from '../setup';
import mountTest from '../mountTest';

const option = [
  {
   
   
    label: 'Mucy',
    value: 'mucy',
  },
  {
   
   
    label: 'Mike',
    value: 'mike',
  },
  {
   
   
    label: 'aMck',
    value: 'amck',
  },
];

const {
   
    mount } = Enzyme;

mountTest(Form);

describe('Form', () => {
   
   
  it('test base form show correctly', () => {
   
   
    const component = mount(
      <Form layout={
   
   'vertical'} style={
   
   {
   
    width: '600px' }}>
        <Form.Item label="Username">
          <Input placeholder="Please enter your usename" width="200"></Input>
        </Form.Item>
        <Form.Item label="Post">
          <Input placeholder="Please enter your post" width="200"></Input>
        </Form.Item>
        <Form.Item wrapperTol={
   
   20}>
          <CheckBox checked={
   
   true}>I have read the manual</CheckBox>
        </Form.Item>
        <Form.Item wrapperTol={
   
   5}>
          <Button type="primary">Submit</Button>
        </Form.Item>
      </Form>,
    );
    const formDom = component.find('.form');
    expect(component.find('.form')).toHaveLength(1);
    expect(formDom.getDOMNode().getAttribute('style')).toEqual('width: 600px;');
    expect(component.find('.form .form-item')).toHaveLength(4);
    expect(
      component
        .find('.form .form-item')
        .at(2)
        .getDOMNode()
        .getAttribute('style')
        ?.includes('margin-bottom: 20px; margin-top: 40px;'),
    ).toBe(true);
    expect(
      component
        .find('.form .form-item')
        .at(3)
        .getDOMNode()
        .getAttribute('style')
        ?.includes('margin-bottom: 20px; margin-top: 25px;'),
    ).toBe(true);
  });
  it('test layout form show correctly', () => {
   
   
    const component = mount(
      <Form layout={
   
   'horizontal'} style={
   
   {
   
    width: '600px' }}>
        <Form.Item label="Username">
          <Input placeholder="Please enter your usename" width="200"></Input>
        </Form.Item>
        <Form.Item label="Post">
          <Input placeholder="Please enter your post" width="200"></Input>
        </Form.Item>
        <Form.Item wrapperTol={
   
   20}>
          <CheckBox checked={
   
   true}>I have read the manual</CheckBox>
        </Form.Item>
        <Form.Item wrapperTol={
   
   5}>
          <Button type="primary">Submit</Button>
        </Form.Item>
      </Form>,
    );
    expect(
      component
        .find('.form .form-item')
        .at(0)
        .getDOMNode()
        .getAttribute('style')
        ?.includes('flex-direction: column; align-items: flex-start;'),
    ).toBe(false);
  });
  it('test control form use correctly', () => {
   
   
    const mockSubmitFn = jest.fn();
    const form = Form.useForm();
    const formRef = createRef();

    const component = mount(
      <Form layout={
   
   'vertical'} formField={
   
   formRef} style={
   
   {
   
    width: '600px' }}>
        <Form.Item
          label="Username"
          field="username"
          rules={
   
   [
            {
   
    required: true, message: '请输入用户名' },
            {
   
    maxLength: 10, message: '最大长度为10位' },
            {
   
    minLength: 3, message: '最小长度为3位' },
          ]}
        >
          <Input placeholder="Please enter your usename" width="200"></Input>
        </Form.Item>
        <Form.Item label="Post" field="post">
          <Input placeholder="Please enter your post" width="200"></Input>
        </Form.Item>
        <Form.Item label="Name" field="name" rules={
   
   [{
   
    required: true, message: '请输入名字' }]}>
          <Select option={
   
   option} width={
   
   200} placeholder={
   
   '请选择'} />
        </Form.Item>
        <Form.Item wrapperTol={
   
   20}>
          <CheckBox checked={
   
   true}>I have read the manual</CheckBox>
        </Form.Item>
        <Form.Item wrapperTol={
   
   5}>
          <Button type="primary" handleClick={
   
   mockSubmitFn}>
            Submit
          </Button>
        </Form.Item>
      </Form>,
    );
    component.find('.form .form-item').at(4).find('button').simulate('click');
    expect(mockSubmitFn).toBeCalled();
    const {
   
    submitResult, ruleResult } = form.onSubmit(formRef);
    const {
   
    name, username } = ruleResult;
    expect(name).toEqual('请输入名字');
    expect(username).toEqual('请输入用户名');
    expect(submitResult).toEqual(false);
  });
  it('test form reset correctly', () => {
   
   
    const form = Form.useForm();
    const formRef = createRef();
    const mockResetFn = jest.fn();

    const component = mount(
      <Form layout={
   
   'horizontal'} formField={
   
   formRef} style={
   
   {
   
    width: '600px' }}>
        <Form.Item
          label="Username"
          field="username"
          rules={
   
   [
            {
   
    required: true, message: '请输入用户名' },
            {
   
    maxLength: 10, message: '最大长度为10位' },
            {
   
    minLength: 3, message: '最小长度为3位' },
          ]}
        >
          <Input placeholder="Please enter your usename" width="200"></Input>
        </Form.Item>
        <Form.Item label="Post" field="post">
          <Input placeholder="Please enter your post" width="200"></Input>
        </Form.Item>
        <Form.Item label="Name" field="name" rules={
   
   [{
   
    required: true, message: '请输入名字' }]}>
          <Select option={
   
   option} width={
   
   200} placeholder={
   
   '请选择'} />
        </Form.Item>
        <Form.Item wrapperTol={
   
   20}>
          <CheckBox checked={
   
   true}>I have read the manual</CheckBox>
        </Form.Item>
        <Form.Item wrapperTol={
   
   5}>
          <Button type="text" handleClick={
   
   mockResetFn} style={
   
   {
   
    margin: '0 10px' }}>
            Reset
          </Button>
        </Form.Item>
      </Form>,
    );
    component
      .find('.form .form-item')
      .at(0)
      .find('input')
      .simulate('change', {
   
   
        target: {
   
   
          value: '123',
        },
      });
    component.find('.form .form-item').at(4).find('button').simulate('click');
    expect(mockResetFn).toBeCalled();
    form.resetFields(formRef);
    expect(
      component.find('.form .form-item').at(0).find('input').getDOMNode().getAttribute('value'),
    );
  });

  it('test global disabled form show correctly', () => {
   
   
    const component = mount(
      <Form layout={
   
   'vertical'} disabled style={
   
   {
   
    width: '600px' }}>
        <Form.Item
          label="Username"
          field="username"
          rules={
   
   [
            {
   
    required: true, message: '请输入用户名' },
            {
   
    maxLength: 10, message: '最大长度为10位' },
            {
   
    minLength: 3, message: '最小长度为3位' },
          ]}
        >
          <Input placeholder="Please enter your usename" width="200"></Input>
        </Form.Item>
        <Form.Item label="Post" field="post">
          <Input placeholder="Please enter your post" width="200"></Input>
        </Form.Item>
        <Form.Item label="Name" field="name" rules={
   
   [{
   
    required: true, message: '请输入名字' }]}>
          <Select option={
   
   option} width={
   
   200} placeholder={
   
   '请选择'} />
        </Form.Item>
        <Form.Item wrapperTol={
   
   20}>
          <CheckBox checked={
   
   true}>I have read the manual</CheckBox>
        </Form.Item>
        <Form.Item wrapperTol={
   
   5}>
          <Button type="primary">Submit</Button>
        </Form.Item>
      </Form>,
    );
    expect(component.find('.form .disabled')).toHaveLength(1);
  });

  it('test Form.Item disabled form show correctly', () => {
   
   
    const component = mount(
      <Form layout={
   
   'vertical'} style={
   
   {
   
    width: '600px' }}>
        <Form.Item
          label="Username"
          field="username"
          rules={
   
   [
            {
   
    required: true, message: '请输入用户名' },
            {
   
    maxLength: 10, message: '最大长度为10位' },
            {
   
    minLength: 3, message: '最小长度为3位' },
            {
   
    fn: (a: string) => a.includes('a'), message: '必须包含a' },
          ]}
        >
          <Input placeholder="Please enter your usename" width="200"></Input>
        </Form.Item>
        <Form.Item label="Post" field="post" disabled>
          <Input placeholder="Please enter your post" width="200"></Input>
        </Form.Item>
        <Form.Item label="Name" field="name" rules={
   
   [{
   
    required: true, message: '请输入名字' }]}>
          <Select option={
   
   option} width={
   
   200} placeholder={
   
   '请选择'} />
        </Form.Item>
        <Form.Item wrapperTol={
   
   20}>
          <CheckBox checked={
   
   true}>I have read the manual</CheckBox>
        </Form.Item>
        <Form.Item wrapperTol={
   
   5}>
          <Button type="primary">Submit</Button>
        </Form.Item>
      </Form>,
    );
    expect(component.find('.form .form-item').at(1).find('.form-item-disabled')).toHaveLength(1);
  });

  it('test useFormContext api correctly', () => {
   
   
    const form = Form.useForm();
    const formRef = createRef();
    const mockFn = jest.fn();

    const component = mount(
      <Form layout={
   
   'vertical'} formField={
   
   formRef} style={
   
   {
   
    width: '600px' }}>
        <Form.Item
          field="username"
          rules={
   
   [
            {
   
    required: true, message: '请输入用户名' },
            {
   
    maxLength: 10, message: '最大长度为10位' },
            {
   
    minLength: 3, message: '最小长度为3位' },
            {
   
    fn: (a: string) => a.includes('a'), message: '必须包含a' },
          ]}
        >
          <Input placeholder="Please enter your usename" width="240"></Input>
        </Form.Item>
        <Form.Item field="phone" rules={
   
   [{
   
    required: true, message: '请输入手机号' }]}>
          <Input placeholder="Please enter your phone number" width="240"></Input>
        </Form.Item>
        <Form.Item wrapperTol={
   
   5}>
          <Button type="primary" handleClick={
   
   mockFn}>
            Register
          </Button>
        </Form.Item>
      </Form>,
    );
    component
      .find('.form .form-item')
      .at(0)
      .find('input')
      .simulate('change', {
   
   
        target: {
   
   
          value: '123',
        },
      });
    component
      .find('.form .form-item')
      .at(1)
      .find('input')
      .simulate('change', {
   
   
        target: {
   
   
          value: '123',
        },
      });
    component.find('.form .form-item').at(2).find('button').simulate('click');
    expect(mockFn).toBeCalled();
    const {
   
    username, phone } = form.useFormContext(formRef);
    expect(username).toEqual('123');
    expect(phone).toEqual('123');
  });
});

组件库地址

开源不易,欢迎学习和体验,喜欢请多多支持,有问题请留言,谢谢支持

目录
相关文章
|
2月前
|
移动开发 前端开发 JavaScript
React 表单与事件
10月更文挑战第10天
44 1
|
3月前
|
前端开发 JavaScript 网络架构
react对antd中Select组件二次封装
本文介绍了如何在React中对Ant Design(antd)的Select组件进行二次封装,包括创建MSelect组件、定义默认属性、渲染Select组件,并展示了如何使用Less进行样式定义和如何在项目中使用封装后的Select组件。
120 2
react对antd中Select组件二次封装
|
15天前
|
存储 前端开发 JavaScript
React 表单输入组件 Input:常见问题、易错点及解决方案
本文介绍了在 React 中使用表单输入组件 `Input` 的基础概念,包括受控组件与非受控组件的区别及其优势。通过具体代码案例,详细探讨了创建受控组件、处理多个输入字段、输入验证和格式化的方法,并指出了常见易错点及避免方法,旨在提升表单的健壮性和用户体验。
27 4
|
2月前
|
前端开发 JavaScript 数据安全/隐私保护
深入探索研究React表单
【10月更文挑战第6天】
91 57
|
1月前
|
前端开发 JavaScript
React 表单处理技巧
【10月更文挑战第24天】本文从初学者角度出发,详细介绍了 React 中表单处理的基本概念、常见问题及解决方案。涵盖受控组件与非受控组件的区别、状态更新、表单验证、多字段管理及高级技巧,通过代码示例帮助读者更好地理解和应用。
81 7
|
3月前
|
前端开发
React添加路径别名alias、接受props默认值、并二次封装antd中Modal组件与使用
本文介绍了在React项目中如何添加路径别名alias以简化模块引入路径,设置组件props的默认值,以及如何二次封装Ant Design的Modal组件。文章还提供了具体的代码示例,包括配置Webpack的alias、设置defaultProps以及封装Modal组件的步骤和方法。
86 1
React添加路径别名alias、接受props默认值、并二次封装antd中Modal组件与使用
|
2月前
|
前端开发
react 封装防抖
react 封装防抖
35 4
|
2月前
|
移动开发 JSON 数据可视化
精选八款包括可视化CMS,jquery可视化表单,vue可视化拖拉,react可视化源码
精选八款包括可视化CMS,jquery可视化表单,vue可视化拖拉,react可视化源码
59 0
|
3月前
封装react-antd-table组件参数以及方法如rowSelection、pageNum、pageSize、分页方法等等
文章介绍了如何封装React-Antd的Table组件,包括参数和方法,如行选择(rowSelection)、页码(pageNum)、页面大小(pageSize)、分页方法等,以简化在不同表格组件中的重复代码。
77 0
|
3月前
|
人工智能 前端开发 JavaScript
react js 处理表单( form )的2个例子
react js 处理表单( form )的2个例子