业务需求
存在这样一个业务场景:
- 用户需要提交一个表单到系统中,但是表单的内容是由用户自己决定的,比如用户添加/删除表单项,从而提交用户自定义的表单内容,比如姓名、年龄、入职时间等
从产品的角度来看,我们需要提供一个可编辑的动态表单,其最终效果如下:
组件设计
我们需要实现一个自定义组件,它能够:
- 接收外部传入的配置信息,来生成表单项的操作按钮(即上图中的"添加input","添加数字输入","添加日期"等按钮
- 用户在页面中输入表单项的名称后,点击操作按钮,能够生成对应的表单项
- 用户可以在生成的表单项中输入值,并对外传递
组件实现
定义props
我们将这个自定义组件命名为EditableForm
,它会接收一个registerComp
的prop,该prop用于传递需要生成的表单项添加按钮,对于它的类型定义如下:
typescript
export type TFormItem = {
name: string // 表单项添加按钮的名称
comp: any // 表单项添加按钮具体要添加的组件
props?: Record<string, any> // 要添加组件的一些额外参数,比如日期输入框的格式
}
html
<template>
<div class="editable-form">
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
registerComp: TFormItem[]
}>(), {
registerComp: () => []
})
</script>
用户的使用案例:
html
<template>
<div class="app">
<EditableForm :register-comp="registerComp" />
</div>
</template>
<script setup lang="ts">
import EditableForm from '@/components/editable-form/EditableForm.vue'
import { ElDatePicker, ElInputNumber } from 'element-plus';
const registerComp = [
{
comp: ElInputNumber,
name: '数字输入'
},
{
comp: ElDatePicker,
name: '日期',
props: {
valueFormat: 'YYYY-MM-DD'
}
}
]
</script>
注意到:
- 上面我们在传入
registerComp
的时候,其中的comp
值为「Element Plus」库的组件 - 当然,我们也可以传入
input
这样的字符串,这样我们在EditableForm
中处理的时候则渲染为原生HTML元素<input>
生成添加按钮
在接收了用户传入的props后,我们需要在内部利用它来生成对应的添加按钮,具体做法如下:
html
<template>
<div class="editable-form">
<div class="workspace">
<input type="text" v-model="label">
<button
v-for="item in buttonArr"
:key="item.name"
@click="addFormItem(item.comp, item.props)">
{{ `添加${item.name}` }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { markRaw, reactive, shallowRef, ref } from 'vue';
import type { TFormItem } from './type';
const props = withDefaults(defineProps<{
registerComp: TFormItem[]
}>(), {
registerComp: () => []
})
let buttonArr = shallowRef<TFormItem[]>([ { name: 'input', comp: 'input' } ])
buttonArr.value = buttonArr.value.concat(props.registerComp)
const label = ref('')
const formItemArr = reactive<{
comp: any, props?: Record<string, any>, label: string, key: string }
[]>([])
function addFormItem(comp: any, props = {}) {
formItemArr.push({
comp: isNative(comp) ? comp : markRaw(comp),
props,
label: label.value,
key: uuidv4()
})
valueArr.push({
value: null,
label: label.value
})
label.value = ''
}
</script>
在上面的代码中:
- 我们在
EditableForm
组件内部定义了一个buttonArr
,它包含了我们内置的初始数据(预制的内部组件),并将props.registerComp
的数据与它进行了合并,最终在模板中渲染出了一系列的表单项添加操作按钮 - 之后我们只需要监听按钮的点击并添加对应的数据到表单项列表
formItemArr
中即可,并且我们利用uuid
来给每个表单项添加了一个唯一的key
值用于提高列表更新渲染的效率 - 对于表单项的添加操作,我们还补充了一个输入框用于用户输入该表单项的名称
- 由于用户在传入
registerComp
时,其中的comp
可以是字符串类型或者一个组件,而组件是一个庞大的对象,因此这种情况下,我们在EditabelForm
中对其进行响应式处理时,需要将comp
标记为不用响应式的数据(使用shallowRef
和markRaw
)
渲染表单项
此时在EditForm
组件中我们已经定义了formItemArr
变量了,它用来存放用户点击添加表单项按钮后所添加的表单项,因此我们要做的是将它渲染到页面中:
html
<template>
<div class="editable-form">
<form>
<div v-for="item, index in formItemArr" :key="item.key">
<span>{{ item.label }}</span>
<component
:is="item.comp"
v-bind="item.props"
v-model="valueArr[ index ].value">
</component>
<button @click="onRemoveBtnClick(index)">-</button>
</div>
</form>
<div class="workspace">
<input type="text" v-model="label">
<button
v-for="item in buttonArr"
:key="item.name"
@click="addFormItem(item.comp, item.props)">
{{ `添加${item.name}` }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { markRaw, reactive, shallowRef, ref } from 'vue';
import type { TFormItem } from './type';
import { v4 as uuidv4 } from 'uuid'
const props = withDefaults(defineProps<{
registerComp: TFormItem[]
}>(), {
registerComp: () => []
})
let buttonArr = shallowRef<TFormItem[]>([ { name: 'input', comp: 'input' } ])
buttonArr.value = buttonArr.value.concat(props.registerComp)
const label = ref('')
const formItemArr = reactive<
{ comp: any, props?: Record<string, any>, label: string, key: string }[]>([])
function addFormItem(comp: any, props = {}) {
formItemArr.push({
comp: isNative(comp) ? comp : markRaw(comp),
props,
label: label.value,
key: uuidv4()
})
valueArr.push({
value: null,
label: label.value
})
label.value = ''
}
const valueArr = reactive<{ label: string, value: any }[]>([])
function isNative(comp: any) {
return typeof comp === 'string'
}
const onRemoveBtnClick = (index: number) => {
formItemArr.splice(index, 1)
valueArr.splice(index, 1)
}
</script>
在上面的代码中,我们新增了一个valueArr
变量,用于保存用户自定义表单项中的数据,并利用v-model
实现了在动态组件上的双向绑定
但此时还存在一个问题,就是使用动态组件渲染HTML原生元素的时候,使用v-model
双向绑定会被认为是组件的双向绑定,使得双向绑定不能正确运行。对此,我们需要进行额外的处理
HTML原生元素值的双向绑定
由于我们可以通过判断传入的comp
值类型是否为字符串来判断动态组件要渲染的是否为一个HTML原生元素,因此我们可以做如下的处理:
html
<template>
<!-- ... -->
<form @input="onInput">
<div v-for="item, index in formItemArr" :key="item.key">
<span>{{ item.label }}</span>
<component
:is="item.comp"
v-if="isNative(item.comp)"
v-bind="item.props"
:sort="index">
</component>
<component
:is="item.comp"
v-else
v-bind="item.props"
v-model="valueArr[ index ].value">
</component>
<button @click="onRemoveBtnClick(index)">-</button>
</div>
</form>
<!-- ... -->
<template>
在上面的代码中,当动态组件要渲染的是一个HTML原生元素的时候,我们对其绑定了一个sort
的attribute,并利用原生原生的事件冒泡,在<form>
元素上进行了事件监听,在此时进行值的写入:
typescript
const onInput = (event: any) => {
const value = event.target.value
const sort = event.target.getAttribute('sort')
const label = formItemArr[ sort ].label
valueArr[ sort ] = { value, label }
}
完整代码
最终完整代码如下:
html
<template>
<div class="editable-form">
表单的值:{{ valueArr }}
<form @input="onInput">
<div v-for="item, index in formItemArr" :key="item.key">
<span>{{ item.label }}</span>
<component
:is="item.comp"
v-if="isNative(item.comp)"
v-bind="item.props"
:sort="index"></component>
<component
:is="item.comp"
v-else
v-bind="item.props"
v-model="valueArr[ index ].value"></component>
<button @click="onRemoveBtnClick(index)">-</button>
</div>
</form>
<div class="workspace">
<input type="text" v-model="label">
<button v-for="item in buttonArr"
:key="item.name" @click="addFormItem(item.comp, item.props)">
{{ `添加${item.name}` }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { markRaw, reactive, shallowRef, ref } from 'vue';
import type { TFormItem } from './type';
import { v4 as uuidv4 } from 'uuid'
const props = withDefaults(defineProps<{
registerComp: TFormItem[]
}>(), {
registerComp: () => []
})
let buttonArr = shallowRef<TFormItem[]>([ { name: 'input', comp: 'input' } ])
buttonArr.value = buttonArr.value.concat(props.registerComp)
const label = ref('')
const formItemArr = reactive<{ comp: any, props?: Record<string, any>, label: string, key: string }[]>([])
function addFormItem(comp: any, props = {}) {
formItemArr.push({
comp: isNative(comp) ? comp : markRaw(comp),
props,
label: label.value,
key: uuidv4()
})
valueArr.push({
value: null,
label: label.value
})
label.value = ''
}
// 实现值绑定
const valueArr = reactive<{ label: string, value: any }[]>([])
function isNative(comp: any) {
return typeof comp === 'string'
}
const onInput = (event: any) => {
const value = event.target.value
const sort = event.target.getAttribute('sort')
const label = formItemArr[ sort ].label
valueArr[ sort ] = { value, label }
}
const onRemoveBtnClick = (index: number) => {
formItemArr.splice(index, 1)
valueArr.splice(index, 1)
}
</script>