2. 表单引擎架构设计
好,咱们进入第二章。这一章我打算聊聊表单引擎的骨架——架构设计。
说实话,我见过太多团队一上来就写代码,结果做到一半发现扩展性极差,最后不得不推倒重来。我自己就踩过这个坑。所以今天咱们把架构先理清楚,后面写代码才不慌。
2.1 整体架构:三层一总线
我个人习惯把表单引擎拆成三层,再加一条总线。为什么这么分?你想想看,表单的本质是什么?是数据的采集、校验和提交。这三件事天然就对应三个层次。
| 层级 | 名称 | 核心职责 |
|---|---|---|
| 展示层 | UI Renderer | 负责组件的渲染、布局、样式 |
| 逻辑层 | Form Controller | 负责数据绑定、校验、联动、状态管理 |
| 数据层 | Data Provider | 负责与后端交互、缓存、持久化 |
| 总线 | Event Bus | 负责三层之间的通信与解耦 |
嗯,这里要注意:总线不是必须的。如果你的表单很简单,比如就三五个字段,那直接函数调用就行。但一旦表单复杂起来——比如有几十个字段、跨组件联动、异步校验——没有总线,代码会乱成一锅粥。
核心原则:展示层只关心「怎么画」,逻辑层只关心「怎么算」,数据层只关心「怎么存」。谁也别越界。
2.2 核心模块划分
基于上面的三层架构,我一般会划分出以下几个核心模块。每个模块都是独立可替换的,这也是「自定义化」的基础。
2.2.1 组件注册中心
这个模块说白了就是一个「组件仓库」。你定义好接口规范,然后所有自定义组件都往这里注册。我在项目中遇到过一种情况:业务方突然说要加一个「地图选点」组件,如果没有注册中心,就得改核心代码。有了它,只需要写一个新组件,注册进去就行。
// 组件注册中心示例
const ComponentRegistry = {
components: new Map(),
register(name, component) {
this.components.set(name, component);
},
get(name) {
return this.components.get(name);
}
};
// 注册一个自定义组件
ComponentRegistry.register('map-picker', {
render: (config) => `<div id="map-${config.id}"></div>`,
validate: (value) => value.length > 0,
defaultValue: null
});
2.2.2 表单描述器
这是整个引擎的「配置文件」。它用 JSON 描述表单长什么样、每个字段怎么校验、字段之间怎么联动。我建议把描述器设计成树形结构,因为表单天然就是嵌套的——分组、子表单、表格,都是树。
// 表单描述器示例
{
"formId": "leave-apply",
"fields": [
{
"name": "leaveType",
"type": "select",
"label": "请假类型",
"options": ["事假", "病假", "年假"],
"validators": ["required"]
},
{
"name": "startDate",
"type": "date",
"label": "开始日期",
"visible": {
"when": "leaveType",
"is": "病假"
}
}
]
}
小技巧:描述器里的「visible」字段就是联动的基础。我曾经用这种方式实现过五层嵌套联动,代码依然清晰可读。
2.2.3 校验引擎
校验这块,我吃过不少亏。一开始我写死了校验规则,结果业务方今天要「手机号格式校验」,明天要「身份证校验」,后天要「自定义正则」。后来我学乖了——校验引擎必须支持插件化。
// 校验引擎核心逻辑
class ValidatorEngine {
constructor() {
this.rules = new Map();
}
addRule(name, handler) {
this.rules.set(name, handler);
}
validate(field, value) {
const validators = field.validators || [];
for (let v of validators) {
const handler = this.rules.get(v);
if (handler && !handler(value)) {
return { valid: false, message: `${field.label} 校验失败` };
}
}
return { valid: true };
}
}
// 添加自定义规则
validator.addRule('phone', (v) => /^1[3-9]\d{9}$/.test(v));
2.2.4 联动管理器
联动是表单引擎里最头疼的部分。为什么?因为联动关系可能是「A改变 -> B隐藏 -> C显示 -> D必填」,这种链式反应很容易写出面条代码。
我的做法是:把所有联动关系定义成「条件-动作」对。条件满足就触发动作,动作可以是显示/隐藏、启用/禁用、赋值/清空。这样逻辑就线性了。
避坑指南:我曾经在联动里写过递归调用,结果页面直接卡死。后来我加了一个「联动深度限制」,默认不超过10层。你最好也加上。
2.3 数据流设计
数据流说白了就是「数据怎么在组件之间跑」。我推荐单向数据流,原因很简单——好调试。数据从描述器来,经过逻辑层处理,到展示层渲染,用户操作后再反向流回去。
// 单向数据流示意
// 1. 初始化:描述器 -> 逻辑层 -> 展示层
const formData = FormController.init(formDescriptor);
UI.render(formData);
// 2. 用户操作:展示层 -> 逻辑层 -> 数据层
UI.onChange('leaveType', (value) => {
const newData = FormController.update('leaveType', value);
DataProvider.save(newData);
UI.render(newData);
});
你可能会问:为什么不直接用双向绑定?嗯,双向绑定在小表单里确实方便,但一旦表单复杂了,你根本不知道数据是谁改的。单向数据流虽然代码多写几行,但每个数据变化都有迹可循。
2.4 组件交互设计
组件之间的交互,我总结下来就三种模式:
- 父子通信:父组件通过 props 传数据给子组件,子组件通过回调通知父组件。这是最基础的方式。
- 兄弟通信:通过共同的父组件中转。比如「请假类型」变了,「开始日期」要跟着变,那就让父组件来协调。
- 跨层级通信:通过 Event Bus。比如一个弹窗里的表单字段要影响主表单的字段,这时候用总线最合适。
我的建议:能用父子通信就别用总线。总线虽然方便,但用多了代码会变成「蜘蛛网」,调试起来想哭。
2.5 小结
这一章咱们把表单引擎的骨架搭起来了。三层一总线的架构、四个核心模块、单向数据流、三种交互模式——这些就是后面所有自定义化功能的基础。
下一章我会讲具体的组件开发,包括怎么设计一个可扩展的组件接口。到时候咱们拿代码说话。