之前做一个某服务包管理系统那阵子,我最怕产品跑过来说"这个表单再加两个字段"。不是怕写,是怕每次都要改代码、发版、等审核。车型几十个、配置项上百个,服务包的字段还跟着车型变——手写表单根本扛不住。
后来我们搞了一套"配置驱动"的表单:字段长什么样、怎么校验、字段之间怎么联动,全由一份 JSON 说了算。今天把这套东西拆开讲讲,顺便说说我踩过的坑,给遇到类似需求的同学省点时间。
第一版:堆 v-if 的笨办法
最早就是老老实实堆 el-form-item,字段少的时候还行。等到服务包分了"基础版 / 豪华版 / 定制版",每版的字段不一样,我就在 template 里套一层又一层的 v-if:
<el-form-item label="定制说明" v-if="formModel.edition === 'custom'"> <el-input v-model="formModel.customDesc" /> </el-form-item>
改一个字段要翻半天代码,最离谱的时候一个 .vue 文件 800 多行,自己都看吐了。更糟的是,产品每调一次配置我就得改一次代码,开发和发版节奏完全被表单绑架。
核心转变:schema 驱动
痛定思痛,我把"表单长什么样"从代码里抽出来,变成一份数据。一份 schema 描述所有字段:类型、标签、绑定的字段名、校验规则、下拉选项。渲染的时候遍历它,动态生成表单项。
const formSchema = [
{ type: 'input', prop: 'packageName', label: '服务包名称',
rules: [{ required: true, message: '服务包名称必填' }] },
{ type: 'select', prop: 'carModel', label: '适配车型',
options: [{ label: '车型A', value: 'A' }, { label: '车型B', value: 'B' }] },
{ type: 'select', prop: 'edition', label: '版本',
options: [], dependOn: 'carModel',
watch: (val, formModel, ctx) => {
// 车型变了,重新拉该车型下的版本列表
formModel.edition = ''
ctx.loadOptions('edition', val)
} },
{ type: 'input', prop: 'customDesc', label: '定制说明',
visibleWhen: (formModel) => formModel.edition === 'custom',
rules: [(formModel) => ({
required: formModel.edition === 'custom',
message: '定制版必须填写定制说明'
})] },
]
渲染引擎:字段类型映射到组件
关键其实就一个东西——字段类型到组件的映射表。Element UI 的 input / select / date-picker / switch 都有现成组件,用一个 map 兜住,再用 component :is 动态渲染。所有字段的值统一收集到一个 formModel 对象里。
<template>
<el-form :model="formModel" :rules="mergedRules">
<el-form-item
v-for="field in visibleFields"
:key="field.prop"
:label="field.label"
:prop="field.prop">
<component
:is="fieldMap[field.type]"
v-model="formModel[field.prop]"
v-bind="field.props || {}" />
</el-form-item>
</el-form>
</template>
<script>
import FormInput from './fields/FormInput.vue'
import FormSelect from './fields/FormSelect.vue'
// ...其他字段组件
export default {
data() {
return {
formModel: {},
fieldMap: { input: FormInput, select: FormSelect /* ... */ }
}
},
computed: {
visibleFields() {
return this.schema.filter(f => !f.visibleWhen || f.visibleWhen(this.formModel))
},
mergedRules() {
// 校验前先按当前 formModel 算出每个字段的 rules
const rules = {}
this.schema.forEach(f => {
if (!f.rules) return
rules[f.prop] = f.rules.map(r => (typeof r === 'function' ? r(this.formModel) : r))
})
return rules
}
}
}
</script>
这里有个 Vue2 的坑得提醒:动态 prop 的字段,如果不在 data 里预先声明,formModel[field.prop] 是不会响应式的。我当初就在这里栽过——表单能渲染,但输入不回显。解决办法是渲染前先 this.$set(this.formModel, field.prop, defaultValue),把所有 schema 里的字段一次性初始化好。
校验规则可配置,但"动态必填"才是坑
rules 本身就好配,Element 的 rules 数组天然是配置友好的。真正的麻烦是条件必填——比如选了"定制版"才要填"定制说明"。
我第一版是在 rules 里写死,结果加一个条件就改一处代码,又回到了原点。后来改成 rules 支持函数:required 可以是 (formModel) => formModel.edition === 'custom'。引擎在每次校验前,先拿当前的 formModel 把每个字段的 rules 算一遍(见上面 mergedRules 的 map)。这样"什么条件下必填"就彻底变成配置的事,不用动渲染代码。
联动逻辑:最难的那个
字段 A 一变,字段 B 的选项或显隐要跟着变。我第一版用 watch 硬编码 A→B 的关系,结果字段一多,watch 里套 watch,乱成一锅粥,哪天改了 A 忘了 B,bug 就来了。
后来统一在 schema 里给字段加 dependOn 和 watch 回调(见上面 edition 字段的例子)。引擎的逻辑是:任意字段变化后,遍历所有声明了 dependOn 该字段的字段,挨个调它们的 watch 回调。所有联动都走同一条调度通道,不再散落各处。新增一个联动,只改 schema,不改引擎。
真实项目里的几个边界问题
-
字段上百个,渲染卡:用
v-show替代频繁增删节点,或者按业务分 Tab / 分步骤。 -
嵌套字段:服务包里常有"子配置列表",得支持数组型字段。schema 里加
type: 'array',渲染引擎碰到数组类型就递归渲染子 schema,数据结构也跟着递归收集。 -
编辑态 vs 预览态:配置是同一份,只是渲染模式不同。编辑态把字段元信息(类型、选项)也展示出来,预览态只渲染纯表单。
回头看
这套东西现在想想,核心就一句话:把 UI 变成数据。配上 Vue 的 component :is 和动态 v-model,配置即表单。后来做别的系统遇到类似的"字段老变"的需求,直接把这套引擎搬过去、改改 schema 就行,开发和发版节奏再也没被表单拖过后腿。
给后来者的建议:别一上来就追求"万能表单",先把你们项目里字段变化最频繁的那一个表单做成 schema 驱动,尝到甜头再扩。真要做全了,你会发现难点从来不是"渲染",而是"联动"和"动态校验"这两块——把这两块用配置描述清楚,事情就成了一大半。
如果你们项目里也有类似的动态表单需求,欢迎在评论区聊聊你们是怎么解的,互相取取经。

501

被折叠的 条评论
为什么被折叠?



