我的GitHub
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
<!-- 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
<!-- 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

我们只需要如下代码

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
<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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
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

我是阿星,阿星的阿,阿星的星!