打造一套「可控且可扩展」的配置型表单系统:Vue3 + Element Plus 实战拆解
关键词:配置驱动(Schema)、递归渲染、provide/inject 上下文、嵌套数据结构、Element Plus 表单校验、字典下拉缓存、可扩展字段体系
适用场景:规则参数配置、动态表单(后端下发 schema)、多页面复用的通用表单能力沉淀
一、你真的需要“配置型表单”吗?
当你的项目出现以下任意两条,配置型表单就开始“值回票价”:
- 表单数量多、字段频繁变化:每次加字段都要改模板,复制粘贴
<el-form-item>容易出现风格不一致,Bug 也难排。 - 多个页面需要同一套交互/样式/校验:必填提示、问号说明、禁用逻辑、label 宽度、分组布局等反复出现。
- 字段由后端决定:比如“规则参数 metaInfo / 脚本参数配置”,前端不应该写死字段。
- 你希望扩展能力可控:新增字段类型的成本要稳定(最好 30 分钟搞定)。
配置型表单的核心思想是:
把“表单结构”变成数据(Schema),把“渲染规则”集中在组件系统里。
二、能力清单:一套“够用且好扩”的最小系统
以本项目实现为例,这套系统的能力边界很清晰:
- 配置驱动渲染:
FormItemConfig[]→ 自动生成表单 - 基础字段:Input / InputNumber / TextArea / Select / Description(纯展示)
- Section 分组布局:支持折叠、网格(columns + span)、样式统一
- 嵌套数据结构:Section 可选
key,决定子字段是“平铺”还是“嵌套对象” - 校验:直接复用 Element Plus 校验体系(
prop+rules) - 默认值:配置
defaultValue自动补齐 - 字典下拉:
dictType拉取并缓存、loading 状态 - 方法暴露:
validate/resetFields/clearValidate/scrollToField/getFormRef
三、总体架构:把复杂性分层,扩展性自然出现
推荐把系统拆成 4 层(本项目也是这样做的):
- Schema 协议层(
types.ts):用 TS 联合类型约束配置结构 - Form 容器层(
ConfigurableForm.vue):管理 v-model、默认值、表单方法 - 渲染分发层(
FormRenderer.vue):把componentType映射到具体组件,负责递归 - 字段实现层(
FormInput/FormSelect/...):统一“label/tooltip/禁用/校验/绑定”
你可以用一张“数据流 + 组件流”图来理解:
四、Schema(配置协议)怎么设计才“好用又不失控”?
建议从“稳定字段”出发:先定义有限的 ComponentType 枚举,然后为每种 type 定义明确的配置结构。
关键点:
- 所有字段共享一套 Base:
title/key/required/defaultValue/disabled/placeholder/showTitle/span/... - 差异化参数放进
extraInfo:例如 Select 的options/dictType/multiple,TextArea 的rows,Number 的min/max/precision - Section 是特殊节点:它本身不绑定字段值,而是承载
children和布局信息;可选key决定数据结构
一个推荐的“发布版”简化协议长这样(便于读者理解;你实际项目可更细):
1export enum ComponentType {2 SECTION = "section",3 INPUT = "input",4 INPUT_NUMBER = "inputNumber",5 TEXTAREA = "textArea",6 SELECT = "select",7 DESCRIPTION = "description",8}9
10export type FormItemConfig =11 | SectionConfig12 | InputConfig13 | InputNumberConfig14 | TextAreaConfig15 | SelectConfig16 | DescriptionConfig;17
18export interface BaseConfig {19 componentType: ComponentType;20 title: string;21 key?: string; // section 可不填,其它一般必填22 required?: boolean;23 defaultValue?: unknown;24 disabled?: boolean;25 placeholder?: string;26 description?: string; // 问号提示27 showTitle?: boolean; // 是否显示 label28 span?: number; // 网格跨列29}30
31export interface SectionConfig extends BaseConfig {32 componentType: ComponentType.SECTION;33 key?: string; // 有 key => 嵌套对象;无 key => 平铺34 children: FormItemConfig[];35 extraInfo?: {36 collapsible?: boolean;37 defaultExpanded?: boolean;38 columns?: number;39 columnGap?: number | string;40 rowGap?: number | string;41 };42}43
44// 其它字段 config:Base + extraInfo + 强制 key45export interface InputConfig extends BaseConfig {46 componentType: ComponentType.INPUT;47 key: string;48}
为什么要用 TS 联合类型?
因为它能把“配置写错”变成编译期错误:比如 Input 忘了写 key、Select 的 extraInfo 写成了错误字段名——这些在大型项目里能省很多排查成本。
五、Form 容器:如何把 <el-form> 封装成“配置表单的运行时”?
容器层要做的事很明确:
- 接管
v-model:外部传入modelValue,内部统一通过 computed get/set 触发update:modelValue - 默认值补齐:根据 schema 递归收集
defaultValue,在数据缺省时写入 - 向下提供 context:所有字段通过 inject 读写同一套数据/能力
- 暴露表单方法:把 Element Plus
FormInstance的能力透出(validate、resetFields…)
你可以用下面的“抽象版代码”概括容器层(方便博客阅读):
1const formRef = ref<FormInstance>();2
3// v-model 桥接4const formData = computed({5 get: () => props.modelValue,6 set: (v) => emit("update:modelValue", v),7});8
9// 统一更新入口:不可变更新(外层对象)10function updateField(key: string, value: unknown) {11 emit("update:modelValue", { ...props.modelValue, [key]: value });12}13
14// 提供上下文(数据 + 操作 + 全局配置)15provide(16 FORM_CTX_KEY,17 computed(() => ({18 formData: props.modelValue,19 updateField,20 disabled: props.disabled,21 sectionStyle: props.sectionStyle,22 sectionHeaderRenderer: props.sectionHeaderRenderer,23 }))24);25
26// 暴露 el-form 方法27defineExpose({28 validate: () => formRef.value?.validate(),29 reset: () => formRef.value?.resetFields(),30});
这一层的设计要点是:
页面只关心“schema + v-model + 提交”,表单运行时细节全部收口在容器里。
六、渲染引擎:递归渲染 + Section 作用域(Scoped Context)
配置型表单的渲染器通常只有一个职责:把 schema 映射到组件树。
但真正决定系统“是否能做复杂表单”的,是 Section 的处理方式。
6.1 Section 的两种模式:平铺 vs 嵌套
- 无 key 的 Section:子字段写入根对象(平铺)
1// { name: "张三", age: 18 }
- 有 key 的 Section:子字段写入
modelValue[sectionKey](嵌套)
1// { basicInfo: { name: "张三", age: 18 } }
6.2 为什么需要 propPrefix?
Element Plus 表单校验使用的是字段路径 prop。
当数据变成嵌套结构时,校验路径必须从 name 变成 basicInfo.name,多层嵌套则继续拼接。
因此渲染器会在 Section(有 key)时创建一个“作用域上下文”:
formData指向嵌套对象updateField变成“更新嵌套对象并回写父级”- 增加
propPrefix = parentPrefix + "." + sectionKey供子字段计算prop
抽象版写法如下:
1function createScopedContext(parentCtx, sectionKey) {2 const parentData = parentCtx.formData;3 const prefix = parentCtx.propPrefix4 ? `${parentCtx.propPrefix}.${sectionKey}`5 : sectionKey;6
7 return {8 ...parentCtx,9 formData: parentData[sectionKey] ?? {},10 updateField: (k, v) =>11 parentCtx.updateField(sectionKey, {12 ...(parentData[sectionKey] ?? {}),13 [k]: v,14 }),15 propPrefix: prefix,16 };17}
这一步带来的收益巨大:
字段组件永远只处理“当前层的扁平对象”,完全不需要关心自己位于第几层嵌套。
七、字段组件模板化:统一 label / tooltip / 禁用 / 校验 / 绑定
一个成熟的配置表单系统,字段组件应该“长得很像”,因为一致性意味着可维护性。
以 Input 为例,一个推荐的字段组件模板是:
- 从 context 拿到
formData / updateField / disabled / propPrefix modelValue = computed(get/set)连接到formData[key]propPath = propPrefix ? propPrefix + "." + key : keyrules = required ? [...] : undefinedisDisabled = fieldDisabled || globalDisabled- label 支持
showTitle+ description tooltip
抽象版如下:
1<script setup lang="ts">2const ctx = inject(FORM_CTX_KEY);3
4const propPath = computed(() =>5 ctx.propPrefix ? `${ctx.propPrefix}.${config.key}` : config.key6);7
8const modelValue = computed({9 get: () => ctx.formData[config.key],10 set: (v) => ctx.updateField(config.key, v),11});12
13const rules = computed(() =>14 config.required15 ? [{ required: true, message: `请输入${config.title}` }]16 : undefined17);18</script>19
20<template>21 <el-form-item22 :label="config.showTitle === false ? '' : config.title"23 :label-width="config.showTitle === false ? '0' : undefined"24 :prop="propPath"25 :rules="rules"26 >27 <el-input28 v-model="modelValue"29 :disabled="config.disabled || ctx.disabled"30 />31 </el-form-item>32</template>
把字段做成“统一模板”的价值:
- 交互统一:required、禁用、placeholder、问号提示一致
- 扩展简单:加新字段时照着模板套,避免每个字段写一套风格
- Bug 收敛:一旦你修复某类交互问题,很容易同步到其它字段
八、Select 的“业务适配”:字典加载缓存 + 多选值格式
配置表单常见的“最难字段”就是 Select,因为它往往牵扯:
- 选项来源:静态 options / 后端字典 / 远程搜索
- 值结构:单选标量 / 多选数组 / 逗号字符串 / 对象数组(value-key)
- 性能与缓存:同一个 dictType 不应该重复请求
8.1 字典加载(带缓存)
本项目的 useDict 做了一个非常实用的工程化处理:Map 缓存。
抽象版:
1const cache = new Map<string, Option[]>();2
3async function fetchDict(dictType: string) {4 if (cache.has(dictType)) return cache.get(dictType)!;5 const data = await http.get(`/system/dict/data/type/${dictType}`);6 const options = data.map((x) => ({ label: x.dictLabel, value: x.dictValue }));7 cache.set(dictType, options);8 return options;9}
8.2 多选“数组 ↔ 逗号字符串”适配
很多后端接口希望多选以 "a,b,c" 传输/存储,而 UI 组件希望数组。
最佳实践就是:把适配收敛在字段组件内部,页面不要关心。
抽象版:
1const modelValue = computed({2 get: () =>3 isMultiple4 ? String(raw ?? "")5 .split(",")6 .filter(Boolean)7 : raw,8 set: (v) =>9 updateField(key, isMultiple ? (Array.isArray(v) ? v.join(",") : v) : v),10});
九、“后端下发 schema”的接入范式:页面越写越薄
配置表单真正的威力,在“后端返回 schema”时体现得最明显。
此时页面基本只需要完成三步:
- 拉取
schemaJsonString JSON.parse成FormItemConfig[]- 把表单数据
v-model绑定到“业务对象的某个字段”上(通过 computed get/set 做桥接)
典型页面结构是:
1const formConfig = ref<FormItemConfig[]>([]);2
3// 业务对象(脱敏示例):你可以理解为“当前规则/配置”的承载对象4const entity = ref<{ params?: Record<string, unknown> }>({});5
6const formData = computed({7 get: () => entity.value.params ?? {},8 set: (v) => (entity.value.params = v as Record<string, unknown>),9});10
11async function load(ruleId: string) {12 const schemaStr = await api.getSchema(ruleId);13 const parsed = JSON.parse(schemaStr);14 formConfig.value = Array.isArray(parsed) ? (parsed as FormItemConfig[]) : [];15}
你会发现:页面逻辑不再关心“渲染细节”,只关心“拿到配置、绑定数据、提交”。
十、扩展指南:新增一个字段类型(DateRange)的标准流程
建议把扩展流程固定成“四步法”,让团队成员能稳定交付:
- Step 1:扩展 Schema 协议:加
ComponentType.DATE_RANGE和对应 config 类型 - Step 2:实现字段组件:按模板接 context、propPath、rules、v-model
- Step 3:渲染器分发:
FormRenderer加一条else-if - Step 4:补文档与示例:README + 示例页加一段配置
发布版示例(抽象):
1// types.ts2export enum ComponentType {3 // ...4 DATE_RANGE = "dateRange",5}6
7export interface DateRangeConfig extends BaseConfig {8 componentType: ComponentType.DATE_RANGE;9 key: string;10 extraInfo?: {11 type?: "daterange" | "datetimerange";12 valueFormat?: string; // "YYYY-MM-DD"13 };14}
1<!-- FormRenderer.vue -->2<FormDateRange3 v-else-if="config.componentType === ComponentType.DATE_RANGE"4 :config="config"5/>
1<!-- FormDateRange.vue(字段组件按模板写) -->2<el-date-picker3 v-model="modelValue"4 :type="extraInfo.type ?? 'daterange'"5 :value-format="extraInfo.valueFormat ?? 'YYYY-MM-DD'"6/>
把扩展路径做成“标准化流水线”,你会得到一个非常重要的组织收益:
新字段能力可以被“规模化生产”。
十一、工程化细节:让这套系统真的能长期维护
配置型表单的“第一版 Demo”往往不难,真正拉开差距的,是那些会在 3 个月后频繁出现的工程化细节。下面这几条,是落地过程中最常见、也最值得提前设计好的点。
11.1 默认值初始化:别把它绑死在 mounted
很多 schema 是异步加载的(接口返回后才渲染)。如果默认值只在组件 mount 时跑一次,很容易出现:
- schema 还没到 → 默认值没补齐
- schema 到了 → 用户看到空值(甚至已经开始填写了,再补值会“闪一下”)
更稳妥的方式是:把“默认值补齐”视为 schema 的一部分运行时逻辑。
- watch
config(必要时也 watchmodelValue) - 仅在“字段缺省”时写入默认值(不要覆盖已有值)
- schema 多次更新时也能正确补齐新增字段
11.2 更新路径统一:避免在渲染器里“就地补对象”
在递归渲染时,你很容易写出类似这样的逻辑:
- “如果
sectionKey对应对象不存在,就formData[sectionKey] = {}先塞一个”
这本质是在修改一个来自 props 的引用对象的深层属性。Vue3 的响应式通常能兜住,但从工程可维护性角度,这类“深层就地修改”会带来两个隐患:
- 更新来源变得不清晰:数据究竟是用户输入改的,还是渲染器为了渲染改的?
- 调试成本上升:你很难在一个统一入口打断点、做埋点或做回放
更推荐的做法是:所有写操作都走 updateField 这条“单一更新通道”。
渲染器只负责“创建作用域”和“拼路径”,不要悄悄改数据。
11.3 校验体系留扩展口:required 只是开始
Element Plus 的校验体系非常成熟,required 只是最基础的一层。真实业务里,迟早会出现:
- 正则校验、范围校验(min/max)
- 自定义 validator(同步/异步)
- 跨字段校验(依赖别的字段值)
因此 schema 最好提前预留 rules 扩展口:
1rules?: Array<{2 trigger?: "blur" | "change";3 message?: string;4 validator?: unknown;5 // ...按你的项目需要扩展6}>;
字段组件只需要做一件事:把 required 生成的规则与 config.rules 合并,统一透传给 <el-form-item>。
11.4 schema 版本化:后端下发场景的“必修课”
一旦 schema 由后端下发,就会自然出现“老 schema 与新前端不兼容”的问题:字段类型变了、extraInfo 结构升级了、section 的布局参数改名了……
最省心的办法是:从第一天就给 schema 带上版本信息,并在前端做迁移器(migration):
- schema 根节点带
schemaVersion - 或每个字段带
typeVersion - 渲染前先跑一次
migrate(schema),把旧结构升级到新结构,再进入渲染流程
它的价值不在“今天”,而在你半年后需要灰度发布、兼容历史数据时,能把升级风险收敛在一个确定的入口里。
十二、为什么不是 form-create?什么时候该用 form-create?
本项目路线:自研“受控版 schema 表单”,底层仍是 Element Plus 表单体系。优点是:
- 数据结构、样式、交互完全可控(特别适配内部业务规范)
- 扩展路径简单(加组件 + 加分发 + 加类型)
- 与现有 Element Plus 生态无缝(校验、布局、表单方法)
form-create 更适合:
- 你需要更完整的表单引擎生态(更多内置控件、动态联动 DSL、更成熟的插件体系)
- 你愿意接受它的 schema 协议与渲染机制(并围绕它的最佳实践搭建)
一句话建议:
- 业务可控、字段有限、强一致性诉求 → 自研(像本文这套)
- 追求开箱即用、字段类型极多、强动态联动 → form-create / 成熟引擎
结语:一套“发布级”的配置表单系统应该长什么样?
你可以用这 6 条作为自检清单:
- 协议清晰:type 枚举 + TS 联合类型,写配置能自动提示、写错能编译失败
- 运行时稳定:容器统一 v-model、默认值、方法暴露、全局配置下发
- 渲染器可递归:Section 能嵌套,且能做到数据结构与校验路径自动对齐
- 字段组件模板化:统一 label/tooltip/禁用/校验/绑定/样式
- 业务增强可沉淀:字典加载缓存、多选值适配等不外溢到页面
- 扩展成本可预测:新增控件是一条固定流水线,而不是“到处打洞”