
开头先说个真实感受我做前端基建七八年服务过的项目里OA、CRM、ERP都有。前阵子团队内部又吵起来了——有人提议“表单直接用 Element Plus 的 el-form 不就行了还封装啥”结果被一个老后端反问“那你帮我改一下这个表单让它在不同审批节点只显示对应字段并且下拉选项从昨晚更新的数据字典里读取别发版明天上线。” 全场安静。不是 Element UI 或 Ant Design 不好而是企业管理系统里的表单从来不是“填几个字段再点提交”那么简单。标题说“不能直接用”重点其实在“直接”两个字不经过组件化设计直接把 UI 库的表单组件铺到业务页面上短期看着快后面就是深坑。1. 企业级表单和普通营销页表单的本质差异1.1 表单项不是“写死”的是后端或配置中心给的普通官网的注册表单字段基本固定用户名、密码、邮箱UI 上写死完全没问题。但 OA、CRM、ERP 里的表单字段数量、类型、布局随时在变。今天人事部说入职登记要加一个“紧急联系人”明天销售部门说客户信息要多一个“所属行业分类”如果每个字段都要改前端代码、走发布流程那这个系统基本没法迭代。所以企业级表单的第一原则是表单项的元数据要能动态配置。常见做法是后端返回一段表单配置或者由低代码平台生成 JSON Schema前端拿着配置去渲染。这套机制业界叫“动态表单配置”也叫表单引擎。此时你再想用 Element UI 的原生 el-form它只解决了“怎么渲染”这最后一公里并没有解决“字段从哪来、校验规则怎么配、联动怎么声明”这些核心问题。不封装你只能在每个页面里手动解析 JSON手动绑 v-model手动写一堆 v-if最终代码会膨胀到没法维护。1.2 表单不是一个页面而是一条业务流程的入口ERP 里的采购申请单同一个表单在“草稿”状态时所有字段可编辑提交后在“部门经理审批”时预算金额只读到了“财务复核”时付款方式字段才可见。这种字段级的状态变化是典型的流程驱动表单。如果直接用 el-form你要在模板里写大量条件el-form-item v-ifformState draft || (formState finance userRole finance) label付款方式 el-select v-modelform.payType.../el-select /el-form-item这样不是不能做而是当状态有十几种、字段有几十个时v-if/v-show 会像杂草一样蔓延。正确做法是把“字段的显隐、是否必填、是否只读”都变成配置项由流程引擎的状态来驱动而不是在模板里堆条件。组件化设计就是把这些规则收拢到表单引擎内部让页面只是“状态 配置”的消费者。1.3 字段级权限、数据字典、国际化的三座大山老牌系统还有一个通病同一个字段在不同角色眼里有不同的表现。比如 CRM 里的“客户年营业额”销售总监能看到精确值一线销售只能看到区间。这不只是显隐问题还涉及格式化、脱敏。用原生组件你得在每个使用的地方做权限判断漏一个就出事故。另外下拉框的选项几乎都来自数据字典而不是写死在页面里。字典可能是“客户类型”“订单状态”“审批结果”接口返回的 code 和 label 一直在变。国际化同理label 可能需要切换语言。这些都属于“表单的上下文”应该由统一的表单引擎注入而不是每个页面各自处理。1.4 多端适配同一套配置要跑在 PC 和手机端现在几乎所有 OA 都有移动端审批。PC 端屏幕宽表单可以多列栅格布局手机端屏幕窄只能单列纵向排列而且最底下要放大按钮方便点按。如果前端直接用 Element UI 的栅格布局写死比如el-row :gutter16 el-col :span12.../el-col el-col :span12.../el-col /el-row这在 PC 上没问题到了移动端就挤成一团。组件化设计要允许同一份 schema 声明多种布局模式比如 PC 用 grid 两列移动端用单列然后由渲染器根据端类型选择布局。这个能力如果没有后面做 App、做企微集成会非常痛苦。2. 破局思路用 Schema 驱动的方式做表单组件化聊到这你大概明白了不是 Element UI / Ant Design 不能用而是要基于它们再封装一层“表单引擎”层。这层引擎的核心是 Schema也就是用一份配置来描述“这个表单长什么样、有什么规则、怎么交互”。2.1 第一步先定义字段元数据模型我在团队里落地时第一件事不是写组件而是定义 TypeScript 接口。这里分享一个简化版interface FieldConfig { /** 字段唯一标识对应表单 model 里的 key */ prop: string; /** 表单 label */ label: string; /** 字段类型映射到基础组件 */ component: input | select | date | cascader | custom-employee | ...; /** 传给基础组件的 props */ props?: Recordstring, any; /** 初始值 */ initialValue?: any; /** 校验规则支持内置和自定义 */ rules?: FormRule[]; /** 是否必填移动端会特殊标记 */ required?: boolean; /** 是否可见支持表达式或函数 */ visible?: boolean | ((ctx: FormContext) boolean); /** 是否禁用同理 */ disabled?: boolean | ((ctx: FormContext) boolean); /** 占位多列布局时使用 */ span?: number; /** 移动端是否独占一行 */ mobileFull?: boolean; /** 数据字典编码 */ dict?: string; /** 权限标识 */ permission?: string; /** 自定义组件名 */ componentPath?: string; }有了这个模型业务方只需要返回一个 FieldConfig 数组前端就能渲染出完整表单。别小看这个“模型”它决定了后续所有扩展的边界。比如想要支持自定义组件就在 component 字段里预留扩展点想要支持异步校验就在 rules 里允许传 async 函数。模型设计得好表单引擎就成功了一半。2.2 第二步建立组件注册表而不是 if-else 地狱很多新手拿到 schema 之后容易写出一堆 ifcomponent v-iffield.component input isel-input / component v-else-iffield.component select isel-select /这也不是不行但每加一种类型都得改一次这个组件。更好的方式是维护一个映射表const componentMap: Recordstring, Component { input: ElInput, select: ElSelect, date: ElDatePicker, cascader: ElCascader, // 自定义业务组件 employee-selector: EmployeeSelector, ... }; // 动态渲染器核心代码 component v-forfield in fields :keyfield.prop :iscomponentMap[field.component] v-bindfield.props v-modelform[field.prop] /然后提供一个 registerComponent 方法让业务团队注册自己的组件。比如“员工选择器”是一个弹窗选人组件内部可能调接口、带回部门信息但只要注册一次任何表单都能通过 component: employee-selector 使用。这就是“不要直接用但也不要禁止用”而是把它们变成引擎的基础积木。2.3 第三步把校验规则变成配置Element UI / Ant Design 都内置了 async-validator 或类似校验器但它们给的是基础能力required、min、max、pattern、validator。企业级表单真正痛苦的是“校验规则和字段配置一起变”。比如 CRM 的“手机号”字段在 A 客户池里要求 11 位大陆手机号在 B 客户池里可能是港澳台手机号。这需要用配置表达。我的做法是支持两种规则字符串规则{ required: true, message: 请输入手机号, pattern: ^1[3-9]\\d{9}$ }自定义规则函数{ validator: async (value) { const res await checkPhone(value); return res.pass; }, message: 该手机号已注册 }另外还需要支持“联动校验”即当其他字段变化时校验规则要重新评估。例如选择“个人”类型时身份证号必填选择“企业”类型时统一社会信用代码必填。这在原生组件里需要监听联动字段然后动态修改 rules很容易出错。如果通过 schema 定义 rules 的触发条件比如when: { type: personal }表单引擎内部就能统一处理。2.4 第四步联动和依赖处理要声明式联动场景最典型下拉选“其他”时显示“备注”输入框。用原生写法你需要在 el-select 的 change 事件里改一个 data 里的 booleanel-select v-modelform.type changevisible.note form.type other一个两个这样写没问题二十个联动呢代码根本没法看。组件化设计里我倾向于把联动也声明在字段配置里{ prop: note, component: input, visible: ctx ctx.model.type other, disabled: ctx ctx.model.status closed }表单引擎会自动收集所有依赖ctx.model.xxx的字段当这些字段值变化时重新计算 visible/disabled/required。这不是什么魔法本质上是在 watch 或者 computed 里统一处理。好处是业务逻辑一目了然测试也容易写。如果你喜欢函数式可以引入类似vueform/vueform的 rule 表达式不引也行自己用 computed 缓存每字段的配置状态就够了。2.5 第五步统一的状态管理与提交处理原生 el-form 只负责展示数据放在页面 data 里提交时手动组装。表单引擎要把“初始值、当前值、脏状态、校验状态、提交中状态”统一管理起来。这样业务方拿到的是一个独立的对象const { form, formConfig, validate, reset, clear, submit, isSubmitting } useDynamicForm({ schema: formSchema, onSubmit: async (values) { ... } });清空表单也是个隐藏坑。原生做法是this.$refs.form.resetFields()但它只会重置到 initialValue不会把初始值清空。ERP 里经常需要“新建一笔但保留部分默认值”这要求引擎能区分 initialValue 和 emptyValue。我在封装时加了 clear 方法默认把表单清成空字符串但保留比如“单据类型采购”这类配置在 schema 里的 initialValue。这些细节原生组件不会帮你做。3. 从 0 到 1 落地方案封装自己的动态表单组件前面讲了设计思路现在说落地。我用 Vue 3 Element Plus 举例子Ant Design React 的思路完全一致只是 API 名称换成 Form、formItem 等。3.1 基础封装接受 schema 渲染出 el-form先写一个最简组件 DynamicForm.vuetemplate el-form refformRef :modelform :rulesflattenRules :label-widthlabelWidth classdynamic-form el-row :gutter16 el-col v-forfield in visibleFields :keyfield.prop :spanisMobile ? 24 : field.span || 12 el-form-item :labelfield.label :propfield.prop :requiredfield.required template v-iffield.component select el-select v-modelform[field.prop] :optionsdictMap[field.dict] || [] :disabledisDisabled(field) v-bindfield.props / /template template v-else-iffield.component date el-date-picker ... / /template template v-else el-input v-modelform[field.prop] ... / /template /el-form-item /el-col /el-row /el-form /template这是一个最简版本。写出来你会发现问题模板里又出现了 template v-if组件一多依然爆。所以还是建议用前面说的 componentMap改造成动态组件渲染。实际代码大概是这样el-form-item v-forfield in visibleFields :keyfield.prop :labelfield.label :propfield.prop DynamicField :fieldfield :formform :dictsdicts / /el-form-itemDynamicField 内部根据 component 从注册表取组件然后渲染。3.2 vue3 动态添加/删除表单行唯一 key 是必修课ERP 的订单明细、OA 的报销费用清单这类表格型子表单是重灾区。用它来展示组件化设计中的典型坑。很多同事一开始这样写el-form-item v-for(item, index) in form.items :keyindex :propitems.${index}.amount el-input v-modelform.items[index].amount / el-button clickremoveRow(index)删除/el-button /el-form-item这个写法有一个致命问题当你删除第一行时第二行的 index 变成 0第三行变成 1DOM 复用时Element Plus 的校验组件会保留上一行的输入内容和校验错误提示。实际表现是我删了第二行第一行的数据变成了原来第二行的数据或者校验错误串行。原因就是 :key 用了 indexVue 无法准确识别行的身份。正确做法是给每一行初始化时生成唯一 idconst addRow () { form.items.push({ id: Date.now() Math.random(), productName: , amount: 0 }); };然后 :key 用item.id。删除时也通过 id 定位。另外动态表单项的校验 prop 必须包含索引并且要注意如果配置的 rules 是写在 schema 上的那么每一行校验规则是相同的但 prop 不同所以渲染时用 function 动态生成 rules 也可以。我的习惯是动态表格统一放在 form engine 的 items 类型里由引擎内部处理 Row 的增删外部只传一个“行模型”。3.3 清空表单的正确姿势区分 reset 和 clear前面提过 resetFields 只回到 initialValue。现实中“清空”往往不是回到初始值而是把值全部设为 null 或空字符。比如新建报销单时表单里的“摘要”“金额”应该为空但“单据类型”默认“差旅报销”要保留。原生 el-form 没有 clear 概念。组件化封装里我会给 useDynamicForm 增加 clear 方法const clear () { const clearObj {}; fields.forEach(f { clearObj[f.prop] f.emptyValue ?? (f.initialValue ?? ); }); formRef.value?.clearValidate(); Object.assign(form, clearObj); };还要注意清空后之前触发的错误提示要一起 reset否则表单“看起来”还有错误。这个点虽然小但在 ERP 批量录入场景下用户天天点“新增”体验差距立马体现。3.4 Axios 升级后 Content-Type 变成 application/json后端收不到参数这是一次真实的线上事故。某系统从 axios 0.x 升到 1.x结果所有 POST 提交表单的页面都报“参数缺失”。后来抓包发现升级前浏览器发送的报文里Content-Type 是application/x-www-form-urlencoded请求体是keyvaluekey2value2升级后 axios 默认把普通对象序列化成了 JSON报文头变成了application/json请求体是{key:value}。后端的 Spring Boot 方法用的是RequestParam接收所以一个参数都拿不到。解决方式有两个如果是表单登录、查询类接口显式告诉 axios 不要转 JSONaxios.post(/api/login, qs.stringify(params), { headers: { Content-Type: application/x-www-form-urlencoded; charsetUTF-8 } });如果是新项目干脆后端就统一接收 JSON直接使用RequestBody。但企业内部老系统很多是 form 模式不能改后端那就只能在统一请求封装里做 content-type 适配。这个坑表面上是 axios 配置问题但放在表单引擎里就是“提交适配器”的问题。这时候组件化设计的好处就体现了你不需要在每个表单页面处理 Content-Type引擎的 submit 方法里统一做序列化和 header 处理。以后再升级也只要改一个地方。3.5 移动端表单必填项与滚动定位移动端审批表单用户最烦的不是填内容而是“明明标了红星还是漏填”。问题在于屏幕窄每行显示不下 label 和星号经常被折叠或者显示不完整。所以工程上必须做两件事顶部增加“必填项”提示条进入页面时一眼看到。在提交校验失败时自动滚动到第一个出错的字段并在该字段下方气泡提示具体原因。Element Plus 的 scrollToField 在 el-form 里也有但只支持 el-form-item 的 prop 定位。在移动端还要考虑键盘弹起导致的滚动偏移以及外层是否设置了 overflow 滚动容器。我封装时写了一个scrollToError()方法遍历校验错误列表找到第一个错误字段的 DOM 节点用el.scrollIntoView({ behavior: smooth, block: center })滚动同时稍微偏移防止被 TabBar 挡住。这个细节原生组件不会管。3.6 表格导出和 POI 多表单导出的分工ERP 里经常遇到“导出当前列表”的需求比如导出发货单。前端可以做 CSV 导出但复杂报表还是后端做更稳。我在表单引擎里不直接做导出但会在“查询表单组件”里预留导出按钮位点击后把当前表单查询条件提交给后端后端用 Apache POI 生成多 sheet 的 Excel。为什么多 sheet因为一个业务单据包含主表、明细表、审核记录分开 sheet 放更清晰。后端生成 POI 多 sheet 的核心代码很常规Workbook workbook new XSSFWorkbook(); Sheet sheet1 workbook.createSheet(主表); Sheet sheet2 workbook.createSheet(明细); Sheet sheet3 workbook.createSheet(审批记录); // 各自写入表头和数据 // 设置列宽、样式 workbook.write(outputStream);前端只需要在表单组件里做好“导出”按钮的 loading 状态和错误提示别让用户连点多次。这里最容易坑的是如果前端自己也用 SheetJS 生成 xlsx遇到几十万行的库存明细浏览器会卡死。所以我的原则是小报表前端做大数据量必须走后端。3.7 表单安全验证码、防暴力提交、防重复提交表单不是 UI 那么简单。OA 登录页、CRM 批量导入都可能成为攻击点。参考渗透测试里“基于表单的暴力破解”这类常见练习前端表单至少要做到登录、找回密码等敏感表单必须带图形验证码或行为验证。提交按钮点击后进入 loading/disabled 状态防止多次点击造成脏数据。更稳妥的做法是后端对提交内容做唯一性校验比如加一段防重 token成功消费一次。前端校验只防君子不防小人核心必填校验、合法性校验、权限校验必须在后端重复做否则用 Postman 绕开页面直接发请求就完蛋。我会在表单引擎 submit 里加一个submitting状态并支持配置preventDoubleSubmit: true。这个默认打开。虽然响应速度看上去慢了那么一点点但在生产环境中至少能少掉一半“重复工单”。这一点值得所有团队留意。4. 什么时候不该自己造“表单引擎”前面为了说明组件化的好处讲了大量案例但我也不是无脑建议所有系统都上表单引擎。凡是工具都有边界。如果你的系统满足以下条件直接用 ElForm / AntForm 就够了表单数量只有个位数且生命周期内不会频繁增加字段。不需要动态配置也不做低代码平台没有运营人员要配置表单。团队只有一两个前端工作量大没时间维护一套抽象层。组件化设计不是为了秀技术而是为了解决问题。当你的业务确实只需要“写死”的表单时用原生组件是最快最稳的方案。贸然引入 schema 引擎配置比代码还难读反而降低效率。但如果你符合下面任意两条我建议你考虑至少做一层轻量封装三个以上系统共用同一套表单交互OA、CRM、ERP。业务提出“这个字段本周要上线不发版”的频率超过一月一次。同一个表单位置在 PC/移动/企微需要做三端适配。表单字段有权限控制不同角色看到的内容不一样。不要一上来就设计一个几百行配置的大引擎。先从最简单的 schema 渲染开始把共用的“动态字段、校验、联动、提交适配”做出来等运行两三个月再根据痛点叠代。这个“演进式设计”比一步到位稳得多。5. 最后说点掏心窝的我在实际项目中见过太多反例刚开始图快直接在页面里用 el-form三个月后表单数量到 40 个复制粘贴的代码到处都是。等想重构的时候业务已经跑在线上每动一个公共逻辑都要全量回归根本不敢下手。后来我们在新系统里从第一天就用组件化设计把表单引擎拆成独立 npm 包所有业务系统共享同一套升级。每次升级 Element Plus 版本只要引擎层测试通过几十个表单页面自动受益这种收益是原生写法给不了的。再说一个小技巧如果你的团队还没准备好做复杂引擎至少可以在项目里约定所有表单都通过一个AppForm.vue和useDynamicForm来使用禁止直接在页面里裸写el-form-item。这个约定虽然没有魔法但已经足够把“不可控”改成“集中管理”。后面再想加能力只需要在引擎里扩展而不需要冲到每一个页面去改代码。组件化设计不是玄学它回答的问题只有一个当表单从“一次性页面”变成了“业务产品”我们应该把复杂度集中在哪里。集中的地方就是你的表单引擎。把握好这个思路Element UI / Ant Design 就会从负担变成工具箱而不是替罪羊。