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

业务需求

存在这样一个业务场景:

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

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

组件设计

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

  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>
相关推荐
小白CAD28 分钟前
前端vue打印后端对象为[object,object]
前端·javascript·vue.js
续亮~4 小时前
6、Redis系统-数据结构-05-整数
java·前端·数据结构·redis·算法
顶顶年华正版软件官方5 小时前
剪辑抽帧技巧有哪些 剪辑抽帧怎么做视频 剪辑抽帧补帧怎么操作 剪辑抽帧有什么用 视频剪辑哪个软件好用在哪里学
前端·音视频·视频·会声会影·视频剪辑软件·视频剪辑教程·剪辑抽帧技巧
MarkHD5 小时前
javascript 常见设计模式
开发语言·javascript·设计模式
程序员云翼6 小时前
7-理财平台
java·vue.js·spring boot·后端·毕设
托尼沙滩裤6 小时前
【js面试题】js的数据结构
前端·javascript·数据结构
不熬夜的臭宝6 小时前
每天10个vue面试题(一)
前端·vue.js·面试
朝阳396 小时前
vue3【实战】来回拖拽放置图片
javascript·vue.js
不如喫茶去6 小时前
VUE自定义新增、复制、删除dom元素
前端·javascript·vue.js
长而不宰7 小时前
vue3+electron项目搭建,遇到的坑
前端·vue.js·electron