四种处理多字段、深嵌套、长表单的可行方案

540 阅读3分钟

引言

  • 最近在开发过程中遇到了典型的多层嵌套字段的长表单的问题,为了之后在遇到类似问题时可以快速找到最适合的解决方案,特在此将各种方案的优缺点做一个比较和记录。
  • 技术栈:Vue3 + Pinia + Ant Design Vue
  • 页面组件关系示意图

index.vue.png

  • 以JSON对象形式表示表单构成关系:
// 整个表单中共4层数组嵌套,每一层数组即上图中用外框圈起来的部分
const obj = {
 A: 'A',
 B: 'B',
 C: 'C',
 D: [
   {
     D_A: 'D-A',
     D_B: 'C-B',
     D_C: 'D-C',
     D_D: [
       {
         D_D_A: 'D-D-A',
         D_D_B: 'D-D-B',
         D_D_C: [
           {
             D_D_C_A: 'D-D-C-A',
             D_D_C_B: 'D-D-C-B',
             D_D_C_C: [
               {
                 D_D_C_C_A: 'D-D-C-C-A',
               },
               {
                 D_D_C_C_A: 'D-D-C-C-A',
                 D_D_C_C_B: 'D-D-C-C-B',
               },
             ],
           },
         ],
       },
     ],
   },
 ],
 E: 'E',
}

方案一:拆解v-model:value

  • 编码:
    • 在store中存储新增或编辑时从接口获取到的数据,为字段编写相应的action方法
    • 将v-model:value拆解成:value="param"+@change="handleChange"
    • 每个字段在表单项中通过:value传递默认值,通过@change触发store中action更新
  • 优点:
    • 简单,逻辑清楚。像处理普通表单一样处理各字段,没有额外的技术难度。不会在多人写协作开发时造成理解上的难度,也不会出现不同嵌套层级间互相修改导致的爆栈。
  • 缺点:
    • 要拆分的字段较多,代码量非常大且重复度高。
    • 需要向子级传递数组下标。编写sotre中的action时,依赖各嵌套层级的字段和数组下标,最后的嵌套层级需要自顶向上的3个或4个下标值。

方案二:ref获取子项expose的数据

  • 编码:
// Index.vue
<template>
    <Basic :ref="basic"></Basic>
    <Dispatch :ref="dispatch"></Dispatch>
    <button @click="handleSubmit">提交</button>
</template>
<script setup>
    const basic = ref(null)
    const dispatch = ref(null)
    const handleSubmit = ()=>{
       const submitData = {}
       const basicData = basic.value.getBasicData() 
       const dispatchData = basic.value.getDisaptchData()
       submitData.A = basicData.A
       submitData.B = basicData.B
       submitData.C = dispatchData.C
       submitData.D = dispatchData.D
    }
</script>
// Baisc.vue
<template>
    <from :model="basic">
        <input v-model:value="basic.A"/>
        <select v-model:value="basic.B"/>
    </form>
</template>
<script setup>
    const basic = reactive({
        A:'',
        B:'',
    })
    const getBasicData = ()=>{
        return basic
    }
    // 用expose出去函数代替直接expose出去data,来避免ref获取dom并非响应式的问题
    defineExpose({
        getBasicData,
    })
</script>
// Dispatch.vue
<template>
    <form :model="dispatch">
        <input v-model:value="dispatch.C">
        <template v-for="(item in dispatch.D">
            <Indicator :ref="(el,index)=>{dealRef(el,index)}">
        </template>
    </form>
</template>
<script setup>
const dispatch = reactive({
    C:'',
    D:[]
})
// v-for生成的组件list,需要通过函数存储起来,用{}代替[]来避免组件销毁的情况
const refList = ref({})
const dealRef = (el,index)=>{
    refList.value[index] = el
}
const getDisaptchData = ()=>{
    const dispatchData = {}
    dispatch.C = dispatch.C
    dispatch.D = Object.keys(refList.value).map((key)=>{
        return refList.value[key].getIndicatorData()
    })
    return dispatchData
}
</script>
// Indicator.vue
<template>
// 写法同dispatch.vue一致
</template>
<script setup>
// ref的获取和暴露indicator组件的数据与Dispatch.vue类似
defineExpose({
   getIndicatorData 
})
</script>
  • 优点
    • 不需要传递额外的字段名和数组下标。向每个子组件传递的数据都是与子组件的展示直接相关的,提交按钮点击时函数自上而下调用,数据自下而上返回。与通过触发action直接修改store中的数据相比,省略了很多确定当前字段在store中嵌套的第几层的第几个字段的操作。
    • 不依赖store进行状态管理。
  • 缺点
    • 全部表单都是通过ref模版引用来获取子组件的数据。ref的模版引用其实是vue3提供的获取dom的方案。

方案三:store hooks中存储提交函数

  • 编码:
// mystore.js
const submitList = []
export const useMyStore = defineStore({
  id: 'mystore',
  state: {
      submitData:{}
  },
  actions:{
      submit(){
          submitList.forEach(item=>{
              item()
          })
          await axios({url:'',param:this.submitData,......})
      },
      updateBasic(param){
          Object.assign(this.submitData,param)
      },
      updateFirstLevel(index1,param){
          this.submitData.D[index1] = param
      },
      updateSecondLevel(index1,index2,param){
          this.submitData.D[index1].D_D[index2] = param
      },
      updateThirdLevel(index1,index2,index3,param){
          //......
      },
      removeSubmit(id){
          submitList = submitList.filter((item)=>{
              return item.id !== id
          })
      }
  }
export function useMyStoreWithSubmit = (submitFunc)=>{
    submitFunc?submitList.push(submitFunc):''
    return useMyStore
}
// Index.vue
<template>
    <Basic></Basic>
    <Dispatch></Dispatch>
    <button @click="handleSubmit">提交</button>
</template>
<script setup>
    const myStore = useMyStoreWithSubmit()
    const handleSubmit = ()=>{
       myStore.submit()
    }
</script>
// Baisc.vue
<template>
    <from :model="basic">
        <input v-model:value="basic.A"/>
        <select v-model:value="basic.B"/>
    </form>
</template>
<script setup>
    const basic = reactive({
        A:'',
        B:'',
    })
    const submitBaisc = ()=>{
        myStore.updateBasic(basic)
    }
    const myStore = useMyStoreWithSubmit({id:'uniquneId1',func:submitBasic})
    onBeforeUnmount(()=>{
        myStore.removeSubmit('uniquneId1')
    })
</script>
// Dispatch.vue
<template>
    <form :model="dispatch">
        <input v-model:value="dispatch.C">
        <template v-for="(item,index) in dispatch.D">
            <Indicator :firstIndex="index" :indicator="item"/>
        </template>
    </form>
</template>
<script setup>
const dispatch = reactive({
    C:'',
    D:[]
})
const submitDispatch = ()=>{
    myStore.updateBasic(dispatch)
}
const myStore = useMyStoreWithSubmit({id:'uniquneId2',func:submitDispatch})
onBeforeUnmount(()=>{
    myStore.removeSubmit('uniquneId2')
})
</script>
// Indicator.vue
<template>
    <input v-model:value="indicatorData.D_A">
    <input v-model:value="indicatorData.D_B">
    <template v-for="(item,index) in indicatorData.D_D">
        <IndicatorItem :firstIndex="props.firstIndex" :secondIndex="index" :indicatorItem="item"/>
    </template>
</template>
<script setup>
const props = defineProps({
    firstIndex:{
        type:Number
    },
    indicator:{
        type:Object
    }
})
// v-model不能直接绑定props中的数据,违反了单向数据流的原则
const indicatorData = reactive({})
watch(()=>{return props.indicator},()=>{
    indicatorData = props.indicator
})
const submitDispatch = ()=>{
    myStore.updateFirstLevel(props.firstIndex,indicatorData)
}
const myStore = useMyStoreWithSubmit({id:'uniquneId2',func:submitDispatch})
// 当前组件被销毁时,将相应的submit函数移除
// 如果不移除,尽管后添加函数执行之后的会覆盖之前提交的数据,但是可能存在数组越界的情况,已经删除的组件数据仍然会因为submit函数的执行存在于最终提交的数据里
onBeforeUnmount(()=>{
    myStore.removeSubmit('uniquneId2')
})
</script>
  • 优点
    • 以组件为基本单位提交数据更新。相比于拆分整个表单字段更新数据的方式,编写代码量降低,action数量减少,调用action出错率降低。
    • 只在提交按钮点击时触发,无需关注额外的事件。
  • 缺点
    • 需要额外向子级传递数组下标。
    • 组件销毁时需要额外处理store中声明的全局变量。

方案四:$onAction

// mystore.js
export const useMyStore = defineStore({
  id: 'mystore',
  state: {
      submitData:{}
  },
  actions:{
      submit(param){
         await axios({url:'',param,......}) 
      },
  }
export function useMyStoreWithSubmit = (submitFunc)=>{
    return useMyStore
}
// Index.vue
<template>
    <Basic></Basic>
    <Dispatch></Dispatch>
    <button @click="handleSubmit">提交</button>
</template>
<script setup>
    const myStore = useMyStoreWithSubmit()
    const handleSubmit = ()=>{
       const param = {}
       myStore.submit(param)
    }
</script>
// Baisc.vue
<template>
    <from :model="basic">
        <input v-model:value="basic.A"/>
        <select v-model:value="basic.B"/>
    </form>
</template>
<script setup>
    const basic = reactive({
        A:'',
        B:'',
    })
    myStroe.$onAction(({ name, store, args, after, onError })=>{
        if(name==='submit'){
            args[0].A = basic.A
            args[0].B = basic.B
        }
    })
    const myStore = useMyStoreWithSubmit({id:'uniquneId1',func:submitBasic})
</script>
// Dispatch.vue
<template>
    <form :model="dispatch">
        <input v-model:value="dispatch.C">
        <template v-for="(item,index) in dispatch.D">
            <Indicator :firstIndex="index" :indicator="item"/>
        </template>
    </form>
</template>
<script setup>
const dispatch = reactive({
    C:'',
    D:[]
})
myStroe.$onAction(({ name, store, args, after, onError })=>{
    if(name==='submit'){
        args[0].C = dispatch.C
        args[0].D = dispatch.D
    }
})
const myStore = useMyStoreWithSubmit()
</script>
// Indicator.vue
<template>
    <input v-model:value="indicatorData.D_A">
    <input v-model:value="indicatorData.D_B">
    <template v-for="(item,index) in indicatorData.D_D">
        <IndicatorItem :firstIndex="props.firstIndex" :secondIndex="index" :indicatorItem="item"/>
    </template>
</template>
<script setup>
const props = defineProps({
    firstIndex:{
        type:Number
    },
    indicator:{
        type:Object
    }
})
const indicatorData = reactive({})
watch(()=>{return props.indicator},()=>{
    indicatorData = props.indicator
})
myStroe.$onAction(({ name, store, args, after, onError })=>{
    if(name==='submit'){
        args[0].D[props.firstIndex].D_A = indicatorData.D_A
        args[0].D[props.firstIndex].D_B = indicatorData.D_B
        args[0].D[props.firstIndex].D_D = indicatorData.D_D
    }
})
const myStore = useMyStoreWithSubmit()
</script>
  • 优点
    • 无需额外处理组件销毁的情况。
    • 以组件为基本单位提交数据更新。
  • 缺点
    • 需要处理数据存在但组件没有初始化时的情况。即数组存在3项,但第2项和第3项可能因为组件的懒加载并没有初始化,那也就不能在$onAction时触发数据更新,则最终提交的param中不存在这两项数据。
    • $onAction监听store中任何一个action触发,是一种性能上的浪费。
    • 需要额外向子级传递数组下标。
  • 补充:不用pinia进行状态管理的,可以使用eventbus来代替$onAction,本质是相同的,都是监听事件的触发,进行后续操作。