业务场景
在实际开发中,我们会接触到这样的业务场景:
- 如下有一个表单,其存在多个表单项,而每个表单项并不是固定的,需要前面表单项的内容来决定的
- 比如:只有当表单项内容为
lzh
的时候才展示后面的表单项(实际业务中的判断条件会更加复杂)
开发痛点
如果我们按照业务逻辑直接在代码中编写,那么就会出现UI和业务逻辑过于耦合的现象,使得后续的代码能以维护,可拓展性和可读性也会很差:
因此,我们需要封装一个能够动态生成表单项的组件FormItemComp
,将业务逻辑和UI渲染逻辑分离开来
设计数据结构
对于动态表单项的生成,我们需要提供一个可行的数据结构,之后我们的自定义组件能够按照这个数据结构帮助我们自动生成表单并执行相关的判断逻辑
对此,我们定义了如下的表单项类型:
import { ElInput, ElInputNumber, ElSelect, ElDatePicker, ElCheckbox } from 'element-plus'
export type FormItemType =
| typeof ElInput
| typeof ElInputNumber
| typeof ElSelect
| typeof ElDatePicker
| typeof ElCheckbox
export interface FormItem {
type: FormItemType
payload: any
next: (current: FormItem, ancients: FormItem[]) => FormItem | null
parent: FormItem | null
}
可以看到,对于每个表单项,都有四个属性:
type
:用于决定该表单项要以什么组件进行渲染,这里我们使用了「ElementPlus」组件库payload
:用于承载该表单项具体的内容,比如要绑定的值value
、表单项的名称label
、表单项渲染组件所需的各种属性attributes
next
:该函数用于生成下一个表单项,在被调用的时候会传入当前的表单项以及所有祖先表单项parent
:该属性用于指向当前表单项的上一个(父级)表单项,使得各种表单项之间能够关联起来,形成链表的数据结构
实现创建表单项工具函数
在上面我们虽然定义了表单项的类型,但是用户在使用的时候,他不能直接将一个非响应式的数据传递给我们的自定义组件,因为这样子即使表单项发生了变化我们也无法让表单项进行动态渲染
因此,我们需要提供一个工具函数给用户,它用来创建符合我们自定义组件要求的表单项数据,具体实现如下:
import { markRaw, reactive } from 'vue'
export function createFormItem(
type: FormItem['type'],
payload: FormItem['payload'],
next?: FormItem['next'],
parent?: FormItem['parent']
) {
// 当用户没有提供next函数时,说明没有下一个表单项了,我们只需要提供一个返回null的next函数即可
if (!next) {
next = () => null
}
if (!parent) {
parent = null
}
const nextFn: FormItem['next'] = (current, ancients) => {
let nextItem = next(current, ancients)
if (!nextItem) {
return null
}
// 在执行next函数创建出下一个表单项之后,我们需要为其指定parent
nextItem.parent = current
// 边界情况的处理,防止用户在实现next函数的时候返回的不是一个reactive的数据
if (!reactive(nextItem)) {
nextItem = reactive(nextItem)
}
return nextItem
}
const formItem: FormItem = reactive({
type: markRaw(type),
payload,
next: nextFn,
parent
})
return formItem
}
上面代码中有几个点值得关注:
- 将
next
函数包装成nextFn
,其内部增加了一些边界情况的处理以及将下一个表单项的parent
执行当前表单项 creatFormItem
函数应该返回一个响应式数据,由于需要为响应式的数据是一个对象,这里我们使用了reactive
进行包裹- 由于
reactive
的响应式包裹是深度的,而对于其中的type
属性,其值为「ElementPlus」中的组件,是一个巨大的对象,考虑到我们在实际使用中并不会去更改表单项的type
,因此这里我们使用Vue提供的markRaw
函数将type
标记为无需响应式的数据
用户使用案例
在实现了创建表单项的工具函数之后,用户就可以用该函数来创建动态表单项的配置了,用例如下:
const item1 = createFormItem(ElInput, {
key: 'item1',
value: '',
label: '表单项一'
}, (current) => {
if (current.payload.value === 'lzh') {
return item2
}
return null
})
const item2 = createFormItem(ElInput, {
key: 'item2',
value: '',
label: '表单项二'
})
之后,我们需要实现一个自定义动态表单项组件来接收这些数据并渲染展示
实现动态表单项组件
渲染单个表单项
对于动态表单项组件,它应该接收我们前面所定义的createFormItem
函数的返回结果:
<template>
<div class="form-item-comp" v-if="formState">
<el-form-item :label="formState.payload.label">
<template v-if="formState.type === ElSelect">
<el-select v-model="formState.payload.value">
<el-option v-for="option in formState.payload.attributes?.options"
:key="option.value"
:label="option.label"
:value="option.value">
</el-option>
</el-select>
</template>
<component
:is="formState.type"
v-model="formState.payload.value"
v-bind="formState.payload.attributes"
v-else>
</component>
</el-form-item>
</div>
</template>
<script lang="ts" setup>
import { ElFormItem, ElSelect, ElOption } from 'element-plus'
import type { FormItem } from './type';
const props = defineProps<{
formState: FormItem | null
}>()
</script>
用户在使用时:
<template>
<form-item-comp :form-state="item1" />
</template>
<script lang="ts" setup>
import { createFormItem } from './components/form-item-comp/type';
import FormItemComp from './components/form-item-comp/FormItemComp.vue';
const item1 = createFormItem(ElInput, {
key: 'item1',
value: '',
label: '表单项一'
}, (current) => {
if (current.payload.value === 'lzh') {
return item2
}
return null
})
const item2 = createFormItem(ElInput, {
key: 'item2',
value: '',
label: '表单项二'
})
</script>
在上面的代码中:
- 我们自定义了一个
FormItemComp
组件,该组件定义了一个formState
的prop,其类型即我们前面定义的FormItem
- 之后我们在模板中对
formState
进行处理,由于el-select
组件需要搭配el-option
组件一起使用,因此我们采用单独的逻辑判断传入的type
是否为ELSelect
(如果有类似的组件,同样可以采用单独的逻辑进行判断) - 对于其它组件,我们则使用动态组件来进行渲染,并通过
v-model
将formState.payload.value
双向绑定到组件上 - 注意,这里对于动态组件由于其渲染的都是自定义组件,因此我们可以直接使用v-model进行值的双向绑定;如果我们需要利用动态组件来渲染原生组件(如
input
、checkbox
等),需要进而额外的逻辑处理,而不可以使用v-model
进行组件的双向绑定。具体可以参见Vue文档中的说明(vuejs.org/api/built-i…)
渲染下一个表单项
上面代码中,我们将用户通过createFormItem
创建的item1
作为FormItemComp
组件的prop进行了传递,并在FormItemComp
组件内部进行消费。但是,我们并没有对下一个表单项进行渲染。由于每个表单项之间存在的关系实际上为链表结构,因此我们可以采用递归组件的方式来渲染每一个表单项。
具体做法如下:
<template>
<div class="form-item-comp" v-if="formState">
<el-form-item :label="formState.payload.label">
<template v-if="formState.type === ElSelect">
<el-select v-model="formState.payload.value">
<el-option v-for="option in formState.payload.attributes?.options"
:key="option.value"
:label="option.label"
:value="option.value">
</el-option>
</el-select>
</template>
<component
:is="formState.type"
v-model="formState.payload.value"
v-bind="formState.payload.attributes"
v-else>
</component>
</el-form-item>
<!-- 通过绑定函数调用表达式的方式,使得每次在FormItemComp组件发生更新的时候都会自动调用一次getNext函数 -->
<form-item-comp :form-state="getNext()" />
</div>
</template>
<script lang="ts" setup>
import { ElFormItem, ElSelect, ElOption } from 'element-plus'
import type { FormItem } from './type';
const props = defineProps<{
formState: FormItem | null
}>()
const getNext = () => {
console.log('getNext被调用了');
let current: FormItem | null = props.formState
if (!current) {
return null
}
const ancients = []
while ((current = current.parent)) {
ancients.unshift(current)
}
return props.formState!.next(props.formState!, ancients)
}
</script>
上面的代码中:
- 我们递归使用了
FormItemComp
组件,并为其绑定了一个getNext()
的函数表达式,该函数表达式的调用时机,为当前所在组件的首次渲染以及组件的每次更新 - 这样,我们就能在当前表单项发生变化的时候,去自动触发后代表单项的更新渲染了
对外传递表单数据
虽然我们可以在传入的item.payload.value
中取到双向绑定的数据,但是这种方式对用户来说不太方便。因此,我们在FormItemComp
组件中新增一个formData
的prop,用于传递表单数据给用户
具体做法如下:
<template>
<div class="form-item-comp" v-if="formState">
<el-form-item :label="formState.payload.label">
<template v-if="formState.type === ElSelect">
<el-select v-model="formState.payload.value">
<el-option v-for="option in formState.payload.attributes?.options"
:key="option.value"
:label="option.label"
:value="option.value">
</el-option>
</el-select>
</template>
<component
:is="formState.type"
v-model="formState.payload.value"
v-bind="formState.payload.attributes"
v-else>
</component>
</el-form-item>
<!-- 通过绑定函数调用表达式的方式,使得每次在FormItemComp组件发生更新的时候都会自动调用一次getNext函数 -->
<form-item-comp :form-state="getNext()" />
</div>
</template>
<script lang="ts" setup>
import { ElFormItem, ElSelect, ElOption } from 'element-plus'
import type { FormItem } from './type';
import { onBeforeUpdate, onUpdated } from 'vue';
const props = defineProps<{
formState: FormItem | null,
formData: Record<string, any>
}>()
const getNext = () => {
console.log('getNext被调用了');
updateFormData()
let current: FormItem | null = props.formState
if (!current) {
return null
}
const ancients = []
while ((current = current.parent)) {
ancients.unshift(current)
}
return props.formState!.next(props.formState!, ancients)
}
const updateFormData = () => {
const key = props.formState?.payload.key
const value = props.formState?.payload.value
props.formData[ key ] = value
}
</script>
用户的最终使用方式:
<template>
<div class="app">
<form-item-comp :form-state="item1" :form-data="formData" />
</div>
</template>
<script setup lang="ts">
import { ElCheckbox, ElDatePicker, ElInput, ElSelect } from 'element-plus';
import { createFormItem } from './components/form-item-comp/type';
import FormItemComp from './components/form-item-comp/FormItemComp.vue';
import { reactive } from 'vue';
const item1 = createFormItem(ElInput, {
key: 'item1',
value: '',
label: '表单项一'
}, (current) => {
if (current.payload.value === 'lzh') {
return item2
}
return null
})
const item2 = createFormItem(ElInput, {
key: 'item2',
value: '',
label: '表单项二'
}, (current) => {
if (current.payload.value === '123') {
return item3
}
return null
})
const item3 = createFormItem(ElSelect, {
key: 'item3',
value: 'a',
label: '表单项三',
attributes: {
options: [
{ value: 'a', label: 'a' },
{ value: 'b', label: 'b' },
{ value: 'c', label: 'c' },
]
}
}, () => {
return item4
})
const item4 = createFormItem(ElDatePicker, {
key: 'item4',
value: '',
label: '表单项四'
}, (current, ancients) => {
if (ancients[ 2 ].payload.value === 'b') {
return item5
}
return null
})
const item5 = createFormItem(ElCheckbox, {
key: 'item5',
value: true,
label: '表单项五',
attributes: {
label: '开心'
}
})
const formData = reactive({})
</script>