实现一个用户可以编辑的表单

业务需求

存在这样一个业务场景:

  • 用户需要提交一个表单到系统中,但是表单的内容是由用户自己决定的,比如用户添加/删除表单项,从而提交用户自定义的表单内容,比如姓名、年龄、入职时间等

从产品的角度来看,我们需要提供一个可编辑的动态表单,其最终效果如下:

组件设计

我们需要实现一个自定义组件,它能够:

  1. 接收外部传入的配置信息,来生成表单项的操作按钮(即上图中的"添加input","添加数字输入","添加日期"等按钮
  2. 用户在页面中输入表单项的名称后,点击操作按钮,能够生成对应的表单项
  3. 用户可以在生成的表单项中输入值,并对外传递

组件实现

定义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标记为不用响应式的数据(使用shallowRefmarkRaw

渲染表单项

此时在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>
相关推荐
Martin -Tang22 分钟前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发23 分钟前
解锁微前端的优秀库
前端
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
老码沉思录1 小时前
写给初学者的React Native 全栈开发实战班
javascript·react native·react.js
我不当帕鲁谁当帕鲁1 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂1 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐2 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成4 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css
红中马喽4 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习
Black蜡笔小新5 小时前
网页直播/点播播放器EasyPlayer.js播放器OffscreenCanvas这个特性是否需要特殊的环境和硬件支持
前端·javascript·html