美文网首页
无星的前端之旅(二十一)—— 表单封装

无星的前端之旅(二十一)—— 表单封装

作者: 无星灬 | 来源:发表于2021-06-16 13:29 被阅读0次

    背景

    我们做的是后台类型的管理系统,因此相对应的表单就会很多。

    相信做过类似项目的老哥懂得都懂。

    因此我们希望能够通过一些相对简单的配置方式生成表单,不再需要写一大堆的组件。

    尽量通过数据驱动。

    思路

    不管是哪个平台,思路都是相通的。

    1.基于UI框架封装

    react我们基于antd封装。

    vue我们基于element封装。

    这两个框架下的表单,几乎都满足了我们对表单的需要,只是需要写那么多标签代码,让人感到厌倦。

    2.如何根据数据驱动

    想要简化标签,首先就需要约定数据格式,什么样类型的数据渲染什么样的标签。

    那么我可以暂定,需要一个type,去做判断,渲染什么样的表单内容标签(是的,if判断,没有那么多花里胡哨,最朴实无华的代码就能满足我们的需求)

    3.确定需要渲染的标签

    业务中其实常用的表单标签就如下几类:

    • select
    • checkbox
    • radio
    • input(包括各个类型的,passwordtextarea之类的)
    • switch

    等等,需要再加

    4.类型需要传递下去

    需要把表单可能用到的属性传递下去。

    实现

    因为我们在vue和react上都有,所以我会给出两个框架的封装代码。

    Vue

    我使用的是vue3+element-plus

    封装两个组件,Form和FormItem

    代码如下:

    Form

    <!-- Form/index.vue-->
    <template>
      <el-form :ref="setFormRef" :model="form" label-width="80px">
        <el-form-item
          v-for="(item, index) in needs"
          :key="index"
          :prop="item.prop"
          :label="item.label"
          :rules="item.rules"
        >
          <!-- 内容 -->
          <FormItem
            v-model="form[item.prop]"
            :type="item.type"
            placeholder="请输入内容"
            :options="item.options || []"
            :disabled="item.disabled"
            v-bind="item"
          />
        </el-form-item>
      </el-form>
    </template>
    <script>
    import { defineComponent, computed, watch } from 'vue';
    import FormItem from '../FormItem/index.vue';
    
    export default defineComponent({
      components: {
        FormItem,
      },
      props: {
        // 需要写的表单内容
        needs: {
          type: Array,
          default: () => [],
        },
        // 已知的表单内容
        modelValue: {
          type: Object,
          default: () => {},
        },
        instance: {
          type: Object,
          default: () => {},
        },
      },
      emits: ['update:modelValue', 'update:instance'],
      setup(props, context) {
        const form = computed({
          get: () => props.modelValue,
          set: (val) => {
            console.log('变化');
            context.emit('update:modelValue', val);
          },
        });
        const setFormRef = (el) => {
          context.emit('update:instance', el);
        };
        // 变化触发更新
        watch(form, (newValue) => {
          context.emit('update:modelValue', newValue);
        });
        return { form, setFormRef };
      },
    });
    </script>
    
    

    FormItem

    <!-- FormItem/index.vue-->
    <template>
      <el-input v-if="type === 'input'" clearable v-model="value" v-bind="$attrs" :class="propsClass" />
      <el-input
        v-else-if="type === 'password'"
        type="password"
        clearable
        v-model="value"
        v-bind="$attrs"
        :class="propsClass"
      />
      <el-radio-group
        v-else-if="type === 'radio'"
        v-model="value"
        v-bind="$attrs"
        :disabled="disabled"
        :class="propsClass"
      >
        <el-radio
          v-for="(item, index) in options"
          :key="index"
          :label="item.value"
          :disabled="item.disabled"
        >
          {{ item.label }}
        </el-radio>
      </el-radio-group>
      <el-checkbox-group
        v-else-if="type === 'checkbox'"
        v-model="value"
        v-bind="$attrs"
        :disabled="disabled"
        :class="propsClass"
      >
        <el-checkbox
          v-for="(item, index) in options"
          :key="index"
          :label="item.value"
          :disabled="item.disabled"
        >
          {{ item.label }}
        </el-checkbox>
      </el-checkbox-group>
      <el-input
        v-else-if="type === 'textarea'"
        type="textarea"
        clearable
        v-model="value"
        v-bind="$attrs"
        :class="propsClass"
      />
      <el-select
        v-else-if="type === 'select'"
        clearable
        v-model="value"
        v-bind="$attrs"
        :disabled="disabled"
        :class="propsClass"
      >
        <el-option
          v-for="(item, index) in options"
          :key="index"
          :label="item.label"
          :disabled="item.disabled"
          :value="item.value"
        />
      </el-select>
      <el-switch v-else-if="type === 'switch'" v-model="value" v-bind="$attrs" :class="propsClass" />
      <el-time-select
        v-else-if="type === 'timeSelect'"
        v-model="value"
        v-bind="$attrs"
        :disabled="disabled"
        :class="propsClass"
      />
    </template>
    
    <script>
    import { defineComponent, computed, watchEffect } from 'vue';
    
    export default defineComponent({
      name: 'FormItem',
      props: {
        // 需要绑定的值
        modelValue: {
          type: [String, Boolean, Number, Array],
          default: '',
        },
        // 传递下来的class
        propsClass: {
          type: String,
          default: '',
        },
        /**
         * 表单的类型 radio 单选 checkbox 多选 input 输入 select 选择 cascader 卡片 switch 切换 timeSelect 时间选择
         * @values radio, checkbox, input, select, cascader, switch, timeSelect,
         */
        type: {
          type: String,
          default: '',
          require: true,
        },
        // {value,disabled,source}
        options: {
          type: Array,
          default: () => [{}],
        },
        disabled: {
          type: Boolean,
          default: false,
        },
      },
      emits: ['update:modelValue'],
      setup(props, context) {
        const value = computed({
          get: () => props.modelValue,
          set: (val) => {
            context.emit('update:modelValue', val);
          },
        });
    
        watchEffect(
          () => props.modelValue,
          (newValue) => {
            value.value = newValue;
          },
        );
        return {
          value,
        };
      },
    });
    </script>
    <style lang="less" scoped>
    :deep(.el-*) {
      width: 100%;
    }
    .width100 {
      width: 100%;
    }
    </style>
    
    

    这里要注意的点是v-bind="$attrs"

    • 因为我们不可能将所有组件可能用到的props都写在这并导出没,而且也没有这个必要。

    • 所以我们可以用到vue提供的$attrs来帮助我们透传下去

    使用

    比如像这样一个表单

    1.png

    我们只需要如下代码

    Rules规则是我们单独定义的符合async-validator的规则,这里就不写引入了

    <template>
    <Form 
      v-model:instance="formRef" 
      v-model="formData" 
      :needs="needs" 
    />
    </template>
    <script>
    import {
      defineComponent, reactive, computed, ref
    } from 'vue';
    export default defineComponent({
      setup(){
        const formRef = ref();
        const options = reactive({
          departments: [],
          places: [],
          roles: [],
        });
        const formData = reactive({
          account: '',
          department: [],
          name: '',
          password: '',
          practicePlace: [],
          rePassword: '',
          roleId: '',
          uniqueid: '',
        });
        const needs = computed(() => [
          {
            label: '用户名',
            type: 'input',
            prop: 'name',
            propsClass: 'width100',
            placeholder: '请输入2-20个汉字,字母或数字',
            rules: [
              Rules.required('用户名不得为空'),
              Rules.dynamicLength(2, 20, '用户名长度为2-20位'),
              Rules.cen,
            ],
          },
          {
            label: '用户账号',
            type: 'input',
            prop: 'account',
            propsClass: 'width100',
            placeholder: '请输入2-20个字母或数字',
            rules: [
              Rules.required('用户账号不得为空'),
              Rules.dynamicLength(2, 20, '用户账号长度为2-20位'),
              Rules.en,
            ],
          },
          {
            label: '密码',
            type: 'password',
            prop: 'password',
            propsClass: 'width100',
            placeholder: '支持6-20个字母、数字、特殊字符',
            rules: [
              Rules.required('密码不得为空'),
              Rules.dynamicLength(6, 20, '密码长度为6-20位'),
              Rules.password,
            ],
          },
          {
            label: '再输一次',
            type: 'password',
            prop: 'rePassword',
            propsClass: 'width100',
            placeholder: '支持6-20个字母、数字、特殊字符',
            rules: [
              Rules.required('请再输入一次密码'),
              Rules.dynamicLength(6, 20, '密码长度为6-20位'),
              Rules.password,
              Rules.same(formData.password, formData.rePassword, '两次密码输入不一致'),
            ],
          },
          {
            label: '角色',
            type: 'select',
            prop: 'roleId',
            propsClass: 'width100',
            placeholder: '请选择角色',
            rules: [Rules.required('角色不得为空')],
            options: options.roles,
          },
          {
            label: '执业地点',
            type: 'select',
            prop: 'practicePlace',
            propsClass: 'width100',
            placeholder: '请选择执业地点',
            multiple: true,
            filterable: true,
            options: [{ label: '全部', value: 'all' }].concat(options.places),
          },
          {
            label: '科室',
            type: 'select',
            prop: 'department',
            propsClass: 'width100',
            placeholder: '请选择科室',
            multiple: true,
            filterable: true,
            options: [{ label: '全部', value: 'all' }].concat(options.departments),
          },
        ]);
    
        // 网络请求获取options,这里就简写了
        // *********************
    
        return {
          formData,
          needs,
          formRef,
        }
      }
      
    })
    </script>
    

    我们只需要聚焦数据,就可以构造出一张表单。

    React

    也是相似的,而且较之Vue的更加灵活,除了我们上述的这种常用表单,我们可以把后台管理的搜索项也认为是表单

    Form

    import React from 'react';
    import { ColProps, Form, FormInstance } from 'antd';
    import { FormLayout } from 'antd/lib/form/Form';
    import FormItem, { IFormItem } from '../FormItem';
    
    interface IForm {
      form: FormInstance<any>;
      itemLayout?: {
        labelCol: ColProps;
        wrapperCol: ColProps;
      };
      layout?: FormLayout;
      options: IFormItem[];
      initialValues?: { [key: string]: any };
      onValuesChange?(changedValues: unknown, allValues: any): void;
    }
    // 这是个单独的表单校验模板
    /* eslint-disable no-template-curly-in-string */
    const validateMessages = {
      required: '${label}是必填项',
    };
    /* eslint-enable no-template-curly-in-string */
    
    const FormComponent = (props: IForm): JSX.Element => {
      const {
        form, onValuesChange, initialValues, options, layout, itemLayout,
      } = props;
    
      return (
        <Form
          form={form}
          {...itemLayout}
          layout={layout}
          onValuesChange={onValuesChange}
          initialValues={initialValues}
          validateMessages={validateMessages}
        >
          {/* 内容 */}
          {options.map((item) => (
            <FormItem key={item.value} {...item} />
          ))}
        </Form>
      );
    };
    FormComponent.defaultProps = {
      layout: 'horizontal',
      itemLayout: {
        labelCol: {},
        wrapperCol: {},
      },
      initialValues: {},
      // 此处默认定义为空函数
      onValuesChange() {},
    };
    
    export default FormComponent;
    export type { IFormItem };
    
    

    需要注意的点

    • form的引用实例由外部传入
    • 取值赋值通过formInstance做,因为和vue不一样,react做父子双向绑定比较复杂(也可能是我不太熟练的缘故),所以建议是不要做成受控组件

    FormItem

    import React from 'react';
    import {
      Form, Radio, Select, Input, DatePicker, Switch,
    } from 'antd';
    import { Rule } from 'antd/lib/form';
    
    const { Option } = Select;
    const { RangePicker } = DatePicker;
    export interface IFormItem {
      type: 'input' | 'radio' | 'select' | 'rangePicker' | 'datePicker' | 'switch';
      label: string;
      // 需要绑定的key值
      value: string;
      // 可选项
      placeholder?: string;
      options?: { label: string; value: string | number }[];
      otherConfig?: any;
      itemConfig? : any;
      rules?: Rule[];
      itemClass?: string;
    }
    // Form.Item似乎也不允许HOC
    const FormItemComponent = (props: IFormItem): JSX.Element => {
      const {
        type, label, value, rules, placeholder, otherConfig, options, itemClass, itemConfig,
      } = props;
      // 判断类型
    
      return (
        <Form.Item label={label} name={value} rules={rules} className={itemClass} {...itemConfig}>
          {(() => {
            switch (type) {
              case 'input':
                return <Input placeholder={placeholder} {...otherConfig} />;
              case 'radio':
                return (
                  <Radio.Group {...otherConfig}>
                    {options?.map((item) => (
                      <Radio key={item.value} value={item.value}>
                        {item.label}
                      </Radio>
                    ))}
                  </Radio.Group>
                );
              case 'select':
                return (
                  <Select {...otherConfig} placeholder={placeholder}>
                    {options?.map((item) => (
                      <Option key={item.value} value={item.value}>
                        {item.label}
                      </Option>
                    ))}
                  </Select>
                );
              case 'rangePicker':
                return <RangePicker {...otherConfig} />;
              case 'datePicker':
                return <DatePicker {...otherConfig} />;
              case 'switch':
                return <Switch {...otherConfig} />;
              default:
                return <div />;
            }
          })()}
        </Form.Item>
      );
    };
    
    export default FormItemComponent;
    
    

    这里要注意的点

    使用

    例如下面两个例子

    2.png
    import React, { useEffect, useState } from 'react';
    import {
      Form
    } from 'antd';
    import FormComponent, { IFormItem } from '../../components/FormComponent';
    const Welcome = (): JSX.Element => {
      const [form] = Form.useForm();
      const [saleList, setSaleList] = useState<Options[]>([]);
      const [firmList, setFirmList] = useState<Options[]>([]);
      const options: IFormItem[] = [{
        type: 'select',
        label: '厂商名称',
        value: 'clientId',
        options: firmList,
        itemClass: 'width25',
        otherConfig: {
          onChange: () => {
            // 选中触发搜索,具体的就不写了
            search();
          },
        },
      }, {
        type: 'select',
        label: '销售人员',
        value: 'saleId',
        options: saleList,
        itemClass: 'width25',
        otherConfig: {
          onChange: () => {
            // 选中触发搜索,具体的就不写了
            search();
          },
        },
      }];
      useEffect(() => {
        // 获取两个列表,具体的就不写了
        getFirmList();
        getSaleList();
      }, []);
      return (
        <FormComponent
          form={form}
          layout="inline"
          options={options}
          initialValues={{
            clientId: '',
            saleId: '',
          }}
        />
      )
    };
    export default Welcome;
    
    3.png
    import React, { useEffect, useState } from 'react';
    import {
      Form
    } from 'antd';
    import FormComponent, { IFormItem } from '../../components/FormComponent';
    
    const UserList = (): JSX.Element => {
      const initialValues = {
        name: '',
        email: '',
        account: '',
        password: '',
        rePassword: '',
        roleId: '',
      };
      const [userForm] = Form.useForm();
      const userOptions: IFormItem[] = [{
        type: 'input',
        label: '名称',
        value: 'name',
        rules: [
          {
            required: true,
          },
          Rules.dynamicLength(2, 20),
          Rules.chinese,
        ],
      }, {
        type: 'input',
        label: '邮箱',
        value: 'email',
      }, {
        type: 'input',
        label: '账号',
        value: 'account',
        rules: [
          {
            required: true,
          },
          Rules.dynamicLength(2, 20),
          Rules.cen,
        ],
      }, {
        type: 'input',
        label: '密码',
        value: 'password',
        rules: [
          {
            required: true,
          },
          Rules.minLength(6),
          Rules.englishAndNumber,
        ],
      }, {
        type: 'input',
        label: '再次确认密码',
        value: 'rePassword',
        itemConfig: {
          dependencies: ['password'],
        },
        rules: [
          {
            required: true,
          },
          Rules.minLength(6),
          Rules.englishAndNumber,
          ({ getFieldValue }) => ({
            validator(_, value) {
              if (!value || getFieldValue('password') === value) {
                return Promise.resolve();
              }
              return Promise.reject(new Error('两次密码不一致'));
            },
          }),
        ],
      }, {
        type: 'select',
        label: '用户角色',
        value: 'roleId',
        options,
        rules: [
          {
            required: true,
          },
        ],
      }];
      return (
        <FormComponent
              form={userForm}
              options={userOptions}
              itemLayout={{
                labelCol: {
                  sm: { span: 5 },
                },
                wrapperCol: {
                  sm: { span: 18 },
                },
              }}
              initialValues={initialValues}
            />
      )
    };
    export default UserList;
    

    over

    相关文章

      网友评论

          本文标题:无星的前端之旅(二十一)—— 表单封装

          本文链接:https://www.haomeiwen.com/subject/nowxyltx.html