阅读 375

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

背景

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

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

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

尽量通过数据驱动。

思路

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

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

文章分类
前端
文章标签