欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

如何使用React从头开始创建一个表单组件(Form)指南

最编程 2024-02-20 22:31:12
...

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

前言

博主最近在做组件库封装开发的工作,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).
						

上一篇: 在VXETable中嵌套ElementPlus Form时遇到的表单验证问题及其解决方案探析

下一篇: 如何使用OkHttp3进行GET和POST请求:包括JSON参数传输与表单数据提交示例