1. 为什么要拆大表单?一个“饿了么”风格项目给我的教训
先交代背景。我手上维护过一个仿“饿了么”的商家后台,里面有个“新增商品”页面,所有信息不分青红皂白堆在同一个 Vue 组件里:商品基本信息、规格库存、配送信息、优惠活动、图文详情、售后说明,加起来 40 多个字段,表单校验规则接近 30 条。那个单文件组件一度写到 1700 行,template 部分滚动起来跟翻山一样。
后来需求方提了个改动,要在“配送信息”里加一个“是否支持开发票”的开关,开出来还要多三个字段。我改了将近两个小时,因为一个字段的联动影响校验、影响提交的数据结构,还要找好几处v-if藏在哪里。改完之后上测试环境,同事反馈说某个规格价格校验不过,我又开始在一个 1700 行文件里大海捞针。就是从那次开始,我下定决心把大表单拆成父子组件结构。
拆完之后最明显的感受:改动“配送信息”再也不需要打开那 1700 行的巨型文件了,每个人的改动冲突概率下降,测试回归范围也清晰了。这篇文章就把我拆分“饿了么”风格大表单的完整思路、代码实现、校验联动方案以及踩过的坑都整理成文字,给同样被巨型表单折磨的同学一个可抄的作业。
1.1 大表单的真实痛点,不只是“代码太长”
很多人觉得大表单拆分就是为了让代码短一点、好看一点,实际没那么简单。我把当时的痛点归纳成四类:
- 改不动:一个字段的改动要牵动校验、提交数据结构、默认值、联动显隐,没有清晰边界的时候,每处改动都像在拆地雷。
- 审不动:代码评审时看到 1700 行文件,评审人根本不可能逐行细看,最后只是走个过场,问题就留到测试阶段或者线上。
- 测不动:回归测试时,因为所有逻辑都在一个组件里,只要改动任何一个字段,整个表单都要回归一遍,没有局部独立验证的可能。
- 复用不了:很多模块比如“地址信息”、“收货人信息”其实在不同页面都会出现,但因为当初全部写在页面组件里,没办法直接复用,只能复制粘贴,然后维护两份、三份甚至更多份代码。
这些痛点不是靠“把代码写整齐一点”就能解决的,必须从组件结构上做调整。
1.2 拆分组件之后到底获得了什么
拆完之后,我统计过几组数据,体验差异非常直观:
| 对比项 | 拆分前 | 拆分后 |
|---|---|---|
| 页面组件行数 | 1700+ | 230 左右 |
| 单次需求改动平均耗时 | 2 小时 | 40 分钟 |
| 回归测试范围 | 整个表单页面 | 仅涉及的子模块 |
| 可复用模块数量 | 0 | 4 |
| 新同事上手成本 | 高,需要通读全文 | 低,按模块理解 |
不过拆组件也不是零成本,要处理父子组件的数据同步、校验联动、事件传递,一开始确实会有点绕。但只要你把数据流搞顺了,后面收益是复利式的。接下来我详细讲怎么拆。
2. 拆分之前必须先想清楚的三件事
拆分大表单不是“看着哪块代码多就切哪块”,而是要先定好三个设计决策:按什么维度切分、数据放在哪、组件之间怎么通信。这三件事没想清楚就动手,拆完只会得到一堆互相纠缠的小组件,比不拆还难受。
2.1 组件粒度划分:按业务模块切,少按“代码长度”切
我当时用过两种划分思路,一种按“字段类型”切,比如“所有输入框放一起、所有下拉框放一起”,另一种按“业务模块”切,比如“菜品基本信息”、“规格库存”、“配送信息”。实践证明,业务模块切分是最符合认知的,因为表单的变更需求天然是按业务模块来的。
举个具体例子,"饿了么"新增菜品页面可以切成这些子模块:
- 基本信息:菜品名、分类、描述、图片
- 规格库存:规格名、价格、库存、条码
- 配送信息:配送方式、配送费、起送价、是否支持发票
- 优惠信息:满减设置、优惠券标记
- 图文详情:富文本内容、详情图片
这五个模块各自独立成组件,父组件只负责聚合数据和最终提交。划分的关键原则是:一个子组件应该拥有高内聚、低耦合的业务职责,它内部的字段变更多半是同时发生的,很少跨模块牵扯。
2.2 数据放哪:父组件统一持有,子组件只管展示和编辑
初期拆组件最容易犯的错误,是让每个子组件自己维护一套 data,父组件提交时才去各个子组件里“收数据”。这种方案第一次写起来爽,因为不用考虑数据同步,但只是把“大坑”换成了“分散的坑”。比如你在子组件里改了价格,其他兄弟组件需要感知这个变化,就得通过事件一路往上抛,再往下传,代码瞬间就绕成了蜘蛛网。
我的方案是:整个表单的数据模型统一放在父组件里。父组件维护一个formData对象,每个子组件通过 props 接收自己需要的那块数据,用户操作时通过$emit把新值传回父组件,父组件更新formData,再通过响应式机制把新数据推回所有需要的地方。
这个模式本质上是 Vue 官方的“单向数据流”:props 向下传递数据,events 向上传递消息。它的好处是数据来源唯一、修改路径清晰,出现 bug 时你永远知道要去哪个组件里改。
2.3 组件通信:只用 props + $emit 能解决 90% 的场景
除了 props 和 $emit,Vue 2 还提供 eventBus、Vuex、$refs 等方式。但在我这个表单拆分场景里,我最终只用了 props + $emit,外加少量的.sync修饰符和v-model封装,完全够用。
为什么不推荐在表单场景里用 eventBus?因为 eventBus 是全局事件机制,你在某个组件里$on监听、在另一个组件里$emit触发,数据流变得隐式、难以追踪。表单编辑器的需求是“明确、可控、可预测”,全局事件总线会让调试难度直线上升。Vuex 则有点大材小用,除非你的表单数据在其他页面也要共享,否则没必要引入这种重量级状态管理。
3. 核心实现:手把手拆一个“饿了么”风格大表单
接下来进入实操环节。我以一个仿“饿了么”商家后台的“新增菜品”表单为例,从父组件的框架搭建,到子组件的具体实现,逐个讲清楚。
3.1 父组件:只做数据聚合和提交
父组件要做的其实不多,但每件事都关键:定义整体formData、渲染子组件、实现数据更新方法、处理校验汇总、执行提交。直接看代码框架:
<template> <div class="product-form"> <el-form ref="productForm" :model="formData" :rules="rules" label-width="100px"> <basic-info v-model="formData.basicInfo" :rules="rules" ></basic-info> <spec-stock v-model="formData.specList" :rules="rules" ></spec-stock> <delivery-info v-model="formData.delivery" :rules="rules" ></delivery-info> <coupon-info v-model="formData.coupon" :rules="rules" ></coupon-info> <div class="submit-bar"> <el-button @click="handleCancel">取消</el-button> <el-button type="primary" @click="handleSubmit">提交</el-button> </div> </el-form> </div> </template>你能看到每个子组件我都用v-model来绑定各自的数据块。v-model本质上是个语法糖,等价于:value="xxx"+@input="xxx = $event"。这块数据在父组件里维护,子组件只是一个“展示和交互区域”。
父组件的 script 部分长这样:
import BasicInfo from './components/BasicInfo.vue' import SpecStock from './components/SpecStock.vue' import DeliveryInfo from './components/DeliveryInfo.vue' import CouponInfo from './components/CouponInfo.vue' export default { name: 'ProductForm', components: { BasicInfo, SpecStock, DeliveryInfo, CouponInfo }, data() { return { formData: { basicInfo: { name: '', categoryId: null, description: '', imageUrl: '' }, specList: [], delivery: { method: 'merchant', fee: 0, minPrice: 20 }, coupon: { enabled: false, thresholds: [] } } } }, methods: { handleSubmit() { this.$refs.productForm.validate(valid => { if (valid) { // 这里拿到的是已经聚合好的 formData,直接提交 console.log(this.formData) } else { this.$message.error('表单校验未通过,请检查') } }) } } }这里有个非常重要的点:父组件里所有校验规则都是通过rules对象统一定义的,并不是把规则散落到各个子组件里。为什么这么做?因为 Vue 的 el-form 校验是以表单为单位执行的,所有表单项的校验规则如果分散在子组件中,这个父级el-form很难统一掌控校验时机和结果。统一放在父组件里,校验触发时一次搞定。
3.2 子组件:接收 props、渲染表单、回传数据
以“基本信息”子组件为例,它负责菜品名称、分类、描述、图片四个字段。子组件的职责就是根据 props 渲染表单元素,并在用户输入时把最新的值通过事件传出去。
<template> <el-card class="section-card" shadow="never"> <div slot="header">基本信息</div> <el-form-item label="菜品名称" prop="name"> <el-input :value="value.name" @input="updateField('name', $event)" placeholder="请输入菜品名称" ></el-input> </el-form-item> <el-form-item label="菜品分类" prop="categoryId"> <el-select :value="value.categoryId" @change="updateField('categoryId', $event)" placeholder="请选择分类" > <el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" ></el-option> </el-select> </el-form-item> <el-form-item label="菜品描述" prop="description"> <el-input type="textarea" :value="value.description" @input="updateField('description', $event)" ></el-input> </el-form-item> </el-card> </template> <script> export default { name: 'BasicInfo', props: { value: { type: Object, required: true }, rules: { type: Object, default: () => ({}) } }, data() { return { categoryOptions: [ { id: 1, name: '热菜' }, { id: 2, name: '凉菜' }, { id: 3, name: '主食' } ] } }, methods: { updateField(key, value) { this.$emit('input', { ...this.value, [key]: value }) } } } </script>这里我特意用了:value配合@input而不是直接在子组件里用v-model绑定 props。因为 Vue 2 里直接给 props 绑 v-model 会触发“避免直接修改 props”的警告。现在这种写法,每次输入都会生成一个新的对象传给父组件,父组件更新formData.basicInfo,再通过响应式系统把新对象传回来。这样既保持了单向数据流,又实现了数据同步。
3.3 更简洁的写法:用 computed 封装 v-model,真正实现“局部 v-model”
上面的写法每次都要写updateField方法,虽然不复杂,但字段一多还是有点啰嗦。后来我发现一个更顺手的方案:在子组件里用 computed 创建一个“可写的计算属性”,配合父组件的 v-model,使用体验跟直接在子组件里用双向绑定一模一样。
<template> <el-card class="section-card" shadow="never"> <div slot="header">基本信息</div> <el-form-item label="菜品名称" prop="name"> <el-input v-model="localValue.name" placeholder="请输入菜品名称"></el-input> </el-form-item> <el-form-item label="菜品分类" prop="categoryId"> <el-select v-model="localValue.categoryId" placeholder="请选择分类"> <el-option v-for="item in categoryOptions" :key="item.id" :label="item.name" :value="item.id" ></el-option> </el-select> </el-form-item> </el-card> </template> <script> export default { name: 'BasicInfo', props: { value: { type: Object, required: true }, rules: { type: Object, default: () => ({}) } }, data() { return { categoryOptions: [] } }, computed: { localValue: { get() { return this.value }, set(val) { this.$emit('input', val) } } } } </script>这样模板里不需要再写一堆updateField方法了,直接用 v-model 即可。需要注意一点:computed 的get返回的是父组件传下来的value对象引用,子组件里直接修改某个属性其实还是改了这个对象,所以本质上你还是会碰到底层对象。为了避免绕晕,我建议在父组件更新数据时始终创建新对象,或者至少保证子组件不直接localValue.xxx = 'yyy',而是通过$emit让父组件去改数据。
一个更保守但更安全的写法是:在子组件里创建本地副本,然后在合适的时机(如@change或@blur)一次性 emit 给父组件。这种方式适合那种“不需要实时同步”的场景,比如文案内容,用户停手了才同步。不过在选择框、开关这类即时交互组件上,还是实时同步体验更好,我自己用得最多的还是 computed + v-model 的组合。
3.4 表单校验的父子协同:关键在 prop 路径
拆完组件之后,表单校验是最容易翻车的环节。很多人拆完组件发现校验不生效了,原因多半是el-form-item的prop路径和el-form的model对不上。
父组件里el-form绑定的 model 是formData,规则里写的是basicInfo.name。子组件里的el-form-item的prop应该怎么写?如果直接在子组件里写prop="name",Vue 的校验器会在父组件的formData上找name字段,根本找不到,校验自然失效。
正确的写法是:子组件里的el-form-item的 prop 要写全路径,例如prop="basicInfo.name"。因为 Element UI 的表单校验是通过model[prop]取值来定位表单项的,子组件里虽然没有直接持有完整的 model,但父组件在渲染时会层层传递formData,整体校验时 Element UI 拿到的是整个 form 的 model 和 rules,它会用这个 prop 路径去 model 里取对应的值进行校验。
一个实用的做法是把基础路径作为子组件的一个 prop 传下去,或者直接在子组件里写上完整路径。我习惯后者,因为代码更直白,不会出现路径拼接的拼写错误。比如在 BasicInfo 组件里:
<el-form-item label="菜品名称" prop="basicInfo.name"> </el-form-item>然后在父组件的 rules 中定义:
rules: { 'basicInfo.name': [ { required: true, message: '请输入菜品名称', trigger: 'blur' } ], 'basicInfo.categoryId': [ { required: true, message: '请选择菜品分类', trigger: 'change' } ], 'delivery.fee': [ { required: true, message: '请输入配送费', trigger: 'blur' } ] }注意,rules 的键名多了引号,因为包含点号。这个细节虽然简单,但确实坑过不少刚接触 Element UI 组件化开发的人。
3.5 动态表单数组:规格库存的拆分与嵌套
前面几个子模块都是对象结构,但“规格库存”这种典型的一对多关系,是数组结构。拆分后怎么处理?先说目标:用户能动态添加多组规格,每组规格包含规格名、价格、库存、条码,还可以删除某一行。
父组件中 formData 里的specList是一个数组。我给子组件传值传整个数组,增删操作也全在子组件里通过 emit 通知父组件。子组件模板:
<template> <el-card class="section-card" shadow="never"> <div slot="header">规格库存</div> <div v-for="(item, index) in list" :key="index" class="spec-row"> <el-form-item :label="'规格 ' + (index + 1)" :prop="'specList.' + index + '.name'" :rules="rules['specList.name']" > <el-input v-model="list[index].name" placeholder="规格名"></el-input> </el-form-item> <el-form-item :label="'价格'" :prop="'specList.' + index + '.price'" :rules="rules['specList.price']" > <el-input v-model="list[index].price" placeholder="价格"></el-input> </el-form-item> <el-button type="text" @click="removeSpec(index)">删除</el-button> </div> <el-button type="primary" plain @click="addSpec">新增规格</el-button> </el-card> </template>这里有一个特别容易踩的坑:v-model="list[index].name"这个写法直接修改了 props 传下来的对象。前面我说过,Vue 2 对 props 直接修改会警告,但是如果父组件传下来的是一个数组引用,你修改数组里某个对象的属性,Vue 实际上不会报警告(因为对象是响应式的,修改属性不触发 props 的赋值操作)。不过这种写法仍然破坏了单向数据流,也会导致当父组件因为其他原因重新渲染时,本地状态可能与父组件不一致。
保险起见,我在这个组件里用了另一种方案:维护一个本地副本localList,用 watch 监听父组件传下来的 value,一旦变化就更新本地副本;用户操作时,直接操作 localList,然后整体 emit 一次。看看实现:
props: { value: { type: Array, required: true } }, data() { return { localList: [] } }, watch: { value: { handler(val) { this.localList = JSON.parse(JSON.stringify(val)) }, immediate: true, deep: true } }, methods: { addSpec() { this.localList.push({ name: '', price: 0, stock: 0, barcode: '' }) this.$emit('input', this.localList) }, removeSpec(index) { this.localList.splice(index, 1) this.$emit('input', this.localList) }, updateSpec() { this.$emit('input', this.localList) } }模板里把v-model="list[index].name"换成v-model="localList[index].name"即可。用深拷贝维护本地副本,是为了避免直接修改 props 引用导致父组件数据被意外篡改。不过这里也要注意:如果specList数据量很大,深拷贝可能带来性能开销,一般表单规模下问题不大,但如果你有几百级递归的树形结构,就要考虑别的方案了。
3.6 联动场景:优惠信息的开关与条件表单
联动是表单实践中的家常便饭。“优惠信息”模块里有个“是否参与满减”的开关,打开时才显示满减规则设置。这个联动逻辑放在父组件还是子组件?
如果联动只影响自身模块内部,放在子组件内部处理最合适。但如果联动还会影响其他模块——比如开启满减后,需要在“基本信息”里展示一个“满减标记”——就需要把状态提升到父组件,通过 props 传给其他模块。
以自身联动为例,我在 CouponInfo 组件里这样处理:
<template> <el-card class="section-card" shadow="never"> <div slot="header">优惠信息</div> <el-form-item label="是否满减"> <el-switch v-model="localValue.enabled"></el-switch> </el-form-item> <template v-if="localValue.enabled"> <el-form-item label="满减门槛" :prop="'coupon.thresholds'" :rules="rules['coupon.thresholds']" > <el-input-number v-model="localValue.thresholds[0]" :min="0"></el-input-number> </el-form-item> <el-form-item label="减免金额"> <el-input-number v-model="localValue.thresholds[1]" :min="0"></el-input-number> </el-form-item> </template> </el-card> </template>当enabled为 false 时,满减字段直接不渲染,提交时也不会被校验到,这比用v-show再手动控制禁用规则要省心得多。但是要注意,如果提交时后端要求thresholds字段始终存在,这里还需要在切换开关时给thresholds赋初始值,否则会出现undefined导致提交报错。
4. 实操过程中的“高光时刻”与“翻车现场”
理论讲完,我把实际开发中遇到的几个比较有代表性的问题列出来,每个都附上排查思路和最终解决方案。
4.1 子组件内 el-form-item 的 prop 失效
现象:子组件里表单校验规则没生效,即输入为空就提交,没有弹出错误提示。
定位过程:先在浏览器里查看 Element UI 的校验源码,发现el-form会在父级el-form-item的 context 中查找对应的 prop 值。由于子组件渲染的el-form-item并没有继承父级el-form的 model 信息,所以规则全都匹配不上。
解决:这里有个小技巧——在父组件中把 rules 传给子组件,子组件里用别名形式。具体做法是父组件把 rules 整体传入,子组件内的el-form-item用完整路径的方式传递 prop,并在子组件里按需引入rules[prop路径]来配置该表单项的校验规则。
我当时踩了这个坑后,把所有子组件内表单项的 prop 都改成了完整路径,再统一在父组件里配 rules,校验不再失效。有一点要强调:如果你在子组件里又单独写了一套 rules 给该子组件内的 el-form-item,那等于拆散了统一的表单校验体系,后患无穷。
4.2 props 直接修改告警
没有用上述 localValue 计算属性方案之前,我在子组件里写了不少this.value.xxx = yyy的代码,控制台立刻出现:
Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders.这个警告不是在 dev 模式下才出现的,它意味着你修改的 props 数据会在父组件重新渲染时被覆盖。更麻烦的是,这种覆盖行为有时是可复现的,有时候又因为异步数据更新导致状态不一致,会留下很难查的 bug。
解决:把需要修改的数据全部放到 computed 里代理,或者用本地副本 + watch 深监听。我当时统一改成了 computed 写法之后,这个警告再也没有出现过。
4.3 联动的数据不同步
有一次我遇到了一个诡异的现象:在一个子组件里修改了规格价格,另一个子组件里的“总价格区间”没有同步。排查半天发现,问题出在父组件中我给两个子组件绑定的是同一个formData.specList的引用,但子组件内部为了保持纯净使用了本地深拷贝,所以修改 localList 后 emit 给了父组件,父组件重新渲染后,理论上另一个子组件应该收到新值。
但实际上另一个子组件用 watch 监听的 value 变化并不是每次都触发,原因是JSON.parse(JSON.stringify(val))深拷贝出来的新对象和旧对象在 watch 的默认浅比较下判定没有变化。
解决方式:watch 加上deep: true,或者不用深拷贝副本,而是直接操作 prop 引用并 emit。最保险的做法是深拷贝 +deep: true组合,代价是每次变化都要做一次深拷贝,但在表单数据规模下性能完全可以接受。
4.4 子组件数量多了,初始渲染性能变差
拆分出的子组件有 5 个,每个里面又有若干 el-form-item,整个表单一次性渲染出来,首屏时间比拆分前慢了一两百毫秒。虽然不算严重,但在低端手机上已经能感知卡顿。
后来我做了两个优化:第一是给暂时不需要渲染的模块加v-if控制。比如“优惠信息”默认不展开,只有在点击“高级设置”时才渲染,借助 Vue 的异步渲染机制,把一部分渲染开销分担到交互之后。第二是把一些纯展示的子组件用v-show或者结合keep-alive做缓存,减少重复渲染成本。
不过这只是我当时的场景,如果你的表单本来就很简单,也不用强行用 v-if 拆渲染时机,保持可读性更重要。
5. 回顾:这一套拆分方案好在哪、还能怎么用
在拆“饿了么”风格大表单这件事上,我最终的成果比较满意。整个页面组件从 1700 行瘦身到 200 多行,四个业务模块各自独立成组件,校验规则统一收敛在父组件里,数据流清晰可追溯。最重要是改动需求的时候,我再也不用担心“牵一发动全身”了。
这个方案不只适用于仿“饿了么”的后台,任何中后台系统里的“信息录入表单”都可以套用。例如订单录入、用户资料编辑、商品配置、审批流程表单,只要超过 15 个字段或者包含三个以上的业务模块,就可以考虑用父子组件拆分的思路重新组织代码。
如果你现在刚接手一个巨型表单页面,我的建议是:不要急着重构,先像我前面说的那样,把数据模型梳理出来,按业务模块画出组件边界,再开始动手。重构过程中随时做好回归测试,每拆完一个模块就验证一下校验逻辑是否正常。多试几次之后,你会慢慢找到那种“一眼就知道这字段该放哪”的感觉。