Vue 动态表单(Dynamic Form)

50 阅读6分钟

Vue 动态表单(Dynamic Form)

动态表单是指根据数据配置(如 JSON 或 JavaScript 对象)来动态生成表单字段的组件。它能够极大地提高开发效率,减少重复代码,尤其适用于字段频繁变化、需要配置化的场景,如后台管理系统、问卷生成器、自定义表单等。

什么是动态表单

传统的表单开发中,每个字段都需要在模板中手动编写 <input><select> 等标签,并绑定对应的 v-model 和验证规则。而动态表单通过配置驱动的方式,将字段的元数据(类型、标签、验证规则、布局等)抽象为一个数组或对象,然后使用 Vue 的渲染能力(如 v-for)循环生成表单元素。

核心思想: 将表单的结构与实现分离,通过修改配置即可调整表单,无需修改模板代码。

为什么需要动态表单

  • 提高开发效率:减少模板代码的编写,尤其是表单字段数量大、变化频繁的场景。
  • 增强可维护性:表单结构集中在配置中,修改字段只需调整配置项。
  • 支持配置化/可视化:可与后台接口配合,实现由后端返回表单配置的动态表单;也可用于拖拽式表单设计器。
  • 易于扩展:增加新的字段类型只需在渲染函数中添加对应组件,不影响现有逻辑。

基础实现

定义字段配置

首先,我们需要定义一组字段配置,每个字段包含类型、标签、字段名、默认值等信息。

// formConfig.js
export const fields = [  {    type: 'input',    label: '用户名',    field: 'username',    placeholder: '请输入用户名',    defaultValue: ''  },  {    type: 'select',    label: '性别',    field: 'gender',    options: [      { label: '男', value: 1 },      { label: '女', value: 2 }    ],
    defaultValue: 1
  },
  {
    type: 'radio',
    label: '爱好',
    field: 'hobby',
    options: [
      { label: '读书', value: 'book' },
      { label: '运动', value: 'sport' }
    ],
    defaultValue: 'book'
  },
  {
    type: 'checkbox',
    label: '技能',
    field: 'skills',
    options: [
      { label: 'Vue', value: 'vue' },
      { label: 'React', value: 'react' }
    ],
    defaultValue: ['vue']
  }
]

渲染表单

在 Vue 组件中,使用 v-for 遍历配置,根据 type 动态渲染不同的表单项。为了简化,我们可以用 v-if / v-else-if 判断,或者使用动态组件 <component :is="...">

<template>
  <form @submit.prevent="handleSubmit">
    <div v-for="field in fields" :key="field.field" class="form-item">
      <label :for="field.field">{{ field.label }}</label>
      
      <!-- 根据字段类型渲染不同控件 -->
      <input
        v-if="field.type === 'input'"
        :id="field.field"
        v-model="formData[field.field]"
        :placeholder="field.placeholder"
      />
      
      <select
        v-else-if="field.type === 'select'"
        :id="field.field"
        v-model="formData[field.field]"
      >
        <option v-for="opt in field.options" :key="opt.value" :value="opt.value">
          {{ opt.label }}
        </option>
      </select>
      
      <div v-else-if="field.type === 'radio'">
        <label v-for="opt in field.options" :key="opt.value">
          <input
            type="radio"
            :name="field.field"
            :value="opt.value"
            v-model="formData[field.field]"
          />
          {{ opt.label }}
        </label>
      </div>
      
      <div v-else-if="field.type === 'checkbox'">
        <label v-for="opt in field.options" :key="opt.value">
          <input
            type="checkbox"
            :value="opt.value"
            v-model="formData[field.field]"
          />
          {{ opt.label }}
        </label>
      </div>
    </div>
    
    <button type="submit">提交</button>
  </form>
</template>
​
<script setup>
import { ref } from 'vue'
import { fields } from './formConfig'
​
// 初始化表单数据
const formData = ref({})
fields.forEach(field => {
  formData.value[field.field] = field.defaultValue
})
​
const handleSubmit = () => {
  console.log('表单数据:', formData.value)
}
</script>

说明:

  • 使用 v-model 绑定到 formData 对象的对应字段。
  • 注意 checkboxv-model 绑定到数组,允许多选。
  • 这种方式简单直观,但当字段类型增多时,模板中的 v-if 会显得臃肿。我们可以进一步优化,使用动态组件。

使用动态组件优化渲染

我们可以为每种字段类型创建一个独立的组件(如 InputField.vueSelectField.vue),然后在模板中用 <component :is="getComponent(field.type)" /> 动态渲染。

<template>
  <form @submit.prevent="handleSubmit">
    <div v-for="field in fields" :key="field.field" class="form-item">
      <label>{{ field.label }}</label>
      <component
        :is="getComponent(field.type)"
        :field="field"
        v-model="formData[field.field]"
      />
    </div>
    <button type="submit">提交</button>
  </form>
</template><script setup>
import { ref, markRaw } from 'vue'
import InputField from './components/InputField.vue'
import SelectField from './components/SelectField.vue'
import RadioField from './components/RadioField.vue'
import CheckboxField from './components/CheckboxField.vue'const fields = [...] // 配置数组const componentMap = markRaw({
  input: InputField,
  select: SelectField,
  radio: RadioField,
  checkbox: CheckboxField
                
})
​
const getComponent = (type) => componentMap[type] || nullconst formData = ref({})
fields.forEach(field => {
  formData.value[field.field] = field.defaultValue
})
​
const handleSubmit = () => {
  console.log('表单数据:', formData.value)
}
</script>

每个字段组件接收 field 配置和 modelValue(用于 v-model),内部实现对应的控件。例如 InputField.vue

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
    v-bind="$attrs"
  />
</template><script setup>
defineProps(['modelValue', 'field'])
defineEmits(['update:modelValue'])
</script>

使用动态组件让代码更清晰,扩展新类型只需增加对应的组件,无需修改模板。

进阶功能

表单验证

动态表单的验证可以设计为配置式,例如在字段配置中添加 rules 属性。验证可以在提交时统一执行,也可以实时触发。我们可以使用第三方库如 VeeValidateVuelidate,也可以手动实现。

手动实现简单验证示例:

在字段配置中增加 rules

{
  type: 'input',
  label: '邮箱',
  field: 'email',
  rules: [
    { required: true, message: '邮箱不能为空' },
    { pattern: /^[^\s@]+@[^\s@]+.[^\s@]+$/, message: '邮箱格式不正确' }
  ]
}

在组件中,添加验证逻辑:

<script setup>
import { ref } from 'vue'
const errors = ref({})
​
const validate = () => {
  const newErrors = {}
  fields.forEach(field => {
    if (field.rules) {
      for (const rule of field.rules) {
        if (rule.required && !formData.value[field.field]) {
          newErrors[field.field] = rule.message
          break
        }
        if (rule.pattern && !rule.pattern.test(formData.value[field.field])) {
          newErrors[field.field] = rule.message
          break
        }
      }
    }
  })
  errors.value = newErrors
  return Object.keys(newErrors).length === 0
}
​
const handleSubmit = () => {
  if (validate()) {
    // 提交
  }
}
</script>

在模板中显示错误信息:

<div v-if="errors[field.field]" class="error">{{ errors[field.field] }}</div>

如果使用 UI 库(如 Element Plus),其表单组件通常自带验证机制,只需将配置传递给相应组件即可。

布局控制

动态表单常常需要灵活的布局,例如栅格系统。可以在字段配置中添加布局属性,如 span(占列数)、offset 等。

{
  type: 'input',
  label: '姓名',
  field: 'name',
  span: 12, // 占12列(假设24栅格)
  // ...
}

在模板中,可以结合 CSS 框架(如 Tailwind、Bootstrap 或 Element Plus 的布局组件)实现动态布局。

以 Element Plus 为例:

<el-form>
  <el-row :gutter="20">
    <el-col v-for="field in fields" :key="field.field" :span="field.span || 24">
      <el-form-item :label="field.label">
        <component
          :is="getComponent(field.type)"
          :field="field"
          v-model="formData[field.field]"
        />
      </el-form-item>
    </el-col>
  </el-row>
</el-form>

字段联动

联动是指一个字段的值变化影响另一个字段的显示、禁用、选项等。可以在配置中定义 dependencies,并在渲染时根据依赖动态计算属性。

实现思路:

  • 在字段配置中添加 visible 函数(或 if 条件),返回布尔值控制显示。
  • 使用 watch 监听依赖字段的变化,动态更新目标字段的配置(如选项列表)。

简单示例:根据选择的“国家”改变“城市”的选项。

{
  type: 'select',
  label: '国家',
  field: 'country',
  options: [...]
},
{
  type: 'select',
  label: '城市',
  field: 'city',
  options: [], // 初始为空
  dependsOn: 'country',
  updateOptions: (country) => {
    // 根据 country 返回新的选项数组
    if (country === 'china') return [{ label: '北京', value: 'beijing' }]
    // ...
  }
}

在组件中,可以定义一个方法监听依赖变化并更新选项。

动态增删字段

某些场景需要允许用户动态添加表单项,例如一组可重复的输入框(如教育经历)。可以在配置中支持 array 类型,使用 v-for 渲染多个相同结构的组。

示例: 动态添加技能列表。

配置:

{
  type: 'dynamic',
  label: '技能列表',
  field: 'skills',
  itemConfig: {
    type: 'input',
    placeholder: '请输入技能'
  },
  defaultValue: ['']
}

渲染时,维护一个数组,并提供添加/删除按钮。

<template>
  <div v-for="(item, index) in formData.skills" :key="index">
    <input v-model="formData.skills[index]" />
    <button @click="removeSkill(index)">删除</button>
  </div>
  <button @click="addSkill">添加技能</button>
</template><script setup>
const formData = ref({ skills: [''] })
const addSkill = () => formData.value.skills.push('')
const removeSkill = (index) => formData.value.skills.splice(index, 1)
</script>

结合 UI 库(Element Plus)的完整示例

下面是一个使用 Element Plus 实现的动态表单示例,包含验证和布局。

<template>
  <el-form :model="formData" :rules="rules" ref="formRef" label-width="100px">
    <el-row :gutter="20">
      <el-col
        v-for="field in fields"
        :key="field.field"
        :span="field.span || 24"
        v-if="field.visible ? field.visible(formData) : true"
      >
        <el-form-item
          :label="field.label"
          :prop="field.field"
          :rules="field.rules"
        >
          <!-- 动态组件渲染字段 -->
          <component
            :is="getComponent(field.type)"
            :field="field"
            v-model="formData[field.field]"
            v-bind="field.props"
          />
        </el-form-item>
      </el-col>
    </el-row>
    <el-form-item>
      <el-button type="primary" @click="submitForm">提交</el-button>
    </el-form-item>
  </el-form>
</template><script setup>
import { ref, reactive, markRaw } from 'vue'
import { ElMessage } from 'element-plus'// 字段类型映射组件
import ElInput from './components/ElInput.vue'   // 封装 Element Plus 输入框
import ElSelect from './components/ElSelect.vue' // 封装 Element Plus 选择器
// ... 其他组件const componentMap = markRaw({
  input: ElInput,
  select: ElSelect,
  // ...
})
​
const getComponent = (type) => componentMap[type]
​
// 字段配置
const fields = ref([
  {
    type: 'input',
    label: '用户名',
    field: 'username',
    span: 12,
    rules: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
    props: { placeholder: '请输入用户名' }
  },
  {
    type: 'select',
    label: '性别',
    field: 'gender',
    span: 12,
    rules: [{ required: true, message: '请选择性别', trigger: 'change' }],
    options: [
      { label: '男', value: 1 },
      { label: '女', value: 2 }
    ],
    props: { placeholder: '请选择' }
  },
  {
    type: 'input',
    label: '邮箱',
    field: 'email',
    span: 24,
    rules: [
      { required: true, message: '请输入邮箱', trigger: 'blur' },
      { type: 'email', message: '请输入正确的邮箱', trigger: 'blur' }
    ],
    props: { placeholder: '请输入邮箱' }
  }
])
​
// 表单数据
const formData = ref({})
fields.value.forEach(field => {
  formData.value[field.field] = field.defaultValue ?? ''
})
​
// 表单引用
const formRef = ref()
​
const submitForm = async () => {
  if (!formRef.value) return
  await formRef.value.validate((valid, fields) => {
    if (valid) {
      ElMessage.success('提交成功')
      console.log('表单数据:', formData.value)
    } else {
      console.log('验证失败', fields)
    }
  })
}
</script>

其中,封装的组件(如 ElInput.vue)需要适配 Element Plus 的 v-model 用法,并将 field.props 传递给原生组件:

<template>
  <el-input
    :model-value="modelValue"
    @update:model-value="$emit('update:modelValue', $event)"
    v-bind="field.props"
  />
</template><script setup>
defineProps(['modelValue', 'field'])
defineEmits(['update:modelValue'])
</script>

注意事项与最佳实践

  • 响应式数据:确保 formData 是响应式的,并在字段变化时能够触发视图更新。
  • 性能优化:如果字段数量很大,考虑使用虚拟滚动或懒加载;避免在模板中放置复杂的计算逻辑。
  • 类型扩展:将字段类型组件设计为可插拔,便于新增类型。
  • 配置标准化:定义统一的字段配置格式,便于维护和文档化。
  • 与后端配合:动态表单常与后端 API 结合,由后端返回表单配置(包括字段、选项、验证规则),前端只需渲染。
  • 可访问性:确保动态生成的表单元素具有正确的 idname 和标签关联,提升无障碍体验。