受控表单

B 端开发中打交道最多的组件 - 表单

原文: 点我跳转

示例代码: 点我跳转

发生甚么事了

最近在掘金摸鱼时,刷到了好几篇关于在 vue2 中封装表单的文章,什么提效 200% 叭叭叭的说的天花乱坠

点进去一看,其实就是把表单封装成一个可配置的巨型组件,模板语法里一大堆 v-if v-else-if

我个人认为,这种封装方式不太可取,原因有以下几点:

  1. 它完全是把写模板的代码量转移到了配置上,实质上只是换了一种写法,工作量并没有减少多少,简直是为了封装而复用,本末倒置

  2. 不仅要理解封装后组件的使用方法,还需要熟悉原始表单组件的使用方法

  3. 并且如果稍微增加、变动几个需求或者换个表单组件,一座没法维护的 💩 山就形成了


这边总结一下较高层抽象的表单通用封装需求:

  1. 可以替换不同的组件库

  2. 可以灵活应对业务逻辑的变化

  3. 使用成本低

这么一看,可以抽离封装的东西其实不多,只有表单数据的逻辑

只对数据进行抽象封装,模板中的组件消费这些数据

撸码

首先整一个简单的表单出来

title
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
<!-- components/FormControl/index.vue -->

<template>
<el-form ref="form" :model="formData" label-width="80px">
<el-form-item label="姓名" prop="name">
<el-input v-model="formData.name"></el-input>
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-radio v-model="formData.sex" label="1"></el-radio>
<el-radio v-model="formData.sex" label="2"></el-radio>
</el-form-item>
<el-form-item label="年龄" prop="age">
<el-input-number v-model="formData.age"></el-input-number>
</el-form-item>
</el-form>
</template>

<script>
export default {
data() {
return {
formData: {
name: undefined,
sex: '1',
age: undefined,
},
};
},
};
</script>

我们简单的思考下,表单一般都是增、改两种操作,那么表单应该能接受一个初始值

那么外部需要能够控制组件内部的值,也就是一个受控组件

这时候不妨贯彻下 vue2 的一些组件思维,直接把数据做成双向绑定 (v-model),减少组件的使用理解成本

这边的双向绑定实现与官方示例不同,我们内部也维护了一个状态而官方示例中并没有,因此组件可以是非受控的

双向绑定语法糖需要实现这两个点:

  1. 外部值变化时,组件内部同步更新

  2. 组件内部值变化时,外部值同步更新

双向绑定配置

在实例中配置 model,配置接收属性为 data,更新外部数据的事件为 update:data

在外部既可以使用 v-mode 也可以使用 .sync 修饰符

title
1
2
3
4
5
6
7
8
9
// components/FormControl/index.vue - script

export default {
model: {
prop: 'data',
event: 'update:data',
},
...
};

外部值变化,更新内部值

首先需要接收一个外部的值 data

title
1
2
3
4
5
6
7
8
9
10
11
12
13
// components/FormControl/index.vue - script

export default {
...
props: {
data: {
type: Object,
required: false,
default: () => ({}),
},
},
...
};

在实例内部监听 data 的变化

大部分情况下表单编辑的初始值都是异步获取的,但是有些需求是外部组件写死的默认值,这种情况下如果没有立刻同步会导致初始值无效

所以需要立即把 data 的值更新到 formData 中,即 immediate: true

title
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// components/FormControl/index.vue - script

export default {
...
data() {
return {
formData: { ... },
};
},
watch: {
data: {
handler(newData) {
Object.keys(newData).forEach((k) =>
this.$set(this.formData, k, newData[k]),
);
},
deep: true,
immediate: true,
},
},
};

内部值变化,更新外部值

直接在实例中监听 formData

因为初始值是外部决定的,所以无需设置 immediate

title
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// components/FormControl/index.vue - script

export default {
...
data() {
return {
formData: { ... },
};
},
watch: {
...
formData: {
handler(n) {
/**
* 这里用解构是为了让外部数据和内部数据不是同一个引用
* 防止出现内存泄漏或其它奇奇怪怪的引用引起的bug
* 一般进行一层的浅拷贝就足够了
*/
this.$emit('update:data', { ...n });
},
deep: true,
},
},
};

完整组件代码

title
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
<!-- components/FormControl/index.vue -->

<template>
<el-form ref="form" :model="formData" label-width="80px">
<el-form-item label="姓名" prop="name">
<el-input v-model="formData.name"></el-input>
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-radio v-model="formData.sex" label="1"></el-radio>
<el-radio v-model="formData.sex" label="2"></el-radio>
</el-form-item>
<el-form-item label="年龄" prop="age">
<el-input-number v-model="formData.age"></el-input-number>
</el-form-item>
</el-form>
</template>

<script>
export default {
model: {
prop: 'data',
event: 'update:data',
},
props: {
data: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
formData: {
name: undefined,
sex: '1',
age: undefined,
},
};
},
watch: {
data: {
handler(newData) {
Object.keys(newData).forEach((k) =>
this.$set(this.formData, k, newData[k])
);
},
deep: true,
immediate: true,
},
formData: {
handler(n) {
this.$emit('update:data', { ...n });
},
deep: true,
},
},
};
</script>

试试效果

直接在页面中引用

title
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- index.vue -->

<template>
<form-control v-model="formData" />
</template>

<script>
import FormControl from './components/FormControl/index.vue';
export default {
components: {
FormControl,
},
data() {
return {
formData: {
name: 'norah1to',
sex: '1',
age: 23,
},
};
},
};
</script>

双向绑定成功

效果

抽取公共逻辑

简单分析下 script 标签中的代码,可以很简单的发现,除了 formData 里的数据结构,其它都是能复用的部分,于是可以这样封装一个 mixin

title
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
// components/FormControl/mixins/control.js

/**
* control
* @param {Record} model
*/
const control = (model = {}, propName = 'data') => ({
model: {
prop: `${propName}`,
event: `update:${propName}`,
},
props: {
[`${propName}`]: {
type: Object,
required: false,
default: () => ({}),
},
immediate: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
formData: {
...model,
},
};
},
watch: {
formData: {
handler(n) {
this.$emit(`update:${propName}`, { ...n });
},
deep: true,
},
},
created() {
this.$watch(
`${propName}`,
(newData) => {
Object.keys(newData).forEach((k) =>
this.$set(this.formData, k, newData[k])
);
},
{ deep: true, immediate: this.immediate }
);
},
});

export default control;

这里我还做了一点小优化:

  1. 有时需要自定义双向绑定的变量名,所以混入的第二个的参数用于自定义变量名,默认值为 data

  2. 前面说过异步场景下监听外部 data 时可以不需要 immediate,于是我把这个监听器改为可控的,通过 immediate 传参控制,在最早可以拿到传参的 created 生命周期手动监听

改造表单组件

使用成本非常低,只需要记住内部数据是存储在 formData 中即可

加上混入后script 标签中的内容简化至 4 行

title
1
2
3
4
5
6
7
8
9
<!-- components/FormControl/index.vue -->

...
<script>
import control from './mixins/control';
export default {
mixins: [control({ name: undefined, sex: '1', age: undefined })],
};
</script>

最终结果,全部代码

title
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- index.vue -->

<template>
<form-control v-model="formData" />
</template>

<script>
import FormControl from './components/FormControl/index.vue';
export default {
components: {
FormControl,
},
data() {
return {
formData: {
name: 'NoraH1to',
sex: '1',
age: 23,
},
};
},
};
</script>
title
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- components/FormControl/index.vue -->

<template>
<el-form ref="form" :model="formData" label-width="80px">
<el-form-item label="姓名" prop="name">
<el-input v-model="formData.name"></el-input>
</el-form-item>
<el-form-item label="性别" prop="sex">
<el-radio v-model="formData.sex" label="1"></el-radio>
<el-radio v-model="formData.sex" label="2"></el-radio>
</el-form-item>
<el-form-item label="年龄" prop="age">
<el-input-number v-model="formData.age"></el-input-number>
</el-form-item>
</el-form>
</template>

<script>
import control from './control';
export default {
mixins: [control({ name: undefined, sex: '1', age: undefined })],
};
</script>
title
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
// components/FormControl/mixins/control.js 混入

/**
* control
* @param {Record} model
*/
const control = (model = {}, propName = 'data') => ({
model: {
prop: `${propName}`,
event: `update:${propName}`,
},
props: {
[`${propName}`]: {
type: Object,
required: false,
default: () => ({}),
},
immediate: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
formData: {
...model,
},
};
},
watch: {
formData: {
handler(n) {
this.$emit(`update:${propName}`, { ...n });
},
deep: true,
},
},
created() {
this.$watch(
`${propName}`,
(newData) => {
Object.keys(newData).forEach((k) =>
this.$set(this.formData, k, newData[k])
);
},
{ deep: true, immediate: this.immediate }
);
},
});

export default control;

总结

看到这你会发现,这只是一个很简单的双向绑定封装,是的,但是大家别小看它,这是我后面很多实践的基石,这个混入之后也会进行更高抽象层次的拆解、封装

它贵在通用性、易用性和可扩展性强,并且 vue2 的复用方式真的不多,选择混入属于是无奈之举(下个季度公司项目会逐渐迁移到 vue3,好耶!)

混入非常容易让使用者混乱,大家千万不要做侵入性过强、属性过多的混入封装,一切从简

后续我会写一些组合使用混入达到提效目的的文章,做 B 端开发的同学可以留意下