表单系统(低代码表单)

低代码表单

实现三个目标
  • 根据一个特定的数据自动生成表单
  • 获取填写数据并支持校验功能
  • 能自定义属性作用到input等原始组件上
首先讲讲总体思想,所谓的低代码表单就是后端传递一个特殊结构的对象数组,然后确定根据这个对象数组来自动生成表单,这个对象结构可以是这样的:
interface FormItem { value: "" // 预填 config: { // 配置 field: 0 // 字段类型 validator: '[0-9]{2}' // 正则校验 SelectOptions: ['男','女'] // 选项 placeholder: "xxx" // 需要透传到原生元素节点的html属性 } }
然后我们定义好每个原子组件,比如Input组件、Select组件等,然后利用vue动态组件来动态地选择渲染哪个组件。在这个过程中,我们定义Form组件,作为一个整体的表单组件,它会接收前面说的对象数组并负责迭代渲染每一个具体的组件,而在Form组件到原生元素节点之间建立一个Field组件,来做为中间层进行一些处理,例如数据处理。
按这种设计思路,组件数据流就是这样的:
Form | Filed | 原生节点
如何传递html属性到原生表单元素上?
通过$attrs透传config对象
如何获取每个表单子项的数据到Form组件中并实现数据校验?
在Field组件监听onChange事件可以获取数据,并通过emit(’update:modelValue’) 将数据传递到Form表单组件中。这样的优点是能够实时地获取数据,在某些时候会很有用,但是缺点也有很多:
  1. 对一些表单元素无效
  1. 如果用户不输入任何值,则传递到Form组件的值为空,而不是预填值
因此我们还需要做另外一个方案来弥补这些缺点,并且还能实现校验。
这个方案的思路是通过defineExpose暴露一个校验函数,函数校验成功则返回输入数据,否则返回提示信息。
在用户点击表单提交时可以利用此校验函数进行校验,如果想用户每输入一个表单项就校验的话可以监听onBlur事件。

简易版本完整代码:

 
// App.vue <template> <div> <div> <h3>操作区</h3> <div v-for="(item, index) in operators" :key="index"> <span>{{FieldMap[item.config.field]}}</span> <button @click="addField(item)">添加</button> </div> </div> <Form :formData="formData" v-model="formValue" /> </div> </template> <script setup> import { reactive, watch, ref } from 'vue'; import { FILES_MAP } from './form/field/fields'; import Form from './form/Form.vue'; const FieldMap = reactive(FILES_MAP) const operators = [ { value: '10', // 预填数值 config: { field: 0, // 类型 type: 'number', // 透传 placeholder: "手机号码", // 透传 validator: '[0-9]{2}' // 校验 } }, { value: '女', config: { field: 1, placeholder: "性别", SelectOptions: ["男", "女"], } } ] const formData = reactive([]) const formValue = ref([]) // 表单各项输入值 watch(formValue, (val)=>{ console.log(val); // 观察数值是否异常 }) function addField(item){ formData.push(item) } </script>
 
// Form.vue <template> <div> // 遍历渲染 <Field v-for="(field, index) in formData" :key="index" :field="field" v-model="formValue[index]" ref="fieldRefs" /> <button @click="submit">提交</button> </div> </template> <script setup> import { reactive, ref } from 'vue'; import Field from './field/Field.vue'; const emit = defineEmits(['update:modelValue']) const props = defineProps({ formData: { type: Array, default: ()=>[] } }) // 数据的值,初始时使用预填值 const formValue = reactive(props.formData.map(item => item.value)) // 在v-for中获取所有的ref const fieldRefs = ref([]) // 提交时进行校验 function submit(){ const validators = fieldRefs.value.map((fieldRef)=> fieldRef.validate) Promise.all(validators.map(validate=> validate())).then(res=>{ formValue.splice(0, formValue.length) formValue.push(...res) console.log('res',res); emit('update:modelValue', formValue) }).catch(err=>{ alert(err) }) } </script>
 
Field组件,包裹元素节点组件,在这里通过动态组件渲染特定的表单元素组件,另外也负责将数据回传会Form组件中。
// Field.vue <template> <div> <component :is="filedCom" v-bind="field.config" :value="field.value" @change="onChange" ></component> </div> </template> <script setup> import { computed, toRef,ref } from '@vue/reactivity'; import { FIELDS_COMPONENTS, FILES_MAP } from './fields'; // v-model 绑定 const emit = defineEmits({ "update:modelValue": String }) const props = defineProps(['field']) const filed = toRef(props, 'field') // 用户输入的数值,初始化时使用预填值 const value = ref(props.field.value) // 根据field字段选择组件 const filedCom = computed(()=> FIELDS_COMPONENTS[FILES_MAP[filed.value.config.field]]) function onChange(e){ value.value = e.target?.value || '' emit("update:modelValue", value.value) } // 定义校验函数,校验函数不仅仅起到校验作用,还要传递数据 async function validate(){ const validator = filed.value.config?.validator if(validator){ if(new RegExp(validator).test(value.value)){ return value.value }else{ return Promise.reject("错误提示消息") } } return value.value } defineExpose({validate}) </script>
 
field文件,保存组件映射。
// field.js import InputCom from "../components/input.vue" import SelectCom from "../components/select.vue" // 表单组件映射 export const FIELDS_COMPONENTS = { 'INPUT': InputCom, "SELECT": SelectCom } // field字段映射,和后端约定好有哪些类型 export const FILES_MAP = { 0: 'INPUT', 1: 'SELECT' }
原生表单组件,可以通过css美化样式,可以根据不同的元素类型做一些特定的处理。
// input.vue <template> <input /> </template> // select.vue <template> <select> <option v-for="item in $attrs.SelectOptions" :key="item">{{ item }}</option> </select> </template>
 

实现无限嵌套

实现无限嵌套是指表单子项里嵌套着另一个表单,例如新加一个表格类型,表格类型可以渲染输入框、多选框,甚至也可以再渲染表格类型。
实现的思路也不难,首先约定好表格类型的field值,假设是3,然后我们在config字段里加上一个新字段fields,它只在表格类型时有效,fields实际上也是一个FormItem对象数组,然后fields数组中又可以嵌套表格类型,如此形成无限嵌套。
在组件上,可以利用递归处理这种嵌套情况,可以递归Form表单组件,也可以递归Field字段组件。具体这里不展开讲

小结

上面的实现比较简单,只实现了文本输入框和多选框,实际中要处理的情况更复杂,不过有了思路后面的组件扩展也比较简单。
需要注意的是,defineExpose暴露函数时,函数的返回值如果是reactive或者ref,那么就会将响应式传递到父组件中,这不符合单向数据流的思想,并且会导致项目变得难以维护,最好是先消除响应式再传递数据,简单的解决思路是:
return data.value //ref return JSON.parse(JSON.stringify(toRaw(data))) // reactive
toRaw函数会返回reactive的原始对象,但是不建议直接操作原始对象,最好使用深拷贝拷贝出一个新的对象进行操作,最简单的深拷贝就是使用JSON。更多细节见
Vue组件通讯
Vue组件通讯

动态表单

所谓的动态表单,是指在多表单系统(一个页面多个表单)中,用户输入某个字段达到一定条件时可以影响另一个表单,举个例子,例如第一个表单的select选项框选择男时,另一个表单的输入框placeholder显示先生你好,如果是女则显示女生你好
实现起来并不难,因为我们前面已经实现了Form组件的v-model这里需要注意的是设计思路应该是自上而下的,因为单向数据流更容易维护。
解决思路是在两表单上增加一个逻辑层,然后监听表单的数据,当达到要求条件时操作FormItem.config配置对象来实现改变表单结构的目的。
// App.js <Form :formData="formData" v-model="formValue" /> <Form :formData="formData" v-model="formValue" /> // Field中监听change事件,当change时也向上传数据 <Field v-for="(field, index) in formData" :key="index" :field="field" @change="submit" v-model="formValue[index]" ref="fieldRefs" /> // App.js const formData = reactive([{ value: '', config: { field: 1, placeholder: "性别", SelectOptions: ["男", "女"], } }]) const formValue = ref([]) const formData2 = reactive([{ value: '', // 预填数值 config: { field: 0, // 类型 type: 'number', placeholder: "手机号码", // 透传 validator: '[0-9]{2}' } },]) const formValue2 = ref([]) watch(formValue, ([val]) => { if(val === '男'){ formData2[0].config.placeholder = "先生你好" }else { formData2[0].config.placeholder = "女士你好" } })
 

代码示例