基于 Vue3 (tsx语法)的动态表单深度实践-只看这一篇就够了

一、 引言:中后台业务生态下的动态表单诉求

在 Vue 中后台项目中,开发者在表单上耗费大量时间重复编写相似的 <el-form-item> 代码,导致开发效率低且难以维护。面对频繁变动的业务需求,因此出现了 "配置驱动" 的动态表单方案,通过 JSON 配置自动生成表单界面。

二:目标

本方案旨在设计一个高拓展性、易于维护的Vue动态表单组件:

  1. 动态生成:通过声明式的JSON配置,自动渲染完整的表单界面,免除编写重复的模板代码。
  2. 复杂交互 :支持字段间的条件显示、值联动、规则校验等,让表单能灵活响应动态业务逻辑。
  3. 布局统一 :提供统一的表单布局风格(如默认一行两列),并支持通过配置自定义行列占比与栅格规则,兼顾一致性与灵活性。
  4. 集成扩展 :允许轻松接入自定义表单控件,确保方案能适应多样化的业务组件需求。

三:基础实现

1.外部定义一个schema表

go 复制代码
const schema = [
  {
    // 字段的唯一标识,用于表单数据绑定和校验规则匹配,如 formData.age
    prop: 'age',
    // 字段的初始值,此值将作为 v-model 绑定的初始数据
    initValue: 18,
    // 显示在表单控件前的标签文本
    label: '年龄',
    // 指定渲染该字段所使用的表单组件,实际会映射到一个已注册的 Vue 组件(如 Element Plus 的 ElInput)
    component: 'Input',
    // 传递给组件的额外属性对象,这些属性将被透传绑定到组件的 props 上
    componentProps: {
      // 设置 Input 组件的 type 属性为 'number',这将渲染一个数字输入框
      type: 'number'
    }
  }
]

2.将schema表传递给动态表单组件

3.表单组件内部接收schema,完成初始化数据并渲染视图

typescript 复制代码
// 1.组件内部监听schema表
watch(
  () => props.schema,
  (arr = []) => {
    formData.value = initModel(arr, formData.value)
    console.log('formData.value根据schema拿到的值', formData.value)
    schema.value = cloneDeep(arr) || []
  },
  {
    immediate: true,
    deep: true
  }
)
// 2.初始表单数据
const initModel = (schema: FormOptions[], model: Object) => {
  const clone_model: any = cloneDeep(model)
  schema.forEach((v) => {
    // 表单里面是否有值了
    const hasField = Reflect.has(model, v.prop)
    // 如果先前已经有值存在,则不进行重新赋值,而是采用现有的值
    // 只更新新加的schema
    clone_model[v.prop] = hasField
      ? clone_model[v.prop]
      : v.initValue !== void 0
      ? v.initValue
      : ''
  })
  return clone_model
}

// 3.组件渲染
<ElForm
  ref="formRef"
  :model="formData"
  :validate-on-rule-change="false"
  label-suffix=":"
  label-position="right"
  // attrs属性透传绑定
  v-bind="attrs"
>
  <component :is="createRow()"></component>
</ElForm>

// 4.createRow函数
const createRow = () => {
  return (
    <>
      {schema.value.map((item: FormOptions, index: number) =>
        createFormItem(item, index)
      )}
    </>
  )
}

// 5.createFormItem函数
const createFormItem = (item: FormOptions, index: number) => {
  return (
    <ElFormItem
      key={index}
      label={item?.label}
      prop={item.prop}
    >
      {createFormItemDom(item, index)}
    </ElFormItem>
  )
}

import { ElInput } from 'element-plus'
export const comMap = {
  'Input': ElInput
}
// 6.createFormItemDom函数
const createFormItemDom = (item: FormOptions, index: number) => {
  const Com = comMap[item?.component]
  return (
    <Com
      v-model={formData.value[item.prop]}
    ></Com>
  )
}

4.视图效果

接下来运行项目,会发现表单被正常渲染,表单项的初始值也正常回显,这样我们一个最基本的动态表单就实现了。(相信大家也发现了,每一步都抽离了一个函数,为什么要这样做呢?往后看会有答案)

四:扩展

可是仅仅实现上面的效果,投入业务使用是远远不够的,常见业务需求:

1.控制字段显隐

2.校验规则、表单事件绑定、自定义内容及插槽渲染

3.表单字段联动

4.统一表单风格

5.表单基本方法

...

接下来我们一一实现:

4.1 实现字段显隐控制

实现字段的显隐控制非常简单,给每一个表单字段加一个hidden属性,渲染的表结构从schema变成过滤掉之后的filteredSchema就可以了。

typescript 复制代码
const filteredSchema = computed(() => {
  //过滤掉隐藏的表单项 -- 追踪
  return schema.value.filter((v: any) => !v?.hidden)
})

// 修改createRow函数,渲染filteredSchema

修改前 : 
const createRow = () => {
  return (
    <>
      {schema.value.map((item: FormOptions, index: number) =>
        createFormItem(item, index)
      )}
    </>
  )
}
修改后 :
const createRow = () => {
  return (
    <>
      {filteredSchema.value.map((item: FormOptions, index: number) =>
        createFormItem(item, index)
      )}
    </>
  )
}

效果演示:

go 复制代码
const schema = [
  {
    prop: 'age',
    initValue: 18,
    // 隐藏字段
    hidden: true,
    label: '年龄',
    component: 'Input',
    componentProps: {
      type: 'number'
    }
  },
  {
    prop: 'text',
    initValue: '数学试卷一张,语文试卷一张。',
    label: '作业',
    component: 'Input',
    componentProps: {
      type: 'text'
    }
  }
]

这样就实现了表单字段的显示与隐藏。

4.2 表单校验规则、表单事件绑定、自定义内容及插槽渲染

接下来我们继续完善动态表单的业务功能 :

4.2.1 校验规则添加

我们先回顾一下之前的createFormItem函数:

typescript 复制代码
// 修改前:

const createFormItem = (item: FormOptions, index: number) => {
  return (
    <ElFormItem
      key={index}
      label={item?.label}
      prop={item.prop}
    >
      {createFormItemDom(item, index)}
    </ElFormItem>
  )
}
目前只绑定了基本的prop和label,此时我们给它加上一个rules属性,
就加上了表单项的校验规则:
const createFormItem = (item: FormOptions, index: number) => {
  return (
    <ElFormItem
      key={index}
      label={item?.label}
      prop={item.prop}
      rules={item?.rules}
    >
      {createFormItemDom(item, index)}
    </ElFormItem>
  )
}
这里我们要考虑扩展性的问题,每一个属性都要声明一次 ,
组件的改动次数和扩展性就会大打折扣,所有我们借用"透传"的思想,
透传一个formItemProps对象,所有关于formItem的属性都可以经过这个对象传递:

// 修改后:
const createFormItem = (item: FormOptions, index: number) => {
  return (
    <ElFormItem
      key={index}
      label={item?.label}
      prop={item.prop}
      rules={item?.rules}
      // 透传formItem的相关属性
      {...(item?.formItemProps || {})}
    >
      {createFormItemDom(item, index)}
    </ElFormItem>
  )
}

效果演示:

yaml 复制代码
const schema = [
  {
    prop: 'age',
    initValue: 18,
    hidden: true,
    label: '年龄',
    component: 'Input',
    componentProps: {
      type: 'number'
    }
  },
  {
    prop: 'text',
    initValue: '数学试卷一张,语文试卷一张。',
    label: '作业',
    component: 'Input',
    // 添加必填校验
    rules: [
      {
        required: true,
        message: '请输入作业',
        trigger: 'blur'
      }
    ],
    componentProps: {
      type: 'text'
    }
  }
]

这样表单的校验规则已经生效,对于其他的自定义规则可以自行尝试。

4.2.2 表单事件绑定

我们先回顾一下之前的createFormItem函数:

typescript 复制代码
// 修改前:
const createFormItemDom = (item: FormOptions, index: number) => {
  const Com = comMap[item?.component]
  const componentProps = item?.componentProps || {}
  return (
    <Com
      v-model={formData.value[item.prop]}
    >
    </Com>
  )
}

// 目前只是做了一个表单字段的v-model绑定,并没有传入事件,
// 同样考虑到扩展性的问题,我们将传递给组件的属性和事件都放进一个对象
// componentProps,完成组件属性的透传

// 修改后:
const createFormItemDom = (item: FormOptions, index: number) => {
  const Com = comMap[item?.component]
  const componentProps = item?.componentProps || {}
  return (
    <Com
      v-model={formData.value[item.prop]}
      // 透传组件的相关属性和事件
      {...componentProps}
    >
    </Com>
  )
}

演示效果:

typescript 复制代码
const schema = [
  {
    prop: 'age',
    initValue: 18,
    hidden: true,
    label: '年龄',
    component: 'Input',
    componentProps: {
      type: 'number'
    }
  },
  {
    prop: 'text',
    initValue: '数学试卷一张,语文试卷一张。',
    label: '作业',
    component: 'Input',
    rules: [
      {
        required: true,
        message: '请输入作业',
        trigger: 'blur'
      }
    ],
    componentProps: {
      type: 'text',
      // 增加表单事件绑定
      onBlur: (val: any) => {
        console.log(val, '失去焦点')
      }
    }
  }
]

这样我们就完成了表单事件的绑定。

4.2.3 自定义内容及插槽渲染

在这里,我们先回顾一下之前的reateFormItemDom函数:

typescript 复制代码
// 修改前:

const createFormItemDom = (item: FormOptions, index: number) => {
  const Com = comMap[item?.component]
  const componentProps = item?.componentProps || {}
  return (
    <Com
      v-model={formData.value[item.prop]}
      {...componentProps}
    >
    </Com>
  )
}

// 这里就能看见,对于渲染内容的选择,已经被固定在了comMap的映射组件中,
// 如果想渲染一段文本或者其他自定义内容,这里显然是不满足的,以及这里的Com组件,并没有渲染自己的子组件,
// 像ElSelect 需要与 ElOptions搭配使用,所有我们也需要考虑这个情况

// 获取所有插槽
const slots = useSlots()
// 渲染ElOption子选项
const rendSelectOptions = (item: FormOptions) => {
  // console.log('渲染selecr选项', item)
  return item.componentProps?.options?.map((option: any) => {
    return (
      <ElOption
        key={option[item.componentProps?.valueField || 'value']}
        label={option[item.componentProps?.labelField || 'label']}
        value={option[item.componentProps?.valueField || 'value']}
      ></ElOption>
    )
  })
}

修改后:

const createFormItemDom = (item: FormOptions, index: number) => {
  // 如果指定插槽,直接渲染插槽内容
  if (item.slot && slots[item.prop]) {
    return slots[item.prop]!(item, formData.value)
  }
  const Com = comMap[item?.component]
  const componentProps = item?.componentProps || {}
  return (
    <Com
      style={{ ...(item?.comStyle || {}), width: '100%' }}
      v-model={formData.value[item.prop]}
      {...componentProps}
    >
      {{
        // 使用默认插槽(default slot)渲染 options
        default: () => (
          <>
            {item?.component === 'Select' &&
              item.componentProps?.options &&
              rendSelectOptions(item)}
          </>
        )
      }}
    </Com>
  )
}

演示效果:

css 复制代码
 <CodeForm ref="codeFormRef" :schema="schema">
      <template #desc>
        <div>这是我的一段描述-仅展示作用</div>
      </template>
 </CodeForm>
 
 const schema = [
  {
    prop: 'age',
    initValue: 18,
    hidden: true,
    label: '年龄',
    component: 'Input',
    componentProps: {
      type: 'number'
    }
  },
  {
    prop: 'text',
    initValue: '数学试卷一张,语文试卷一张。',
    label: '作业',
    component: 'Input',
    rules: [
      {
        required: true,
        message: '请输入作业',
        trigger: 'blur'
      }
    ],
    componentProps: {
      type: 'text',
      onBlur: (val: any) => {
        console.log(val, '失去焦点')
      }
    }
  },
  {
    prop: 'desc',
    label: '描述',
    slot: true
  },
  {
    prop: 'csa',
    label: '阶段',
    component: 'Select',
    componentProps: {
      options: [
        {
          label: '儿童',
          value: 1
        },
        {
          label: '青年',
          value: 2
        },
        {
          label: '老年',
          value: 3
        }
      ]
    }
  },
]

截止到这里,我们就实现了表单校验规则、表单事件绑定、自定义内容及插槽渲染的相关工作,接下来我们继续往下看,已经满足了一些简单系统的业务需求。

4.3 表单字段联动、表单数据获取

对于相对来说比较复杂的业务,常常也会伴随着表单字段的联动,接下来模拟一个简单的业务联动需求:

1.表单的阶段字段不可填,根据年龄自动映射;

2.输入年龄:

0-5岁,作业字段置空,且为禁用选项,并去除必填校验;阶段变为儿童;

6-22岁,作业字段默认赋值 : '一张试卷' ,并支持修改;阶段变为青年;

...(其他数字暂不做处理)

要实现这个需求,仅仅靠上面的实现肯定是不够的,上面所有的实现仅限于固定内容渲染,而不能 满足上面所提到的**"表单动态联动"、"表单动态赋值" ** ,这时给组件增加两个方法:

setSchemas :用于除初次渲染外,改变表单渲染结构。

typescript 复制代码
import { set } from 'lodash'

const setSchemas = (arr: SetColumns[]) => {
  // 在这里不要尝试修改value值  -- 也不要尝试在这新增schema项
  // 如果想修改 -- 请第一次就加上,设置了hidden的表单项,不会显示出来
  // 想要修改值,请使用setValues方法
  arr.forEach((item) => {
    const { path, prop, value } = item
    const isEext = schema.value.find((v: any) => v.prop === prop)
    // 如果路径不存在,无效操作
    if (!isEext) {
      console.warn(
        `[setSchemas] 属性 "${prop}" 不存在,跳过更新(请确保在初始化时声明)`
      )
      return
    }
    set(isEext, path, value)
  })
}

setValues :用于除初次渲染外,改变表单值

javascript 复制代码
const setValues = (value: Object) => {
  Object.assign(formData.value, value)
}

有了这两个方法的加入,我们就可以实现上面的业务效果了,演示效果:

yaml 复制代码
const schema = [
  {
    prop: 'age',
    label: '年龄',
    component: 'Input',
    componentProps: {
      type: 'number',
      onChange: (val: any) => {
        // 改变表单状态
        codeFormRef.value?.setSchemas([
          {
            prop: 'text',
            path: 'componentProps.disabled',
            value: val <= 5
          }
        ])
        // 改变表单数据
        codeFormRef.value.setValues({
          csa: val <= 5 ? 1 : val >= 6 && val <= 22 ? 2 : 3,
          text:
            val <= 5 ? '' : val >= 6 && val <= 22 ? '试卷一张' : ''
        })
      }
    }
  },
  {
    prop: 'text',
    initValue: '数学试卷一张,语文试卷一张。',
    label: '作业',
    component: 'Input',
    rules: [
      {
        required: true,
        message: '请输入作业',
        trigger: 'blur'
      }
    ],
    componentProps: {
      type: 'text',
      onBlur: (val: any) => {
        console.log(val, '失去焦点')
      }
    }
  },
  {
    prop: 'desc',
    label: '描述',
    slot: true
  },
  {
    prop: 'csa',
    label: '阶段',
    component: 'Select',
    componentProps: {
      options: [
        {
          label: '儿童',
          value: 1
        },
        {
          label: '青年',
          value: 2
        },
        {
          label: '老年',
          value: 3
        }
      ]
    }
  },
]

到这里,我们就实现了一个基本满足业务需求的动态表单。

4.4 统一表单风格

我们还需要统一一下表单风格,这里设计了两种风格:

  1. 居中展示,每行展示一个表单项
ini 复制代码
<CodeForm
      ref="codeFormRef"
      :schema="schema"
      :isCenter="true"
      :labelWidth="'100px'"
    >
      <template #desc>
        <div>这是我的一段描述-仅展示作用</div>
      </template>
    </CodeForm>

2.每行展示多个表单项(默认一行两个表单项)

ini 复制代码
<CodeForm
      ref="codeFormRef"
      :schema="schema"
      :labelWidth="'100px'"
    >
      <template #desc>
        <div>这是我的一段描述-仅展示作用</div>
      </template>
    </CodeForm>

实现代码:

javascript 复制代码
// 修改前 :
const createRow = () => {
  return (
    <>
      {schema.value.map((item: FormOptions, index: number) =>
        createFormItem(item, index)
      )}
    </>
  )
}

const calculateOffset = (item: FormOptions) => {
  // 默认 span 为 12(占一半宽度)
  const span = item?.colAttrs?.span || 12
  // 计算偏移量:(24 - span) / 2
  return Math.max(0, (24 - span) / 2)
}
// 修改后 :
const createRow = () => {
  if (props.layoutType === 'cell') {
    return (
      <ElRow
        {...(props.rowAttrs || {})}
        gutter={props.isCenter ? 0 : 10} // 非居中模式时才设置间隔
      >
        {filteredSchema.value.map(
          (item: FormOptions, index: number) => (
            <ElCol
              key={item.prop} // 建议用 item.prop 作为 key
              span={item?.colAttrs?.span || 12} // 优先使用 item 的配置
              offset={props.isCenter ? calculateOffset(item) : 0} // 动态计算偏移量
              {...(item?.colAttrs || {})}
            >
              {createFormItem(item, index)}
            </ElCol>
          )
        )}
      </ElRow>
    )
  } else {
    return (
      <>
        {filteredSchema.value.map(
          (item: FormOptions, index: number) =>
            createFormItem(item, index)
        )}
      </>
    )
  }
}
4.5 表单基本方法

下面总结了一些基本的表单方法,暴露在外给调用的组件使用:

getData :获取表单数据

javascript 复制代码
const getData = (isReset = false) => {
  return new Promise((resolve) => {
    //只要设置过的值,全部让其在外面组件可以拿到
    resolve(formData.value)
  })
}

resetFields :用于除初次渲染外,改变表单值

ini 复制代码
const resetFields = () => {
  formRef.value?.resetFields()
}

validate :用于除初次渲染外,改变表单值

typescript 复制代码
const validate = () => {
  return new Promise((resolve) => {
    formRef.value?.validate((valid: any) => {
      resolve(valid)
    })
  })
}

cleanField :用于除初次渲染外,改变表单值

typescript 复制代码
const cleanField = (field: string) => {
  formRef.value.clearValidate(field)
}

五:结言

以上内容就是此次实践动态表单的全部内容,各位快去试试吧!

组件库官网文档 : element-plus.org/

相关推荐
之恒君36 分钟前
PromiseResolveThenableJobTask 微任务是怎么被执行的
前端
华仔啊36 分钟前
CSS常用函数:从calc到clamp,实现动态渐变、滤镜与变换
前端·css
Aniugel37 分钟前
Vue2简单实现一个权限管理
前端·vue.js
乐无止境38 分钟前
系统性整理组件传参14种方式
前端
爱泡脚的鸡腿39 分钟前
uni-app D8 实战(小兔鲜)
前端·vue.js
izx88840 分钟前
JavaScript 中 `this` 的真相:由调用方式决定的动态指针
javascript
睡神雾雨41 分钟前
Vite 环境变量配置经验总结
前端
咪库咪库咪41 分钟前
vue5
前端