ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

告别硬编码表单:Vue3+Element Plus配置化表单引擎实践

告别硬编码表单:Vue3+Element Plus配置化表单引擎实践 前一阵子接手一个后台管理项目进去第一周就快被里面密密麻麻的form组件搞崩溃了。倒不是说el-form本身有什么问题而是业务方几乎每周都会提表单改动新增一个字段、调整联动逻辑、改变校验规则。每次都得翻模板、找变量、改校验稍不注意就把另一些隐藏逻辑弄坏。熬过两轮需求后我决定不再被表单项拖着走而是把form组件重新包了一层把表单项、校验规则、联动逻辑全部配置化。结果自然是爽了不少——业务侧新增一个普通字段基本只改配置渲染层完全不用动。这篇文章就把这套form组件的高级用法完完整整拆一遍适合被复杂表单折磨过、想给表单做一次结构性升级的前端开发。1. 硬编码表单的痛为什么一个“简单改动”总是要动全身1.1 一个典型的“需求变更”现场我接手项目里有个“创建配送单”页面最初只有收货人、手机号、收货地址三个字段。代码写得很常规一个el-form套三个el-form-item每个都是固定的input。看着挺清爽后来业务方提了一条需求增加“配送方式”可选“送货上门”或“到店自提”。选择“送货上门”时需要展示地址栏选择“到店自提”时需要展示“自提点”下拉框。这时改动就不止是加一个字段了——模板里要加两个el-form-item还要写v-show控制显隐提交时得判断选中的配送方式校验规则也要跟上。这还没完。过了两周业务方又要求加“备注、发票信息、是否加急”等六七个字段其中有一些字段之间存在复杂联动比如“是否开电子发票”选“是”的时候要出现“发票抬头”和“纳税人识别号”。到这一步模板代码已经膨胀到两三百行表单校验散落在rules对象里数据模型和UI结构强耦合在一起。一个字段的增删改已经不可能只动“一个地方”了。1.2 硬编码表单的典型特征我复盘了一下几乎所有被“写死”的form组件都会存在这类问题核心可以总结成三点UI模板与业务规则混在一起表单项的控件类型、占位符、可选项、是否展示全都直接写在template里。一个表单项的完整信息被拆成了模板、script、样式三处看起来非常碎片化。校验规则与字段定义分离字段值在data里定义校验规则在rules里定义联动又在watch或方法里实现。改一个字段可能要同时动这三个地方少改一处就是隐患。缺少统一的扩展入口想新增一种自定义控件比如商品选择器必须回到模板里写新的el-form-item很难在项目多个表单中复用。这不仅增加了开发成本还让后续接手的人很痛苦。新同事给表单加字段根本不敢随便动因为不知道哪里藏着联动逻辑。1.3 高级用法的本质把表单当作“数据渲染器”其实form组件本身并不复杂它就是一个“把表单状态和控件绑定再按校验规则给出反馈”的容器。真正让表单变得复杂的是业务规则的不断叠加。所以要解决这个困境不应该继续在模板里堆节点而是把表单的表单模型抽出来当作一份可解释的数据schema来处理。这套“高级用法”的核心思想就是把表单项的结构定义、校验规则、显示逻辑、控件类型全部从组件代码中剥离放到一份配置对象里。渲染层只需要根据配置遍历输出控件并处理好值和校验的绑定。这样表单就变成了一个“函数”输入是配置和初始数据输出是一套可交互、可校验的用户界面。2. 配置化表单模型先定义好字段的“元数据结构”2.1 从el-form到schema model的映射要让配置可运行第一步是先定义一份足够表达“表单项”所有信息的结构。我将它称为FieldSchema。我实际在项目里用的结构类似下面这样{ name: receiverName, label: 收货人, component: input, placeholder: 请输入收货人姓名, props: { maxlength: 20, clearable: true }, rules: [ { required: true, message: 请填写收货人, trigger: blur } ], visibleWhen: (formModel) true, defaultValue: }name对应表单数据模型中的字段名也是el-form-item的proplabel表单项的显示名称component控件类型如input、select、date、radio稍后会映射到实际组件props需要透传给控件的原始属性rules该字段的校验规则沿用el-form原本的规则语法visibleWhen一个接收formModel返回布尔值的函数控制该项是否显示;defaultValue初始化时给表单模型补默认值。当然实际项目中还会扩展很多字段比如span控制栅格宽度、disabledWhen控制禁用状态、tooltip提示文案等。但上面这些字段已经构成了“可独立渲染一个表单项”的最小完整结构。2.2 为什么配置用数组而不是普通对象我第一次设计时踩过这个坑。一开始我是按字段名做key、字段详情做value像是fieldsMap: { receiverName: { ... }, receiverPhone: { ... }, }结果遇到两个问题一是需要给字段排序时只能给每个配置加一个order属性还得额外做排序逻辑二是有动态添加字段的需求时对象形式更新起来并不直观。后来我改成了数组fieldList: [ { name: receiverName, ... }, { name: receiverPhone, ... }, ]数组天然有序v-for遍历时能直接按顺序渲染。需要添加字段时只需fieldList.splice(1, 0, newFieldSchema)层次清晰。如果你的表单中需要子分组、卡片、栅格布局数组里还可以再加一层type: grid的类型配置就变得更加灵活。2.3 规则配置从哪来配置里的rules看起来简单但真正落地时最好做两层拆分一层是基础的规则集合另一层是字段引用的规则名。比如项目中多少个表单都需要“手机号校验”如果每次都把pattern写进配置配置会越来越庞大。我的做法是维护一个全局的规则注册表const ruleRegistry { required: (label) ({ required: true, message: 请填写${label}, trigger: blur }), phone: () ({ pattern: /^1[3-9]\d{9}$/, message: 手机号格式不正确, trigger: blur }), email: () ({ type: email, message: 邮箱格式不正确, trigger: blur }), integer: () ({ pattern: /^\d$/, message: 必须为正整数, trigger: change }) }配置里直接写{ name: receiverPhone, label: 手机号, component: input, rules: [required, phone] }然后在生成最终rules时把ruleRegistry里的函数解析成真正的rule对象。这样多个表单共享同一套基础规则维护成本低而且后端的校验提示文案也能保持统一。3. 极简渲染器实现用动态组件把所有字段渲染出来3.1 维护一个“组件注册表”配置里的component是字符串要想渲染真实的控件必须建立字符串到组件的映射。我用的是一个简单的Mapimport { ElInput, ElSelect, ElDatePicker, ElRadioGroup } from element-plus const componentMap { input: ElInput, select: ElSelect, date: ElDatePicker, radio: ElRadioGroup } export function getComponent(name) { if (!componentMap[name]) { throw new Error(Unknown form component: ${name}) } return componentMap[name] }将来如果想扩展一个“用户选择器”只需要用app.component注册一个全局组件然后在componentMap里加一行userSelect: UserSelect所有配置为userSelect的表单项就会自动渲染完全不用再改模板。需要注意像ElSelect这种组件通常需要绑定options选项。我处理的方式是把options也放进配置里的props。例如{ name: deliveryType, label: 配送方式, component: select, props: { options: [ { label: 送货上门, value: delivery }, { label: 到店自提, value: pickup } ] } }有些UI框架原生select不支持options这种直传方式那就需要在前端加一个包装器或者用插槽处理。这里为了演示简洁我假设ElSelect已经通过外层扩展支持了options属性。3.2 渲染模板只需要十几行有了fieldList和组件注册表模板部分可以收敛得很短el-form refformRef :modelformModel :rulescomputedRules label-width120px template v-forfield in fieldList :keyfield.name el-form-item v-showshowField(field) :labelfield.label :propfield.name component :isgetComponent(field.component) v-modelformModel[field.name] v-bindfield.props / /el-form-item /template /el-formformModel是一个普通reactive对象初始值由fieldList里的defaultValue统一生成computedRules是计算属性遍历fieldList动态为每个name生成规则对象showField(field)内部调用field.visibleWhen?.(formModel) ?? true实时判断是否显示。这套渲染器的核心优势是模板完全与业务字段解耦。以后不管新增多少字段这里的模板代码一行都不用改要改的只是fieldList数组。3.3 校验规则映射隐藏字段不要参与校验这里有一个比较关键的点如果某个字段由于联动逻辑被隐藏原本的rules依然会挂在el-form上吗会的。隐藏的el-form-item如果仍然存在校验模块照样会去校验它导致用户看不见字段却弹了“请填写XX”的错误。我的做法是在computedRules里直接过滤掉不可见字段const computedRules computed(() { const rules {} for (const field of fieldList.value) { if (field.visibleWhen ? field.visibleWhen(formModel) : true) { rules[field.name] parseRules(field.rules, field) } } return rules })每次formModel变化时computedRules都可能重新计算不可见字段会从rules中移除。但要注意如果之前这个字段已经产生了校验错误虽然移除rules后错误会自动消失但马上当它重新可见时校验错误状态可能还在。保险起见我在visibleWhen变化的回调里对相关字段调用formRef.value.clearValidate(field.name)确保残留的错误状态被清除。3.4 表单值的初始化与重置配置化之后初始化表单数据不应该再手动一个个写for (const field of fieldList.value) { formModel[field.name] field.defaultValue ?? }重置表单时可以重新执行这段逻辑。注意el-form的resetFields只会重置到初始值而初始值其实就是我们在formModel中第一次赋的值所以如果你在动态配置里修改过defaultValue重置时务必要用上面的循环重新覆盖一次再调用formRef.value.clearValidate()。4. 联动、远程搜索与跨字段校验配置化之后如何实现高级交互4.1 显隐联动从v-show迁移到visibleWhen表单最常见的联动是“A选了什么后B显示/隐藏”。传统写法是在模板里写v-if或v-show并且要在watch里监听数据变化。配置化的写法则是在FieldSchema中声明一个函数{ name: pickupPoint, label: 自提点, component: select, props: { options: selfPickOptions }, visibleWhen: (model) model.deliveryType pickup }这个函数是响应式的因为渲染层在computed或watchEffect里读取了formModel。当deliveryType变化时visibleWhen会自动重新求值。注意如果联动字段变化时需要清空被隐藏字段的值我建议把这种副作用写在一个统一的handleFormChange方法里而不是零散撒在联动函数里。不然联动逻辑一多就会变成一团乱麻。4.2 远程搜索下拉框的配置化处理很多表单需要远程搜索。比如一个“选择商品”的下拉框输入关键字后从接口搜索候选数据。传统写法的确要监听remote-method事件。在配置化方案里我可以把remoteMethod也作为props注入{ name: productId, label: 选择商品, component: select, props: { remote: true, filterable: true, remoteMethod: async (query) { loading.value true try { const res await fetchProducts(query) productOptions.value res.data.list } finally { loading.value false } }, options: productOptions } }这里remoteMethod是定义在组件中的函数但被塞到了field.props里。这种方式要注意响应性productOptions必须是一个ref或reactive数组才能在下拉列表更新时被渲染层感知到。当然函数进配置有好处也有坏处如果配置需要被序列化传给后端就不能这么写。这个矛盾我在下一章细说。4.3 跨字段校验的配置姿势有些校验不是孤立的比如“确认密码”要和“密码”相同或者“开始日期”不能晚于“结束日期”。el-form的rules支持validator函数它接收的rule中有一个getFieldValue方法不同版本API略有差异有的直接这个object里没有需要用全局的formModel。我的实现是让配置中的validator从闭包里访问当前的formModel{ name: confirmPassword, label: 确认密码, component: input, rules: [ { required: true, message: 请再次输入密码, trigger: blur }, { validator: (rule, value, callback) { if (value ! formModel.password) { callback(new Error(两次输入的密码不一致)) } else { callback() } }, trigger: blur } ] }因为formModel是在组件作用域被捕获的当用户在“密码”字段修改后即使不触发“确认密码”的校验再次提交时也会用最新值重新校验。但如果要支持“改密码后立刻清除确认密码错误”这种体验还需要在密码字段的change事件里调用clearValidate(confirmPassword)。这部分建议封装成公共方法例如bindFieldEvents。4.4 历史数据回显与异步初始值编辑页面经常是数据从接口取回来后再给表单赋值。配置化表单同样支持但要注意一点在数据还没回来时formModel是空的有些联动函数的计算结果可能和最终不一致导致字段显隐闪跳。我的经验是给渲染器加一个loading插槽el-form v-loadingisLoading ... /el-form数据回来时先把异步数据合并进formModel再等待一个tick让联动函数重新计算。如果有字段依赖其他字段的初始值来设置显隐可以先把所有字段的初始值都设置好再调用一次computedRules刷新校验。4.5 动态添加一组字段实际业务中还有一种场景动态表单列表比如“添加多张发票明细”每一行有名称、金额、数量。这种虽然已经超出了el-form原生常规用法但配置化渲染器很好扩展。思路是在配置中定义一个特殊字段类型是array或sub-form它的props中包含子字段配置{ name: invoiceItems, label: 发票明细, component: subForm, props: { fields: [ { name: name, label: 名称, component: input }, { name: amount, label: 金额, component: inputNumber }, { name: quantity, label: 数量, component: inputNumber } ] } }渲染时遇到subForm类型就递归渲染内部子表单同时对外层只暴露一个数组formModel.invoiceItems。这个做法已经有点像低代码表单引擎了。如果你不想全部重新造轮子可以先用el-form现有方法实现一个极简的子表单组件我这里就不展开全部源码了。5. 配置化落地的四个深坑不绕过去就等着炸5.1 函数不能塞进JSON后端下发配置怎么办很多团队做中后台希望后端能下发动态表单配置。这时候问题就来了visibleWhen、validator、remoteMethod这些在配置里都是函数函数没法被JSON.stringify。一旦配置要经过接口传输就必须把函数拆出去。我对外的方案是配置中只保留“规则代码”或“标识符”函数统一放在前端注册表里。例如{ visibleWhen: deliveryTypeEqPickup, validator: sameAsPassword, remoteMethod: searchProducts }然后在渲染器内部解析这些字符串const conditionMap { deliveryTypeEqPickup: (model) model.deliveryType pickup }后端下发的是纯JSON前端解析时再把字符串映射为函数。这样既保留了配置化的灵活性又不会有序列化问题。其中validator要稍微处理一下上下文的注入因为校验时可能还需要访问formModel所以函数签名设计成(model, rule, value, callback)比较好。5.2 联动字段赋值导致的死循环配置化联动很容易写出“A变化时给B赋值B变化时给A赋值”的循环问题。最常见的情况是在某些联动逻辑里会主动去修改formModel的值而修改后的值又触发了另一个字段的visibleWhen重算重算时又去改其它值最终栈溢出。我踩过比较典型的一个坑某表单里有一个“省市区”级联选择省份后要根据省份清空城市和区县。我当时直接在watch里监听省份然后清空城市而城市字段的visibleWhen又依赖于省份不为空清空操作触发后visibleWhen重新执行……虽然最终没有死循环但每次操作都会多出很多无谓的计算。现在我的原则是联动逻辑永远只做“计算展示条件”和“准备可选项”不做“写回其他字段值”的复杂操作。如果真的要清空值建议放在一个统一处理入口比如onFieldChange(fieldName, value)方法里并且对相同字段值的重复变化做一个防抖判断确保不会反复触发。5.3 隐藏字段校验错误残留前面提到过不可见字段要从rules里摘掉。但真正处理起来还有个细节当某个字段从可见变为不可见时它在el-form-item校验实例中可能还保留着错误信息。如果你只是从rules中删除错误文字不一定会立刻消除等你让它重新可见时错误状态又回来了。我的处理有两步。第一步在visibleWhen结果变化的侦听器里对比前后差异对消失的字段调用formRef.value.clearValidate(fieldName)第二步通过修改el-form-item上的prop来控制校验实例。如果你使用的是Element Plus还可以在el-form-item上动态绑定:propvisible ? field.name : undefined让隐藏字段直接失去校验绑定。这样更彻底。5.4 别把配置对象做成全局深拷贝的“黑盒”配置化表单的价值在于可追踪、可调试但如果你把所有字段配置放在一个深不可测的函数里每次修改后都通过deepClone传递那后期会很难定位问题。我建议在开发环境给fieldList增加一个轻量的校验函数检查每个字段是否具备name、component、label并检测重复字段名和未注册的组件。一旦出错直接在控制台抛出可读性强的错误信息。我自己写了一个validateSchema函数放在初始化入口function validateSchema(fieldList) { const names new Set() fieldList.forEach((field) { if (!field.name || !field.label || !field.component) { throw new Error(Field schema error: ${JSON.stringify(field)}) } if (names.has(field.name)) { throw new Error(Duplicate field name: ${field.name}) } names.add(field.name) if (!componentMap[field.component]) { throw new Error(Unknown component: ${field.component}) } }) }别嫌这个函数简单它能帮你拦截大量低级错误。6. 配置化表单还能延伸出什么从一次性改造到表单引擎6.1 低代码表单配置器的雏形当你把表单完全变成配置驱动后配合一个简单的可视化编辑器就可以拖拽式生成表单页面。曾经中后台项目里那些“活动报名”“调查问卷”页面我们团队就是通过配置化方式做出来的。运营同学在管理端自己拖字段、配置校验规则生成一份JSON配置提交后前端渲染器直接渲染。这种模式并不需要特别复杂的引擎基础就是本文这套form组件的高级用法。6.2 多端复用的价值如果有一天需要做小程序端或H5端原本Vue组件可能需要换一套实现。但因为业务层逻辑已经沉淀在fieldList配置里前端只需要在各端实现一套对应的渲染器就能复同一份表单定义。尤其是校验规则、联动条件这些纯数据部分完全不用改。我在项目里已经验证过Web端用Element Plus移动端换成了Vant的Field和Form两边的fieldList配置完全一致只有渲染器不同。这种收益不是单个表单页面能衡量的而是整个项目表单体系的架构红利。6.3 表单变更审计与权限控制字段列表由配置驱动后你可以给配置加版本号记录每次修改的操作人和时间。这比直接改代码更容易追溯。另外在不同角色下可以动态拼接fieldList——管理员看到所有字段普通用户只能看到部分字段。这些逻辑在配置化之后都变得非常轻量。当然别为了用到每个功能而过度设计。如果当前项目只有一两个简单表单直接用原生写法更快。最后分享一点个人实操体会配置化这套东西落地到现在我最明显的感觉是常规的表单新增需求基本不需要再看模板代码了把fieldList往浏览器控制台一打就能知道这个表单包含哪些字段、结构是什么。而且因为渲染层相同新人在熟悉这个模式后处理表单需求的效率提升很快。但也得说句公道话配置化不等于银弹它适合表单项数量大、变动频繁、联动复杂的模块如果只是一个简单的登录表单老老实实写el-form两行就够了硬上这套反而增加理解成本。我的建议是你可以先在一个业务模块做试点把最核心的字段配置、渲染器和校验映射跑通再逐步铺开。开发时记得在本地给schema加校验能挡掉大多数低级错误。如果有条件顺手写一个可视化的调试面板实时展示当前表单的配置和数据流排查问题会轻松很多。
返回列表