手把手带写一个优雅的联动表单(动态表单)解决方案

前言

本文写于“动态表单项目”诞生之初,手把手带写b端联动表单小轮子并记录一下自己的思考,完整复现了作者设计一个“配置化”表单工具时的开发思路与实现技巧

通过(摸鱼学习)本文,将会收获:

  • 一个优雅的联动表单解决方案
  • 一些关于配置化轮子的设计思想与开发技巧
  • 递归组件等等编码实践

(通过声明式的配置得到)如下效果:

手把手带写一个优雅的联动表单(动态表单)解决方案

从用户侧入手——构想用户消费方式

比如要实现一个配置化的联动表单,首先思考用户侧的使用,大致就是用户提供一些配置,中间可能被我们处理成一些标准的联动表单配置对象,最终表单对象映射为具体的联动表单组件。

所以第一步我们可能提供一个函数Fn去接收用户提供的配置,然后这个函数的作用就是返回一个表单项的配置对象用于映射成组件,所以用户可能的消费方式就是Fn(some options),比如我们接收用户配置的方法叫createFormItem,然后我们就要思考一个联动表单的表单配置需要哪些属性:

  • 首先需要一个type用来标识要渲染的组件的类型,可能取值为"input" | "select" | "checkbox"
  • 然后是payload参数可能是一个对象,有valueoptions等属性对应目标组件的valueoption
  • 最后从动态表单的实现层面比较核心的就是第三个参数next,它是一个函数,决定了下一个表单项是什么,换句话说决定下面渲染什么表单,我们可以提供给用户currentacients两个参数,分别代表当前的表单配置项和所有的前置表单项(祖先),这样用户就可以根据前置的所有表单项的取值等自行编写逻辑,从而决定下一个表单项究竟展示什么(设计核心:下一项表单展示什么由他的所有前置表单的状态决定

所以createFormItem方法可能的类型定义如下:

import { reactive } from "vue";
​
export type TFormItemType = 'input' | 'select' | 'checkbox'export interface IFormItem  {
  type: TFormItemType;
  payload: any;
  next: (current: IFormItem, acients: IFormItem[]) => IFormItem | null
  parent: IFormItem | null; // 这里之所以给表单项多加一个parent属性是为了后续方便构造acients数组
}
​
export function createFormItem(
  type: IFormItem['type'],
  payload: IFormItem['payload'],
  next: IFormItem['next']
): IFormItem {
  // ...
  // return like { type, paylo likead, next, parent };
}

上面的思考就是从用户侧考虑我们需要什么,然后下面就是一个可能的用户创建表单配置项的具体操作:

const formItem1 = createFormItem(
  'input',
  {
    label: '值为show-select则展示下拉框',
    value: 'show-select'
  },
  (current, acients) => {
    // 当前表单的value为'show-select'时下一个渲染formItem2
    if(current.payload.value === 'show-select') {
      return formItem2
    }
    return null;
  }
)

运行时之前的准备工作——通过接口函数对原始数据进行“增强”

标题的意思可以粗略的理解为:我们的接口函数接收了用户的原始配置,然后我们可以对这些配置数据,或者说依赖这些数据做一些处理,从而达成一定的目的,为运行时之前做进一步的准备工作。

具体以我们正在实现的动态表单为例,所谓接口函数就是指暴露给用户的接口,这里也就是createFormItem方法;最终消费表单配置对象的是一个组件,也就是说真正的运行时是组件的渲染,只有在运行时,我们才可以正确的提供next方法的currentacients参数,或者说currentacients参数也是仅仅针对运行时的概念,所以说我们执行createFormItem的时候还不到真正调用用户传入的next方法的时候,但是我们直接透传原始的next方法给运行时(组件),我们是没有办法构造acients的,因为formItem节点之间是孤立的。所以我们对原始next多包装一层再传给运行时,包装一层的目的就是让formItem之间建立联系,我喜欢称呼这样的操作为“逻辑增强”

createFromItem的完整逻辑与思路注释如下:

export function createFormItem(
  type: IFormItem['type'],
  payload: IFormItem['payload'],
  next: IFormItem['next']
): IFormItem {
  
  // 对next方法进行“增强”,核心逻辑还是调用next,并且返回next的返回值
  const nextFunc: IFormItem['next'] = (current, acients) => {
    
    const nextItem = next(current, acients);
​
    // 增强: 
    // 调用next方法确定下一个表单项的时候,也就意味着下一个表单项的parent是当前表单项,所以给下一个表单项添加parent属性
    // 最终目的还是让表单项之间建立关联,从而为运行时提供构造acients的条件
    if(!nextItem) {
      return null
    }
    nextItem.parent = current;
​
    return nextItem;
  }
​
  // 最终一定要返回一个响应式对象(状态改变影响视图更新)
  return reactive({
    type,
    payload,
    next: nextFunc,
    parent: null,
  })
}

万事俱备,成功前的最后一步——依赖前置成果编写运行时

我们的目标是动态表单,自然最后一步是把前面创建的表单对象翻译成具体的组件,而且在组件渲染时,我们还需要计算acients等然后正儿八经的、真正的去调用next方法,前面对next方法的“增强”也终于到了“用武之地”。

至于组件如何编写,自然是通过prop接收一个表单配置对象,然后条件渲染具体的组件。然后还需要通过next方法确定下一个组件渲染什么,在html结构部分可以通过递归的形式消费“链表式”的组件配置对象

FormItem.vue

<template>
  <template v-if="props.formState">
    <el-form-item :label="props.formState.payload.label">
      <!-- 根据props.formState.type进行条件渲染,消费当前配置对象渲染为表单组件 -->
      <el-input v-if="props.formState.type==='input'" v-model="props.formState.payload.value" />
      <el-select v-if="props.formState.type==='select'" v-model="props.formState.payload.value">
        <el-option
          v-for="item in props.formState.payload.options"
          :key="item.value"
          :label="item.label"
          :value="item.value"
        />
      </el-select>
      <el-checkbox-group v-if="props.formState.type==='checkbox'" v-model="props.formState.payload.value">
        <el-checkbox
          v-for="item in props.formState.payload.options"
          :key="item.value"
          :label="item.label"
          :value="item.value"
        />
      </el-checkbox-group>
    </el-form-item>
    <!-- 递归使用FromItem组件,并调用getNext方法确定下一个组件配置对象 -->
    <form-item :form-state="getNext()"></form-item>
  </template>
</template><script setup lang="ts">
import { IFormItem } from './FormItem';
import { ElFormItem, ElSelect, ElInput, ElCheckboxGroup, ElCheckbox } from 'element-plus';
const props = defineProps<{formState:IFormItem | null}>();
​
// 组件内getNext即为运行时(真正去调用的时机),此时提供current与acients
const getNext = () => {
  const current = props.formState;
  if(!current) return null;
​
  // 构造acients
  let ptr = current;
  const acients: IFormItem[]  = [];
  while(ptr && ptr.parent) {
    // 指针移动
    ptr = ptr.parent;
    // 浅拷贝 & 插入acients
    const acient = ptr;
    acients.unshift(acient);
  }
​
  return props.formState?.next(current, acients);
}
</script>

最后我们可以在App.vue中测试一下效果,如下也就是我们编写的动态表单的消费方式:

<script setup lang="ts">
import dynamicFormItem from './components/dynamic-form/DynamicForm';
import FormItem from './components/dynamic-form/FormItem.vue';
import { ElForm } from 'element-plus';
</script><template>
 <el-form>
  <form-item :form-state="dynamicFormItem"></form-item>
 </el-form>
</template><style scoped>
</style>

源码地址 & 补充说明

  • 源码已上传git:gitee.com/jin-rongda/…
  • 此文章使用的代码是基于git第一次提交版本,是实现动态表单的最精简版本,但同时存在很多不足,比如配置不够方便,通过acients数组访问祖先控件不够方便等问题,上面version的问题可能也已经解决,需要访问如上代码回滚到第一次提交即可。
  • 这个小轮子会不断改善,优化用户体验 & 规范代码逻辑等等,欢迎大家cr指正!

原文链接:https://juejin.cn/post/7352079468507021348 作者:荣达

(0)
上一篇 2024年4月1日 上午10:52
下一篇 2024年4月1日 上午11:03

相关推荐

发表回复

登录后才能评论