受控表单
B 端开发中打交道最多的组件 - 表单
原文: 点我跳转
示例代码: 点我跳转
发生甚么事了
最近在掘金摸鱼时,刷到了好几篇关于在 vue2
中封装表单的文章,什么提效 200%
叭叭叭的说的天花乱坠
点进去一看,其实就是把表单封装成一个可配置的巨型组件,模板语法里一大堆 v-if
v-else-if
我个人认为,这种封装方式不太可取,原因有以下几点:
它完全是把写模板的代码量转移到了配置上,实质上只是换了一种写法,工作量并没有减少多少,简直是为了封装而复用,本末倒置
不仅要理解封装后组件的使用方法,还需要熟悉原始表单组件的使用方法
并且如果稍微增加、变动几个需求或者换个表单组件,一座没法维护的 💩 山就形成了
这边总结一下较高层抽象的表单通用封装需求:
可以替换不同的组件库
可以灵活应对业务逻辑的变化
使用成本低
这么一看,可以抽离封装的东西其实不多,只有表单数据的逻辑
只对数据进行抽象封装,模板中的组件消费这些数据
撸码
首先整一个简单的表单出来
<!-- 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
),减少组件的使用理解成本
这边的双向绑定实现与官方示例不同,我们内部也维护了一个状态而官方示例中并没有,因此组件可以是非受控的
双向绑定语法糖需要实现这两个点:
外部值变化时,组件内部同步更新
组件内部值变化时,外部值同步更新
双向绑定配置
在实例中配置 model
,配置接收属性为 data
,更新外部数据的事件为 update:data
在外部既可以使用 v-mode
也可以使用 .sync
修饰符
// components/FormControl/index.vue - script
export default {
model: {
prop: 'data',
event: 'update:data',
},
...
};
外部值变化,更新内部值
首先需要接收一个外部的值 data
// components/FormControl/index.vue - script
export default {
...
props: {
data: {
type: Object,
required: false,
default: () => ({}),
},
},
...
};
在实例内部监听 data
的变化
大部分情况下表单编辑的初始值都是异步获取的,但是有些需求是外部组件写死的默认值,这种情况下如果没有立刻同步会导致初始值无效
所以需要立即把 data
的值更新到 formData
中,即 immediate: true
// 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
// components/FormControl/index.vue - script
export default {
...
data() {
return {
formData: { ... },
};
},
watch: {
...
formData: {
handler(n) {
/**
* 这里用解构是为了让外部数据和内部数据不是同一个引用
* 防止出现内存泄漏或其它奇奇怪怪的引用引起的bug
* 一般进行一层的浅拷贝就足够了
*/
this.$emit('update:data', { ...n });
},
deep: true,
},
},
};
完整组件代码
<!-- 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>
试试效果
直接在页面中引用
<!-- 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
// 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;
这里我还做了一点小优化:
有时需要自定义双向绑定的变量名,所以混入的第二个的参数用于自定义变量名,默认值为
data
前面说过异步场景下监听外部
data
时可以不需要immediate
,于是我把这个监听器改为可控的,通过immediate
传参控制,在最早可以拿到传参的created
生命周期手动监听
改造表单组件
使用成本非常低,只需要记住内部数据是存储在 formData
中即可
加上混入后script
标签中的内容简化至 4 行
<!-- components/FormControl/index.vue -->
...
<script>
import control from './mixins/control';
export default {
mixins: [control({ name: undefined, sex: '1', age: undefined })],
};
</script>
最终结果,全部代码
<!-- 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>
<!-- 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>
// 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 端开发的同学可以留意下