从面条代码到抽象能力:一个小表单场景里的前端成长四阶段

在日常业务开发里,有一类场景出现频率极高:

  • 页面里有一个子表单组件(用 ref 引用)
  • 提交前要让子表单先做一轮校验
  • 校验通过后,从当前组件的数据里组装一个 payload 发给后端

看起来再普通不过,比如下面这样一段代码:

js 复制代码
if (this.reviewContent?.length) {
  // 先让子表单组件校验
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}

const payload = {
  reviewContent: this.reviewContent,
  attachmentList: this.attachmentList,
};

// 调接口......
  • 有内容就校验;
  • 校验不过就提示;
  • 校验通过就拼一个 payload 去提交。

很多前端的"表单生涯",就是从这种线性、直接、带一点点面条味的代码开始的。

有意思的是:
同样一个需求,不同水平的前端,会写出完全不同层次的代码。

从这个小例子出发,我们刚好可以串一条:初级 → 中级 → 高级 → 架构 的成长路径。


一、初级前端:能把流程串起来,就是胜利 🎯

典型写法

还是这段最"朴素"的代码:

js 复制代码
if (this.reviewContent?.length) {
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}

const payload = {
  reviewContent: this.reviewContent,
  attachmentList: this.attachmentList,
};

// 调用接口,比如:api.submit(payload)

逻辑完全按脑子里的流程来:

  1. 有自评内容吗?(this.reviewContent?.length
  2. 有的话就调用子表单的 validate()
  3. 校验没过就弹一条错误消息
  4. 校验通过,拼一个请求体对象
  5. 调接口

这个阶段的特点

  • ✅ 优点:

    • 写起来非常快;
    • 读起来也很直接------业务同学都能看懂。
  • ❌ 问题:

    • 强耦合在当前组件

      • 假设 $refs.reviewForm 一定存在;
      • 假设 validate() 返回 { ok, message }
      • 假设消息提示必须在这里做。
    • 逻辑、UI 提示、payload 结构全部糊在一起;

    • 若别的页面也要搞"先校验子表单再组装 payload",往往是复制粘贴一份再改改字段。

初级阶段的核心目标其实只有一个: "我能把功能做出来。"

想到哪写到哪,是完全正常的。


二、中级前端:开始对"重复"和"耦合"过敏 🧩

当你写了第三、第四个类似的提交流程后,会开始皱眉头:

  • "怎么又是先 validate 再拼对象?"
  • "为什么到处都有同样的错误提示逻辑?"
  • "这要是修改字段名,不得全项目搜索一遍?"

这时候,就会自然走向第一步抽象:提取工具函数

第一步:按"模块"抽函数

比如我们为这块自评表单单独写一个工具文件:

js 复制代码
// utils/reviewForm.js

// 校验自评表单
export function validateReview(vm) {
  if (!vm.reviewContent?.length) {
    // 没填内容,视为不需要校验
    return { ok: true };
  }

  const form = vm.$refs.reviewForm;
  if (!form || typeof form.validate !== 'function') {
    // 看业务需求,这里也可以认为是异常
    return { ok: true };
  }

  const res = form.validate();

  if (!res || res.ok === false) {
    const msg = res?.message || '自评信息校验未通过';
    vm.$message.error(msg);
    return { ok: false, message: msg };
  }

  return { ok: true };
}

// 构建自评 payload
export function buildReviewPayload(vm) {
  return {
    reviewContent: vm.reviewContent,
    attachmentList: vm.attachmentList,
  };
}

组件里就可以这样用:

js 复制代码
import { validateReview, buildReviewPayload } from '@/utils/reviewForm';

async onSubmit() {
  const { ok } = validateReview(this);
  if (!ok) return;

  const payload = buildReviewPayload(this);
  await api.submit(payload);
}

中级前端的思维变化

  • 不再满足于"能跑",开始追求"以后好改一点";
  • 能意识到:可以把公共逻辑抽成函数,避免重复粘贴;
  • 但抽象粒度通常还是按业务模块划分
    reviewFormUtilsbaseInfoUtilspriceUtils......

封装有了,代码好了不少,但还停留在"每个业务模块一套 "的阶段:

每加一个新表单,又是一组新的 validateXxx + buildXxxPayload


三、高级前端:从"封装"走向"抽象模式" 🧠

再往前走一步,你会开始问更有意思的问题:

  • 这些校验 + payload 的套路,本质上是不是一样的?
  • 哪些是"流程",哪些是"策略/细节"?
  • 我能不能写一份通用逻辑,让所有表单都用?

1)提炼通用流程:getPayload

观察会发现,每个表单提交流程几乎都是:

  1. 根据某个 ref 拿到子表单组件;
  2. 调用 validate() 做校验;
  3. 如果失败 → 提示并中断;
  4. 如果成功 → 根据当前组件数据构造 payload。

于是可以写出一个只关心流程的函数:

js 复制代码
// utils/getPayload.js

/**
 * @param {Vue} vm - 当前组件实例
 * @param {Function} buildPayload - (vm) => payload 对象
 * @param {String} refName - 子表单的 ref 名
 */
export function getPayload(vm, buildPayload, refName) {
  const form = vm.$refs?.[refName];

  // 没有这个表单:视为无需校验,直接拼 payload
  if (!form || typeof form.validate !== 'function') {
    return { ok: true, payload: buildPayload(vm) };
  }

  const handle = (res) => {
    if (!res || res.ok === false) {
      const msg = res?.message || '校验未通过';
      vm.$message.error(msg);
      return { ok: false, payload: null, message: msg };
    }
    return { ok: true, payload: buildPayload(vm) };
  };

  try {
    const maybe = form.validate();

    // 支持 Promise 风格的 validate
    if (maybe && typeof maybe.then === 'function') {
      return maybe.then(handle).catch((e) => {
        const msg = e?.message || '校验异常';
        vm.$message.error(msg);
        return { ok: false, payload: null, message: msg };
      });
    }

    // 同步返回
    return handle(maybe);
  } catch (e) {
    const msg = e?.message || '校验异常';
    vm.$message.error(msg);
    return { ok: false, payload: null, message: msg };
  }
}

组件使用示例:

js 复制代码
import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(
    this,
    (vm) => ({
      reviewContent: vm.reviewContent,
      attachmentList: vm.attachmentList,
    }),
    'reviewForm',
  );

  if (!res.ok) return;
  await api.submit(res.payload);
}

此时,getPayload

  • 不再关心 payload 结构;
  • 不再关心具体业务,只负责:
    "找到表单 → 校验 → 错误处理 → 调用参数构造 payload"

2)这里已经有设计模式的影子

  • getPayload 像是一个小号的 模板方法模式(Template Method):

    • 固定了流程:校验 → 错误处理 → 构建 payload
    • 把"payload 怎么构"这一段留给调用方(作为"模板中的可变步骤")。
  • buildPayload 实际上也符合 策略模式(Strategy)的思路:

    • 同一个处理流程,根据不同策略函数(buildXxxPayload),构建不同业务数据。

高级前端的特点是:

  • 不只是"会封装",而是会从逻辑里识别出模式
  • 能把"稳定的部分"和"易变的部分"拆开,分别对待;
  • 会刻意让代码有可扩展点,而不是为了当前需求把东西都焊死。

四、前端架构:把"表单校验 + payload"升级成一种通用能力 🏗️

再往上一个段位,前端架构考虑的不只是"这段代码写得好不好看",而是:

"这种模式在整个项目范围内,要怎么用、怎么演进?"

如果全站有 N 个子表单,每个都要:

  • ref + validate
  • 再拼各自的 payload

那么架构会更倾向于做一件事:

把这种模式正式命名、抽象成一类'能力',然后由所有页面共同复用。

1)用配置表达"表单 → payload"的映射关系

先把"这个 ref 对应什么 payload"抽成配置:

js 复制代码
// config/formPayloadMap.js

export const formPayloadMap = {
  // 自评表单
  reviewForm(vm) {
    return {
      reviewContent: vm.reviewContent,
      attachmentList: vm.attachmentList,
    };
  },

  // 基础信息表单
  baseForm(vm) {
    return {
      baseInfo: vm.baseInfo,
      projectId: vm.projectId,
    };
  },

  // 报价表单
  priceForm(vm) {
    return {
      priceList: vm.priceList,
    };
  },

  // ......
};

2)getPayload:只需要传 refName

现在改造 getPayload,变成只需要 vm + refName

js 复制代码
// utils/getPayload.js
import { formPayloadMap } from '@/config/formPayloadMap';

export function getPayload(vm, refName) {
  const buildPayload = formPayloadMap[refName];

  if (!buildPayload) {
    console.warn(`[getPayload] 未配置 ref "${refName}" 对应的 payload 构造函数`);
    return { ok: false, payload: null, message: `未配置 ${refName} 映射` };
  }

  const form = vm.$refs?.[refName];

  // 没有表单:视为无需校验,直接拼 payload
  if (!form || typeof form.validate !== 'function') {
    return { ok: true, payload: buildPayload(vm) };
  }

  const handle = (res) => {
    if (!res || res.ok === false) {
      const msg = res?.message || '校验未通过';
      vm.$message.error(msg);
      return { ok: false, payload: null, message: msg };
    }
    return { ok: true, payload: buildPayload(vm) };
  };

  try {
    const maybe = form.validate();

    if (maybe && typeof maybe.then === 'function') {
      return maybe
        .then(handle)
        .catch((e) => {
          const msg = e?.message || '校验异常';
          vm.$message.error(msg);
          return { ok: false, payload: null, message: msg };
        });
    }

    return handle(maybe);
  } catch (e) {
    const msg = e?.message || '校验异常';
    vm.$message.error(msg);
    return { ok: false, payload: null, message: msg };
  }
}

组件里的使用体验就变成:

js 复制代码
import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(this, 'reviewForm');
  if (!res.ok) return;

  await api.submit(res.payload);
}

调用方只需要关心:

  • "我这块用的是哪个 ref 的表单?"

至于:

  • 要不要校验;
  • 校验怎么提示;
  • payload 字段怎么组装;

全部由统一机制 + 配置来处理。

3)架构视角下,多了几件事要考虑

在这个阶段,思考重心变成了:

  • 一致性

    • 表单校验行为统一;
    • 错误提示统一;
    • payload 结构的调整有统一入口。
  • 可配置 / 可扩展

    • 新增表单只需要:

      1. 约定好 ref 名;
      2. formPayloadMap 里加一个构造函数;
    • 对核心流程无侵入。

  • 领域化

    • 不再把子表单当成"某个页面的实现细节",
      而是当成领域里的一个"实体":
      reviewFormbaseFormpriceForm......
    • 每个实体都有"校验 + 构造请求体"的统一接口。
  • 长期演进成本

    • 将来如果:

      • 改用新的 UI 表单库;
      • 数据结构升级;
      • Vue2 升 Vue3;
    • ------绝大多数改动都可以限制在小范围内完成。


五、同一个需求,不同段位的差别到底是什么?

用一句话概括每个阶段的心智模式:

  • 初级前端:

    "我能把这个流程串起来,让它跑起来。"

  • 中级前端:

    "这里有重复,我抽一个函数出来,大家都用。"

  • 高级前端:

    "这是一种模式。

    哪些是稳定流程?哪些是可变策略?

    我怎么设计抽象,让一份逻辑服务多个业务?"

  • 前端架构:

    "这不仅是个工具函数,而是一类'通用能力'。

    我要用机制 + 配置,把它变成整个项目的基础设施。"

而最初那段看起来有点"面条味"的代码,其实只是起点。

当你开始嫌弃这类代码,开始思考"能不能抽象、能不能通用"的那一刻,你就已经在从"写代码的人",向"设计代码的人"迈进了。


如果你现在项目里正好到处都是:

js 复制代码
const res = this.$refs.xxx.validate();
// if (!res.ok) ...
// const payload = { ... }

不妨找一个最典型的提交流程,从:

直接写 → 提取函数 → 通用流程 + 策略 → 配置化

这四步路径里,选你觉得当前团队能接受的一步 先落地。

技术成长很多时候不是换框架、追新库,而是搞定这种"看起来很小,但无处不在"的模式。


六、如果业务继续变复杂:往"建造者风格"进化 🧱

前面那套 getPayload + formPayloadMap,足以覆盖大部分常规业务表单场景:

  • 每个表单的 payload 结构相对固定;
  • 只要从 vm 上摘几个字段拼一下就行。

但真实项目有时候会长成这样:

  • 某些字段是「勾选了某个开关才需要拼进去」
  • 某些片段要按不同的业务类型组合(比如:普通流程 / 加急流程 / 审批流)
  • 有时还需要先异步拿一部分数据,再参与构建 payload

这时候,简单的:

js 复制代码
formPayloadMap[refName](vm) {
  return { ... };
}

就可能开始变得又长又丑了:里面充满 if / switch / 三元运算符。

这个时候,就可以考虑往**"建造者风格(Builder-style)"**上进化:

把一个"大 payload"拆成多个可组合的构建步骤。

1)一个简化版 PayloadBuilder 示例

先来个最小可用的版本:

js 复制代码
// builders/PayloadBuilder.js
export class PayloadBuilder {
  constructor(vm) {
    this.vm = vm;
    this.data = {};
  }

  withReview() {
    if (this.vm.reviewContent?.length) {
      this.data.reviewContent = this.vm.reviewContent;
    }
    return this;
  }

  withAttachments() {
    if (Array.isArray(this.vm.attachmentList)) {
      this.data.attachmentList = this.vm.attachmentList;
    }
    return this;
  }

  withBaseInfo() {
    if (this.vm.baseInfo) {
      this.data.baseInfo = this.vm.baseInfo;
    }
    return this;
  }

  // 你可以继续加更多 withXxx 模块...

  build() {
    return this.data;
  }
}

使用方式(举个场景):

js 复制代码
import { PayloadBuilder } from '@/builders/PayloadBuilder';
import { getPayload } from '@/utils/getPayload';

async onSubmit() {
  const res = await getPayload(
    this,
    (vm) => new PayloadBuilder(vm)
      .withReview()
      .withAttachments()
      .withBaseInfo()
      .build(),
    'reviewForm',
  );

  if (!res.ok) return;
  await api.submit(res.payload);
}

这里发生了几件事:

  • getPayload 仍然负责:
    找到 ref → 校验 → 错误处理

  • 具体 payload 构建过程交给 PayloadBuilder

    • 每个 withXxx() 负责一个独立模块;
    • build() 返回最终对象。

好处是:

  • 可以非常自然地按业务组合:

    • 某些场景只要 .withReview().withAttachments()
    • 另一些场景再 .withBaseInfo().withSomethingElse()
  • 单个 withXxx 内部逻辑变复杂也不怕,不会把一个函数搞成 200 行 if-else

2)什么时候才值得用 Builder 风格?

简单粗暴的判断:

  • 值得上 Builder 的情况

    • payload 真的是**「很多块拼起来」**的;
    • 不同业务场景需要「选择性启用某些块」;
    • 每块内部逻辑都可能变得很复杂(多条件、多分支、甚至异步)。
  • 没必要上 Builder 的情况

    • 只是把 3~5 个字段丢进对象里;
    • 变动很少,大部分字段都是 1:1 映射;
    • 没有复杂的组合逻辑。

换句话说:

Builder 风格的价值,在于把一个复杂构建过程拆成多个可组合的小模块

如果你的业务没有复杂到这个程度,

现在这套 formPayloadMap[refName](vm) + 少量 if,其实已经刚刚好。

3)和前面几级抽象的关系

可以把这几层理解成「渐进增强」:

  • Lv.3 高级前端:

    getPayload(vm, buildPayload, refName)

    • 通用流程 + 策略函数,已经很好用了。
  • Lv.4/架构阶段:

    配一个 formPayloadMap[refName] = buildPayload

    • 把"谁负责构建什么"集中管理。
  • Builder 风格:

    在单个 buildPayload(vm) 的实现内部,如果逻辑变复杂,再引入 PayloadBuilder

    • 是对某一个领域的构建细节做进一步拆分,而不是推翻整个体系。

也就是说,Builder 不是替代前面的抽象,而是为「某个复杂 payload」加的一层"精细化构建工具"。


七、这条路还能怎么走?一些扩展方向思路 🚀

最后顺带聊几个可以继续进化的方向,你可以根据项目实际情况慢慢加,不用一口吃胖子。

1)配合 TypeScript 做强类型约束

当前的写法都是 JS 靠自觉:

  • formPayloadMap 的 key/返回结构完全靠约定;
  • getPayload 的返回 { ok, payload } 也没有类型提示。

如果用 TS,可以做几件事:

  • formPayloadMap 建立一个统一的类型 Map:

    • 比如:type FormPayloadMap = { reviewForm: ReviewPayload; baseForm: BasePayload; ... }
  • getPayload 根据 refName 返回不同的 payload 类型(泛型 + 索引类型);

  • PayloadBuilder 每个 withXxx() 加上返回类型约束,防止漏字段/写错字段名。

好处是:一旦后端改了字段,TS 能第一时间把相关代码全标红,你就不需要靠"全局搜索 + 祈祷"。

2)统一成"多表单聚合"的流程

很多真实页面不是只有一个子表单,而是:

顶层页面 → N 个子块(基础信息、自评、报价、附件......)

最后统一点一个「提交」,要校验所有子块,组合所有 payload。

在现有基础上,可以设计一个"多表单聚合器",伪代码例如:

js 复制代码
async function collectAllPayloads(vm, configList) {
  const allPayload = {};

  for (const cfg of configList) {
    const { refName, mountPoint } = cfg;
    const res = await getPayload(vm, refName);
    if (!res.ok) return { ok: false };

    // mountPoint 决定这块 payload 挂在最终对象的哪里
    allPayload[mountPoint] = res.payload;
  }

  return { ok: true, payload: allPayload };
}

调用时:

js 复制代码
const res = await collectAllPayloads(this, [
  { refName: 'baseForm', mountPoint: 'baseInfo' },
  { refName: 'reviewForm', mountPoint: 'review' },
  { refName: 'priceForm', mountPoint: 'price' },
]);

if (!res.ok) return;
await api.submit(res.payload);

这时:

  • getPayload单个表单的能力
  • collectAllPayloads多表单聚合的能力
  • 再配合 Builder,你就有了一条从"字段级 → 模块级 → 表单级 → 全页级"的构建链路。

3)把"校验 + 构建"做成可插拔中间件

现在,getPayload 里,校验逻辑顺序是写死的:

校验 → 错误提示 → 构建 payload

如果业务越来越复杂,可以考虑做成类似"中间件管线"的模式,比如:

js 复制代码
const pipeline = [
  validateFormStep,
  extraAsyncCheckStep,
  normalizeDataStep,
  buildPayloadStep,
];

runPipeline(vm, refName, pipeline);

每个 step 接收 context(比如 { vm, form, payload }),按顺序处理。

这样你可以:

  • 在某些表单插入额外风控校验;
  • 在某些表单前面加数据归一化逻辑;
  • 保持主流程一致但允许个性化"插片"。

这就已经非常接近「前端领域里自己的 mini-framework」了。

4)跨框架 / 跨项目复用

你现在的设计,其实已经很容易跨栈:

  • ref 概念:React 可以用 useRef + forwardRef 来模拟;
  • validate:绝大多数表单库都有类似 API;
  • payload 构造:和框架无关,本身就是纯函数/Builder。

如果你有多个项目(Vue2/Vue3/React 混合),理论上可以:

  • 把"构建规则"和"校验规则"抽到一个独立 npm 包;
  • 每个项目只写一层很薄的"外壳",适配自己的 ref/组件体系。

这就是从"一个项目里的抽象",升级成"多项目共享的领域包"。


你现在这整套,从最开始那段:

js 复制代码
if (this.reviewContent?.length) {
  const res = this.$refs.reviewForm?.validate();
  if (!res || !res.ok) {
    return this.$message.error(res?.message || '请完善自评信息');
  }
}
const payload = { ... };

一路演进到:

  • 通用 getPayload
  • 配置化 formPayloadMap
  • 按需引入 PayloadBuilder 做复杂构建
  • 再往外是多表单聚合、类型约束、流水线、跨项目复用
相关推荐
LXA08092 小时前
Vue 3中使用JSX
前端·javascript·vue.js
执携2 小时前
Vue Router (历史模式)
前端·javascript·vue.js
MobotStone2 小时前
边际成本趋近于零:如何让AI智能体"说得清、讲得明"
人工智能·架构
summer_west_fish2 小时前
Distributed Architecture: 分布式服务架构演进
架构
依米_2 小时前
一文带你剖析 Promise.then all 实现原理,状态机、发布订阅模式完美实现异步编程
javascript·设计模式
陈陈小白2 小时前
npm run dev报错Error: listen EADDRINUSE: address already in use :::8090
前端·npm·node.js·vue
杂鱼豆腐人2 小时前
pnpm环境下防止误使用npm的方法
前端·git·npm·node.js·git bash
我是ed2 小时前
# vue2 使用 cesium 展示 TLE 星历数据
前端