受控表单
B 端开发中打交道最多的组件 - 表单
原文: 点我跳转
示例代码: 点我跳转
发生甚么事了
最近在掘金摸鱼时,刷到了好几篇关于在 vue2
中封装表单的文章,什么提效 200%
叭叭叭的说的天花乱坠
点进去一看,其实就是把表单封装成一个可配置的巨型组件,模板语法里一大堆 v-if
v-else-if
我个人认为,这种封装方式不太可取,原因有以下几点:
它完全是把写模板的代码量转移到了配置上,实质上只是换了一种写法,工作量并没有减少多少,简直是为了封装而复用,本末倒置
不仅要理解封装后组件的使用方法,还需要熟悉原始表单组件的使用方法
并且如果稍微增加、变动几个需求或者换个表单组件,一座没法维护的 💩 山就形成了
这边总结一下较高层抽象的表单通用封装需求:
可以替换不同的组件库
可以灵活应对业务逻辑的变化
使用成本低
这么一看,可以抽离封装的东西其实不多,只有表单数据的逻辑
只对数据进行抽象封装,模板中的组件消费这些数据
撸码
首先整一个简单的表单出来
title1 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
|
<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
修饰符
title1 2 3 4 5 6 7 8 9
|
export default { model: { prop: 'data', event: 'update:data', }, ... };
|
外部值变化,更新内部值
首先需要接收一个外部的值 data
title1 2 3 4 5 6 7 8 9 10 11 12 13
|
export default { ... props: { data: { type: Object, required: false, default: () => ({}), }, }, ... };
|
在实例内部监听 data
的变化
大部分情况下表单编辑的初始值都是异步获取的,但是有些需求是外部组件写死的默认值,这种情况下如果没有立刻同步会导致初始值无效
所以需要立即把 data
的值更新到 formData
中,即 immediate: true
title1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
|
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
title1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
export default { ... data() { return { formData: { ... }, }; }, watch: { ... formData: { handler(n) {
this.$emit('update:data', { ...n }); }, deep: true, }, }, };
|
完整组件代码
title1 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
|
<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>
|
试试效果
直接在页面中引用
title1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
<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
title1 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
|
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 行
title1 2 3 4 5 6 7 8 9
|
... <script> import control from './mixins/control'; export default { mixins: [control({ name: undefined, sex: '1', age: undefined })], }; </script>
|
最终结果,全部代码
title1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
<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>
|
title1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
<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>
|
title1 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
|
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 端开发的同学可以留意下