在日常业务开发里,有一类场景出现频率极高:
- 页面里有一个子表单组件(用
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)
逻辑完全按脑子里的流程来:
- 有自评内容吗?(
this.reviewContent?.length) - 有的话就调用子表单的
validate() - 校验没过就弹一条错误消息
- 校验通过,拼一个请求体对象
- 调接口
这个阶段的特点
-
✅ 优点:
- 写起来非常快;
- 读起来也很直接------业务同学都能看懂。
-
❌ 问题:
-
强耦合在当前组件:
- 假设
$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);
}
中级前端的思维变化
- 不再满足于"能跑",开始追求"以后好改一点";
- 能意识到:可以把公共逻辑抽成函数,避免重复粘贴;
- 但抽象粒度通常还是按业务模块划分 :
reviewFormUtils、baseInfoUtils、priceUtils......
封装有了,代码好了不少,但还停留在"每个业务模块一套 "的阶段:
每加一个新表单,又是一组新的 validateXxx + buildXxxPayload。
三、高级前端:从"封装"走向"抽象模式" 🧠
再往前走一步,你会开始问更有意思的问题:
- 这些校验 + payload 的套路,本质上是不是一样的?
- 哪些是"流程",哪些是"策略/细节"?
- 我能不能写一份通用逻辑,让所有表单都用?
1)提炼通用流程:getPayload
观察会发现,每个表单提交流程几乎都是:
- 根据某个
ref拿到子表单组件; - 调用
validate()做校验; - 如果失败 → 提示并中断;
- 如果成功 → 根据当前组件数据构造 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 结构的调整有统一入口。
-
可配置 / 可扩展:
-
新增表单只需要:
- 约定好 ref 名;
- 在
formPayloadMap里加一个构造函数;
-
对核心流程无侵入。
-
-
领域化:
- 不再把子表单当成"某个页面的实现细节",
而是当成领域里的一个"实体":
reviewForm、baseForm、priceForm...... - 每个实体都有"校验 + 构造请求体"的统一接口。
- 不再把子表单当成"某个页面的实现细节",
-
长期演进成本:
-
将来如果:
- 改用新的 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做复杂构建 - 再往外是多表单聚合、类型约束、流水线、跨项目复用