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 组件交互设计

组件之间的交互,我总结下来就三种模式:

  1. 父子通信:父组件通过 props 传数据给子组件,子组件通过回调通知父组件。这是最基础的方式。
  2. 兄弟通信:通过共同的父组件中转。比如「请假类型」变了,「开始日期」要跟着变,那就让父组件来协调。
  3. 跨层级通信:通过 Event Bus。比如一个弹窗里的表单字段要影响主表单的字段,这时候用总线最合适。

我的建议:能用父子通信就别用总线。总线虽然方便,但用多了代码会变成「蜘蛛网」,调试起来想哭。

2.5 小结

这一章咱们把表单引擎的骨架搭起来了。三层一总线的架构、四个核心模块、单向数据流、三种交互模式——这些就是后面所有自定义化功能的基础。

下一章我会讲具体的组件开发,包括怎么设计一个可扩展的组件接口。到时候咱们拿代码说话。