Vue 表单组件拆分:可复用不等于把所有行为塞进一个输入框
表单组件最容易走向一个坏结局:为了复用,把越来越多的行为都塞进同一个“万能输入框”里,最后这个组件既什么都能做,又什么都说不清。等真正要接一个近百字段、联动复杂的表单时,它反而成了最大的阻力。
这次商品发布页重构就把这个问题彻底掀开了。字段接近一百个,行为差异很大,团队里现成的 AppInput 看起来像救命稻草,翻开一看却已经长到八百多行。继续往里塞 prop 当然也能交付,但那只是把问题往后滚。
后面会从这个“万能输入框失控”的现场出发,讲表单组件到底该统一什么、该拆开什么,以及为什么“可复用”从来不等于“把所有场景塞进一个组件”。
考古:一个组件是怎么从三十行长到八百行的
这个组件最早是前年一位已经转岗的同事写的,git log 里第一版干干净净三十行,就是个统一了样式的输入框。之后的提交记录像一部纪录片:A 页面要在失焦时校验,加个 validateOnBlur;B 页面要前置图标,加个 prefixIcon;C 页面手机号要中间四位打码,加个 maskRule;去年双十一运营活动页要个特殊的金色边框,加个 theme。每一次都只多一个 prop,每一次都"合理",攒到现在 props 列表能滚一屏,模板里 v-if 套 v-if,改一个分支怕影响另外五个调用方,加测试又覆盖不全。
我数了一下,这次商品发布页需要的输入行为里,有四个是 AppInput 现在做不到的。摆在面前两条路:继续往里塞四个 prop,把八百行喂成一千行;或者停下来想清楚,表单组件到底该怎么拆。我拿着这个问题去找前端负责人,他说了句定了后面三周方向的话:"这次别修它了,重新拆。拆完这一版就是以后的规矩。"
下面就是这三周里拆出来的做法和判断。
第一刀:先统一外观,再考虑行为
回看 AppInput 的膨胀史,第一个教训是它从一开始就没分清自己要统一什么。表单组件一开始最值得统一的,通常是这些:
- label 展示
- 错误提示
- 间距
- 基础交互样式
如果一上来就把业务校验、字段联动、接口逻辑一起塞进去,组件很快就会失去边界。
我这次的拆法是两层:基础输入组件只负责输入,字段容器负责 label 和错误展示,页面负责业务规则。
1<!-- BaseInput.vue --> 2<template> 3 <input 4 class="base-input" 5 :value="value" 6 :placeholder="placeholder" 7 @input="$emit('input', $event.target.value)" 8 @blur="$emit('blur')" 9 > 10</template> 11 12<script> 13export default { 14 name: 'BaseInput', 15 props: { 16 value: { 17 type: String, 18 default: '', 19 }, 20 placeholder: { 21 type: String, 22 default: '', 23 }, 24 }, 25}; 26</script>
它不关心"这是手机号还是商品标题",也不关心提交接口。它只是一个稳定的输入控件。
字段容器单独处理表单布局:
1<!-- FormField.vue --> 2<template> 3 <label class="form-field"> 4 <span class="form-label">{{ label }}</span> 5 <slot /> 6 <span v-if="error" class="form-error" role="alert">{{ error }}</span> 7 </label> 8</template> 9 10<script> 11export default { 12 name: 'FormField', 13 props: { 14 label: String, 15 error: String, 16 }, 17}; 18</script>
这样输入控件和字段布局互不绑定,组合起来也更灵活。
这套两层结构里,FormField 用 <slot /> 把内容交出去是关键。它只管"左边一个 label、中间一块内容、下面一行错误",至于中间塞的是输入框、下拉框、还是一组单选按钮,它一概不管。商品发布页五个分组、近百个字段,全是这些写法的排列组合:
1<FormField label="商品标题" :error="errors.title"> 2 <BaseInput v-model="form.title" /> 3</FormField> 4 5<FormField label="所属分类" :error="errors.category"> 6 <BaseSelect v-model="form.category" :options="categories" /> 7</FormField> 8 9<FormField label="商品类型" :error="errors.type"> 10 <label><input type="radio" value="real" v-model="form.type"> 实物</label> 11 <label><input type="radio" value="virtual" v-model="form.type"> 虚拟</label> 12</FormField>
布局的一致性(label 对齐、错误样式、间距)由 FormField 统一保证,内容的多样性由 slot 放开。这种"用插槽换配置项"的思路,是这次重拆过程里我对抗组件膨胀最有效的一招——**能用结构表达的差异,就别用 prop 去配。**一旦你发现自己在给组件加 type="text|select|radio" 这种 prop,再配一堆 if/else 决定渲染什么,基本就是该用插槽的信号。AppInput 里那几十个 v-if,一大半都是没用插槽欠下的债。
顺带说个语法层面的小插曲。需要往子组件传渲染上下文时(比如 SKU 表格里每行的自定义单元格),要用作用域插槽。我们二月刚升到 Vue 2.6,新的 v-slot 语法可以用了:
1<SkuTable :rows="form.skuList"> 2 <template v-slot:price="{ row }"> 3 <BaseInput v-model="row.price" placeholder="单价" /> 4 </template> 5</SkuTable>
但项目里存量代码全是老的 slot-scope 写法,两种语法混在一个代码库里。我们的约定是新代码一律 v-slot,老代码不专门去改——语法迁移不值得单开工作量,但新旧混写时 code review 得盯着点,别在同一个组件里两种都用。
第二刀:props 多到一定程度,复用就开始走样
拆完基础层,我给自己立了条规矩来防止重蹈 AppInput 的覆辙。先看反面教材,一旦出现下面这种趋势,就要警觉:
showErrorvalidateOnBlurcustomRulelayoutModeextraTipbeforeChange
参数越来越多,看起来像组件更通用了,实际上往往说明它开始同时服务太多场景。
比如一个组件同时负责输入、布局、校验、接口、权限、联动,它最后会变成这样:
1<SmartInput 2 v-model="form.phone" 3 label="联系电话" 4 layout-mode="horizontal" 5 :validate-on-blur="true" 6 :remote-rule="checkPhone" 7 :show-extra-tip="true" 8 :disabled-by-permission="true" 9/>
看起来调用很短,实际所有复杂度都藏进了组件内部。后面任何业务差异都会继续加 props——AppInput 就是这么死的。
更清楚的方式是把差异留在页面:
1<FormField label="联系电话" :error="errors.phone"> 2 <BaseInput 3 v-model="form.phone" 4 placeholder="请输入联系电话" 5 @blur="validatePhone" 6 /> 7</FormField>
页面知道业务规则,组件负责稳定能力。这个边界会更耐用。
判断一个 prop 该不该加,我的标准是问自己:"这个 prop 描述的是组件的能力,还是某个页面的业务?"placeholder、disabled、maxlength 这种是能力,加;remote-rule="checkPhone"、disabled-by-permission 这种是业务,不加——checkPhone 是商品发布页的事,权限判断是这个角色的事,组件不该知道这些。
还有一类伪装得很好的 prop 叫"行为开关",比如 validateOnBlur、showError、autoFormat。它们危险在于:每加一个布尔开关,组件内部的分支就翻倍,true/false 的组合爆炸到后面根本测不过来。我的处理是,能让调用方自己用事件和插槽控制的行为,就别做成内部开关。比如"失焦时校验",与其加 validateOnBlur 让组件内部决定要不要校验,不如组件老老实实 @blur 抛出去,让页面决定 blur 之后干什么。控制权交给页面,组件就永远只有一种行为,简单且可预测。
第三刀:v-model 只是输入协议
在 Vue 2 里,自定义组件支持 v-model,通常对应 value 和 input:
1export default { 2 props: { 3 value: String, 4 }, 5 methods: { 6 handleInput(event) { 7 this.$emit('input', event.target.value); 8 }, 9 }, 10};
这是一种输入协议,不代表组件应该顺手处理所有业务状态。
如果一个输入组件内部私自维护一份值,又和父组件值同步,就很容易出现状态不同步。除非有明确原因,否则让父组件成为唯一数据来源会更简单。
这话我有资格说,因为这次重拆的过程里我自己现踩了一遍。价格那一组要一个金额输入框,用户输入 1000 显示成 1,000。我图省事在组件内部 data 里存了个 innerValue,输入时格式化 innerValue,再 emit 纯数字给父组件。自测没问题,联调时炸了:SKU 联动逻辑在某些情况下会把单价重置成 0,但组件内部的 innerValue 还显示着 1,000,因为我没处理"外部值变化时同步内部值"。于是页面数据是 0、输入框显示 1,000,测试一口气提了三个单,其实是同一个根因。
补救要么加 watch 监听 value 回写 innerValue,要么干脆不存内部状态——用 computed 把格式化做成纯展示:
1export default { 2 props: { value: { type: [Number, String], default: '' } }, 3 computed: { 4 // 展示用,永远从 value 派生,不存第二份真相 5 displayValue() { 6 return this.value === '' ? '' : Number(this.value).toLocaleString(); 7 } 8 }, 9 methods: { 10 handleInput(e) { 11 // 去掉千分位逗号,emit 纯数字 12 const raw = e.target.value.replace(/,/g, ''); 13 this.$emit('input', raw); 14 } 15 } 16};
只要值的"唯一真相"始终在父组件,组件内部的所有东西都从它派生,就不会出现两份状态打架。"单一数据源"听起来是句正确的废话,但表单里 90% 的状态不同步 bug,根子都在偷偷存了第二份。
另外分类扩展属性那块动态字段,上周还让我结结实实撞了一回 Vue 2 响应式的墙——this.formData[field.key] = '' 加的字段打字没反应,得用 this.$set 或者整体替换对象。那个坑牵扯到 Object.defineProperty 的原理,我前几天单独写了一篇读源码笔记,这里不展开,只留一句结论:动态生成的表单字段,初始化方式决定它是不是响应式的,别等输入框失灵才想起来。
第四刀:校验逻辑放在哪里
近百个字段的校验,是这次表单最容易失控的部分。我最后的选择是:基础校验放在页面或表单层,写成纯函数:
1function validateProduct(form) { 2 const errors = {}; 3 4 if (!form.title.trim()) { 5 errors.title = '请输入商品标题'; 6 } 7 8 if (!/^1\d{10}$/.test(form.contactPhone)) { 9 errors.contactPhone = '请输入有效手机号'; 10 } 11 12 return errors; 13}
页面提交时使用:
1methods: { 2 async submit() { 3 this.errors = validateProduct(this.form); 4 5 if (Object.keys(this.errors).length > 0) { 6 return; 7 } 8 9 const result = await this.saveProduct(this.form); 10 11 if (!result.ok) { 12 this.submitError = result.message; 13 return; 14 } 15 16 this.submitError = ''; 17 }, 18},
校验规则和业务字段强相关时,放在页面或表单模块里更直观。输入组件只需要把输入和失焦事件抛出来。
把校验写成这样一个纯函数(输入 form、输出 errors 对象)有几个实打实的好处。一是好测——它不依赖组件、不依赖 DOM,单测里直接喂数据断言结果就行:
1test('手机号格式错误时报错', () => { 2 const errors = validateProduct({ title: '测试商品', contactPhone: '123' }); 3 expect(errors.contactPhone).toBe('请输入有效手机号'); 4});
商品发布的校验规则我写了四十多条 Jest 用例,跑一遍两秒钟。要是校验埋在组件里,这四十个场景就得挂载组件、模拟输入、断言 DOM,谁写谁知道。二是好复用——同一份规则,新建页和编辑页都能用,不用在两个组件里各抄一遍。三是触发时机灵活,整体提交时校验全部,单字段失焦时只挑那个字段的错误展示,规则本身不用动。
实际落地时我把校验分了两层:能在本地同步判断的(必填、格式、长度、价格区间)放纯函数里,提交前一把校验完,体验最好,用户不用等网络;需要查后端的(商品编码是否重复、SKU 组合是否已存在)单独做成异步校验,挂在失焦或防抖之后,别和同步校验混在一起。混在一起的后果是,同步规则也得 await,本来即时的反馈被网络拖慢了。这两类校验性质不同,分开处理代码也清楚。
当然也认真考虑过 Element UI 的 el-form + rules,那套 async-validator 不是不能用,团队别的页面用得也不少。但商品发布这种"A 填了 B 才必填、分类换了规则整组换"的跨字段联动多到离谱,声明式 rules 写起来全是别扭的函数套函数,不如手写纯函数直白。工具没有优劣,只有跟场景合不合手。
第五刀:提交错误要统一
表单最容易出现错误处理分散的问题。这次我在接口层约定了统一结构:
1async function postJson(url, payload) { 2 try { 3 const response = await fetch(url, { 4 method: 'POST', 5 headers: { 'Content-Type': 'application/json' }, 6 body: JSON.stringify(payload), 7 }); 8 9 if (!response.ok) { 10 return { ok: false, message: '保存失败,请稍后重试' }; 11 } 12 13 return { ok: true, data: await response.json() }; 14 } catch (error) { 15 return { ok: false, message: '网络异常,请检查连接' }; 16 } 17}
页面只处理 ok、message 和 data,不用在每个表单里重复写一套 try...catch 文案。
不过统一文案有个边界,是联调时后端同事提醒我的:后端的字段级校验错误别被这层"友好文案"吃掉。比如提交时后端返回 { field: 'productCode', message: '该商品编码已存在' },这种带具体字段的错误应该回填到对应输入框下面,而不是笼统弹一句"保存失败"——一百个字段的表单弹这么一句,用户找错误能找哭。我们的约定是后端在 422 这类校验失败时返回结构化的字段错误,前端这层包装把它透传出去:
1if (response.status === 422) { 2 const body = await response.json(); 3 // 把字段级错误带回去,让页面回填到对应 FormField 4 return { ok: false, fieldErrors: body.errors }; 5}
页面拿到 fieldErrors 后合并进 this.errors,错误就精准落到了出问题的那个字段下面,还能顺手把页面滚动到第一个报错的 FormField。统一错误处理是为了消灭重复的样板代码,不是为了把所有错误都碾成同一句话——该精确的地方还得精确。
提交按钮的 loading 和防重复点击也顺手在这层兜了:提交期间禁用按钮、置 submitting = true,无论成功失败都在 finally 里复位,避免用户狂点导致重复创建商品。这种细节不做,测试环境发现不了,一上线遇到网络慢的用户就出脏数据。
还有一个百字段表单特有的需求:草稿。运营填到一半去开会,回来页面过期了,一百个字段重填一遍能把人逼疯。我加了个简单的本地草稿——watch 表单对象(加防抖),序列化存进 localStorage,进页面时提示"检测到未提交的草稿,是否恢复"。实现不到五十行,验收时运营专门跑来说了句谢谢。表单做到最后,拼的往往不是组件设计,是这种体谅用户的小地方。
收刀:什么时候值得继续抽象
商品发布页上周五提测了。整套表单最后的结构是:BaseInput/BaseSelect 这层基础控件不到两百行,FormField 六十行,校验是一个纯函数模块,接口错误处理是一个 postJson。没有一个组件超过两百行,没有一个 prop 是为某个特定页面加的。
但我也提醒自己别矫枉过正——抽象不是不能加,而是要等重复足够明确。
比较适合继续抽象的内容:
- 统一的字段间距
- 统一的错误展示
- 统一的输入尺寸
- 统一的禁用和加载状态
- 稳定的输入协议
不适合过早塞进基础组件的内容:
- 某个页面的接口请求
- 某个业务的联动规则
- 某个角色的权限判断
- 某个活动页的特殊文案
组件越基础,越应该稳定;业务越多变,越应该留在业务层。
前端负责人 review 完这套东西,问了我一个问题:"如果明年有人往 BaseInput 里加 maskRule,你拦得住吗?"我说拦不住,但我在组件顶上留了注释,写了这个组件只做什么、不做什么,以及为什么——AppInput 的八百行就是没写这句话的下场。
Vue 表单组件做得稳不稳,关键不在于抽没抽,而在于抽象边界是不是克制。输入组件最适合统一的是外观和基础交互,不该顺手把整个业务逻辑也吞进去。复用是为了减轻页面压力,不是为了再造一个更复杂的配置层。稳妥的顺序是:**先抽基础输入,再抽字段布局,最后根据真实重复抽表单能力。**不要一开始就造一个能配置所有场景的万能组件——每一个八百行的祖传组件,都曾经是一个三十行的好组件。