業務場景
在實際開發中,我們會接觸到這樣的業務場景:
如下有一個表單,其存在多個表單項,而每個表單項並不是固定的,需要前面表單項的內容來決定的
比如:只有當表單項內容為
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 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 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 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 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 setup> import { ElFormItem, ElSelect, ElOption } from 'element-plus' import type { FormItem } from './type'; const props = defineProps<{ formState: FormItem | null }>() 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) } </script>
上面的程式碼中:
我們遞迴使用了
FormItemComp
元件,併爲其繫結了一個getNext()
的函式表示式,該函式表示式的呼叫時機,為當前所在元件的首次渲染以及元件的每次更新這樣,我們就能在當前表單項發生變化的時候,去自動觸發後代表單項的更新渲染了
對外傳遞表單資料
雖然我們可以在傳入的item.payload.value
中取到雙向繫結的資料,但是這種方式對使用者來說不太方便。因此,我們在FormItemComp
元件中新增一個formData
的prop,用於傳遞表單資料給使用者
具體做法如下:
<template> <div 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 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> <form-item-comp :form-state="item1" :form-data="formData" /> </div> </template> <script setup> 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>