Vue 表单组件拆分:可复用不等于把所有行为塞进一个输入框

表单组件最容易走向一个坏结局:为了复用,把越来越多的行为都塞进同一个“万能输入框”里,最后这个组件既什么都能做,又什么都说不清。等真正要接一个近百字段、联动复杂的表单时,它反而成了最大的阻力。

这次商品发布页重构就把这个问题彻底掀开了。字段接近一百个,行为差异很大,团队里现成的 AppInput 看起来像救命稻草,翻开一看却已经长到八百多行。继续往里塞 prop 当然也能交付,但那只是把问题往后滚。

后面会从这个“万能输入框失控”的现场出发,讲表单组件到底该统一什么、该拆开什么,以及为什么“可复用”从来不等于“把所有场景塞进一个组件”。

考古:一个组件是怎么从三十行长到八百行的

这个组件最早是前年一位已经转岗的同事写的,git log 里第一版干干净净三十行,就是个统一了样式的输入框。之后的提交记录像一部纪录片:A 页面要在失焦时校验,加个 validateOnBlur;B 页面要前置图标,加个 prefixIcon;C 页面手机号要中间四位打码,加个 maskRule;去年双十一运营活动页要个特殊的金色边框,加个 theme。每一次都只多一个 prop,每一次都"合理",攒到现在 props 列表能滚一屏,模板里 v-ifv-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 的覆辙。先看反面教材,一旦出现下面这种趋势,就要警觉:

  • showError
  • validateOnBlur
  • customRule
  • layoutMode
  • extraTip
  • beforeChange

参数越来越多,看起来像组件更通用了,实际上往往说明它开始同时服务太多场景。

比如一个组件同时负责输入、布局、校验、接口、权限、联动,它最后会变成这样:

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 描述的是组件的能力,还是某个页面的业务?"placeholderdisabledmaxlength 这种是能力,加;remote-rule="checkPhone"disabled-by-permission 这种是业务,不加——checkPhone 是商品发布页的事,权限判断是这个角色的事,组件不该知道这些。

还有一类伪装得很好的 prop 叫"行为开关",比如 validateOnBlurshowErrorautoFormat。它们危险在于:每加一个布尔开关,组件内部的分支就翻倍,true/false 的组合爆炸到后面根本测不过来。我的处理是,能让调用方自己用事件和插槽控制的行为,就别做成内部开关。比如"失焦时校验",与其加 validateOnBlur 让组件内部决定要不要校验,不如组件老老实实 @blur 抛出去,让页面决定 blur 之后干什么。控制权交给页面,组件就永远只有一种行为,简单且可预测。

第三刀:v-model 只是输入协议

在 Vue 2 里,自定义组件支持 v-model,通常对应 valueinput

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}

页面只处理 okmessagedata,不用在每个表单里重复写一套 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 表单组件做得稳不稳,关键不在于抽没抽,而在于抽象边界是不是克制。输入组件最适合统一的是外观和基础交互,不该顺手把整个业务逻辑也吞进去。复用是为了减轻页面压力,不是为了再造一个更复杂的配置层。稳妥的顺序是:**先抽基础输入,再抽字段布局,最后根据真实重复抽表单能力。**不要一开始就造一个能配置所有场景的万能组件——每一个八百行的祖传组件,都曾经是一个三十行的好组件。