Mint 自研框架设计与实现:从重复开发走向配置驱动(四)

4 阅读11分钟

DSL 配置设计

前文介绍了 Mint DSL 的基础模型:DSL 分为领域模型和项目模型,两者的关系类似面向对象中的基类与子类。

领域模型负责沉淀某个垂直领域的通用配置,项目模型则继承领域模型,并根据具体项目的需求进行扩展或覆盖。

本章暂不展开继承与合并算法,而是先回答一个更基础的问题:

一份 DSL 配置,应该如何完整描述一个中后台站点?

从站点结构拆解配置层级

在设计配置之前,先观察一个常见的中后台系统由哪些部分组成。

页面整体通常可以分为两个区域:

  • 顶部导航区域;
  • 主体内容区域。

顶部导航区域一般包含:

  • Logo;
  • 站点名称;
  • 一级菜单;
  • 用户信息及操作入口。

主体内容区域则可能包含:

  • 左侧菜单栏;
  • 框架内置的标准管理页面;
  • 业务自行开发的自定义页面;
  • 通过 iframe 接入的第三方页面。

按照这个结构,一份站点 DSL 可以拆分成下面几个层级:

站点配置
├── 站点基本信息
│   ├── 名称
│   ├── Logo
│   └── 默认首页
└── 菜单配置
    ├── 菜单分组
    │   └── 子菜单
    └── 页面模块
        ├── 左侧菜单模块
        ├── 自定义页面
        ├── Schema 标准页面
        └── iframe 页面

基于这个结构,可以先得到一份基础配置:

module.exports = {
  // 中后台名称
  name: "",

  // 中后台 Logo
  icon: "",

  // 默认首页
  homePage: "",

  // 顶部菜单
  menu: [
    {
      // 菜单名称
      label: "",

      // 菜单唯一标识
      key: "",

      // 菜单类型:module | group
      menuType: "",

      // menuType 为 group 时配置子菜单
      subMenu: [],

      /**
       * 页面模块类型:
       *
       * sider  展示带有左侧菜单的业务模块
       * custom 跳转到业务自行开发的页面
       * schema 使用框架内置的标准管理页面
       * iframe 嵌入外部页面
       */
      moduleType: "",

      // 左侧菜单模块配置
      siderConfig: {
        menu: [],
      },

      // 自定义页面配置
      customConfig: {
        path: "",
      },

      // Schema 标准页面配置
      schemaConfig: {},

      // iframe 页面配置
      iframeConfig: {
        path: "",
      },
    },
  ],
};

这份配置描述了站点的整体骨架,但还没有深入到具体页面。

其中,customiframe 的配置相对简单:

  • custom 指向业务项目内部自行开发的路由页面;
  • iframe 指向需要嵌入的外部页面。

真正需要重点设计的是 schema 模块,因为它承载了 Mint 配置驱动页面的核心能力。

Schema 页面需要描述哪些内容

Schema 页面是框架内置的标准管理页面,主要用于承接中后台中大量重复出现的增删改查场景。

Mint 希望通过 Schema 页面覆盖大约 80% 的通用需求,同时为剩余 20% 的个性化场景提供扩展能力。

以商品管理页面为例,一个典型的管理页面通常包含:

  • 搜索区域;
  • 表格区域;
  • 表格顶部操作区域;
  • 表格行操作区域;
  • 弹窗、抽屉等扩展组件。

因此,schemaConfig 可以先拆分为三个部分:

{
  schemaConfig: {
    // 搜索区域布局与按钮
    searchConfig: {
      extendButtons: [],
    },

    // 表格区域及操作按钮
    tableConfig: {
      headerButtons: [],
      rowButtons: [],
    },

    // 页面扩展组件
    componentConfig: {},
  },
}

三者的职责分别是:

  • searchConfig:描述搜索区域的整体行为;
  • tableConfig:描述表格及其顶部、行内操作;
  • componentConfig:描述弹窗、抽屉等可被事件调度的组件。

不过,这份配置目前只描述了页面布局,还没有回答一个关键问题:

搜索项和表格列从哪里来?

要回答这个问题,需要从页面的数据结构出发继续抽象。

从页面布局抽象出统一数据模型

搜索和表格看起来是两个独立区域,但它们实际上都在围绕同一份业务数据工作。

例如,商品管理页面可能包含商品名称、商品状态、商品价格、商品图片和创建时间等字段。其中,“商品名称”可能同时出现在搜索区域和表格中;“商品状态”可能在搜索区域展示为下拉框,在表格中展示为状态文本。

因此,与其分别维护搜索字段和表格字段,不如先集中定义一份页面数据模型,再描述每个字段在不同场景中的展示方式。

Mint 使用 JSON Schema 描述页面的数据结构:

{
  schemaConfig: {
    // 页面数据请求接口
    api: "",

    // 页面数据模型
    schema: {
      type: "object",

      properties: {
        fieldKey: {
          // 字段数据类型
          type: "",

          // 字段展示名称
          label: "",

          // 字段在搜索区域中的配置
          searchOption: {},

          // 字段在表格区域中的配置
          tableOption: {},

          // 其他应用场景的扩展配置
          xxxOption: {},
        },
      },

      // 必填字段
      required: [],
    },
  },
}

这里需要区分两个概念:

  • schemaConfig 是 Mint 中一个完整标准页面的配置;
  • schemaConfig.schema 是页面内部的数据模型,结构遵循 JSON Schema。

JSON Schema 不仅可以描述字段类型和必填规则,还可以配合 AJV 完成运行时数据校验。

在 JSON Schema 的基础上,Mint 为每个字段增加了不同场景的 Option 配置:

字段定义
├── type
├── label
├── searchOption
├── tableOption
└── xxxOption

同一个字段可以在不同区域采用不同的展示方式:

  • 在搜索区域中展示为输入框或下拉框;
  • 在表格中展示为文本、图片或者格式化后的数值;
  • 在编辑弹窗中展示为地图、选择器或其他复杂组件;
  • 在未来新增的业务场景中,通过新的 xxxOption 继续扩展。

这样,字段的数据定义只需要维护一次,各个页面区域只负责描述如何使用该字段。

搜索字段配置

搜索区域本质上是一个动态表单。

框架负责实现表单容器、布局、提交和重置等通用能力,DSL 只需要描述每个字段应该使用什么控件,以及控件需要哪些参数。

searchOption 的基础结构如下:

searchOption: {
  /**
   * 透传给 UI 组件的属性
   *
   * 例如:
   * placeholder、allowClear、disabled 等
   */
  uiComponentConfig: {},

  /**
   * 搜索控件类型:
   *
   * input            普通输入框
   * select           静态下拉框
   * dynamicSelect    动态下拉框
   * dateRangePicker  日期范围选择器
   */
  comType: "",

  /**
   * 静态枚举数据
   *
   * comType 为 select 时使用
   */
  enumList: [],

  /**
   * 是否在搜索区域显示
   *
   * 默认显示,只有显式配置为 false 时才隐藏
   */
  visible: true,

  // 搜索字段默认值
  default: "",

  /**
   * 动态选项请求地址
   *
   * comType 为 dynamicSelect 时使用
   */
  api: "",
}

例如,商品名称可以配置成输入框:

name: {
  type: "string",
  label: "商品名称",

  searchOption: {
    comType: "input",
    visible: true,
    default: "",
    uiComponentConfig: {
      placeholder: "请输入商品名称",
      allowClear: true,
    },
  },
}

商品状态可以配置成静态下拉框:

status: {
  type: "string",
  label: "商品状态",

  searchOption: {
    comType: "select",
    visible: true,
    enumList: [
      {
        label: "上架",
        value: "online",
      },
      {
        label: "下架",
        value: "offline",
      },
    ],
    uiComponentConfig: {
      placeholder: "请选择商品状态",
      allowClear: true,
    },
  },
}

如果下拉选项需要从服务端获取,则可以使用动态下拉框:

categoryId: {
  type: "string",
  label: "商品分类",

  searchOption: {
    comType: "dynamicSelect",
    visible: true,
    api: "/api/category/list",
    uiComponentConfig: {
      placeholder: "请选择商品分类",
      allowClear: true,
    },
  },
}

为什么需要 visible

visible 不只是一个控制显示和隐藏的开关,它还与 DSL 的继承机制有关。

假设领域模型默认展示商品状态:

searchOption: {
  visible: true,
}

某个项目继承领域模型后,可以通过下面的配置隐藏该字段:

searchOption: {
  visible: false,
}

如果完全删除 searchOption,合并配置时很难判断项目模型是希望继承,还是希望移除该搜索项。

因此,Mint 约定:

  • 未配置 visible 时默认显示;
  • 只有显式配置为 false 时才隐藏。

这使项目模型能够明确覆盖领域模型中的展示行为。

表格字段配置

同一个字段在表格中如何展示,由 tableOption 描述:

tableOption: {
  /**
   * 透传给 UI 表格列的属性
   *
   * 例如:
   * width、fixed、align、ellipsis 等
   */
  uiTableColumnConfig: {},

  /**
   * 是否在表格中显示
   *
   * 默认显示,只有显式配置为 false 时隐藏
   */
  visible: true,

  // 数值字段保留的小数位数
  toFixed: 2,

  /**
   * 翻译字段
   *
   * 可以使用另一个字段的值作为展示内容
   */
  translateField: "",

  /**
   * 字段展示方式
   *
   * image 表示按照图片渲染
   * 未配置时直接展示原始数据
   */
  showWay: "",
}

例如,商品价格可以配置小数位数:

price: {
  type: "number",
  label: "商品价格",

  tableOption: {
    visible: true,
    toFixed: 2,
    uiTableColumnConfig: {
      width: 120,
      align: "right",
    },
  },
}

商品图片可以指定为图片展示:

image: {
  type: "string",
  label: "商品图片",

  tableOption: {
    visible: true,
    showWay: "image",
    uiTableColumnConfig: {
      width: 100,
    },
  },
}

如果接口返回的是状态码,同时还提供了状态名称,可以通过 translateField 指定实际展示字段:

status: {
  type: "string",
  label: "商品状态",

  tableOption: {
    visible: true,
    translateField: "statusText",
  },
}

这样,同一个字段的数据定义、搜索方式和表格展示方式便被组织到了一起。

页面级布局配置

字段的 searchOptiontableOption 描述“字段如何展示”,而 searchConfigtableConfig 描述“页面区域如何组织”。

两者的职责并不相同:

schema.properties
└── 描述每个字段在不同场景中的表现

searchConfig / tableConfig
└── 描述搜索区和表格区的整体结构与操作

页面级配置如下:

{
  searchConfig: {
    // 搜索区域的扩展按钮
    extendButtons: [],
  },

  tableConfig: {
    // 表格顶部按钮
    headerButtons: [],

    // 表格行操作按钮
    rowButtons: [],
  },
}

例如,“新增商品”通常属于表格顶部操作,可以放在 headerButtons 中;“查看”“编辑”“删除”等针对单条数据的操作,则放在 rowButtons 中。

扩展组件配置

标准搜索和表格可以覆盖大部分管理页面,但一些业务操作仍然需要弹窗、抽屉或其他复杂组件。

因此,Mint 提供了 componentConfig,用于声明页面可以调度的扩展组件。

例如,定义一个商品编辑弹窗:

componentConfig: {
  editFormModal: {
    // 弹窗标题
    title: "编辑商品",

    // 是否展示底部操作区域
    showFooter: true,

    // 确认按钮文案
    okText: "确认",

    // 取消按钮文案
    cancelText: "取消",

    // 弹窗宽度
    width: "600px",

    /**
     * 组件向外抛出的事件标识
     *
     * 模板解析器可以根据该标识
     * 继续执行刷新列表等后续操作
     */
    eventKey: "refreshList",
  },
}

这里的 editFormModal 是组件在当前页面中的唯一名称。

DSL 只描述组件实例需要的配置,不关心组件具体如何实现。组件的注册、查找和渲染,将由后续的模板解析器与组件调度机制负责。

按钮与事件配置

组件声明完成后,还需要通过按钮触发相应行为。

Mint 不在 DSL 中直接编写 JavaScript 函数,而是使用 eventKey 描述按钮需要触发的事件,再通过 eventOption 提供事件参数。

例如,在表格行操作区配置“查看”按钮:

tableConfig: {
  rowButtons: [
    {
      // 按钮文案
      label: "查看",

      // 触发框架内置的组件展示事件
      eventKey: "showComponent",

      // 事件参数
      eventOption: {
        // 对应 componentConfig 中的组件名称
        comName: "editFormModal",
      },

      // 透传给 Button 组件的属性
      uiButtonConfig: {},
    },
  ],
}

当用户点击“查看”按钮时,模板解析器会按照下面的顺序处理:

用户点击“查看”
        ↓
读取按钮的 eventKey
        ↓
找到 showComponent 事件处理器
        ↓
读取 eventOption.comName
        ↓
在 componentConfig 中找到 editFormModal
        ↓
渲染并打开对应组件

对于删除等数据操作,可以在 eventOption 中声明请求参数:

tableConfig: {
  rowButtons: [
    {
      label: "删除",
      eventKey: "remove",

      eventOption: {
        params: {
          id: "schema::id",
          source: "product-management",
        },
      },

      uiButtonConfig: {
        danger: true,
      },
    },
  ],
}

其中,Mint 可以约定两类参数值:

  • "schema::id":从当前行数据中读取 id 字段;
  • "product-management":直接使用配置中的固定值。

以当前行数据为例:

{
  id: 1001,
  name: "示例商品",
}

下面的参数配置:

params: {
  id: "schema::id",
  source: "product-management",
}

经过模板解析器处理后,会得到:

{
  id: 1001,
  source: "product-management",
}

通过这种引用规则,DSL 可以声明事件参数与当前行数据之间的映射关系,而不需要在配置中编写具体的取值函数。

完整 DSL 结构

经过前面的逐层拆解,一份完整的站点 DSL 可以整理为下面的结构:

module.exports = {
  // 中后台基本信息
  name: "",
  icon: "",
  homePage: "",

  // 顶部菜单
  menu: [
    {
      label: "",
      key: "",

      /**
       * module 页面模块
       * group  菜单分组
       */
      menuType: "module",

      // menuType 为 group 时使用
      subMenu: [],

      /**
       * sider  左侧菜单模块
       * custom 业务自定义页面
       * schema 框架标准页面
       * iframe 外部页面
       */
      moduleType: "schema",

      siderConfig: {
        menu: [],
      },

      customConfig: {
        path: "",
      },

      schemaConfig: {
        // 页面数据接口
        api: "",

        // 页面数据模型
        schema: {
          type: "object",

          properties: {
            fieldKey: {
              label: "",
              type: "",

              // 搜索区域中的字段配置
              searchOption: {
                uiComponentConfig: {},
                comType: "",
                enumList: [],
                visible: true,
                default: "",
                api: "",
              },

              // 表格区域中的字段配置
              tableOption: {
                uiTableColumnConfig: {},
                visible: true,
                toFixed: 2,
                translateField: "",
                showWay: "",
              },

              // 其他业务场景的扩展配置
              xxxOption: {},
            },
          },

          required: [],
        },

        // 搜索区域整体配置
        searchConfig: {
          extendButtons: [],
        },

        // 表格区域整体配置
        tableConfig: {
          headerButtons: [],

          rowButtons: [
            {
              label: "",
              eventKey: "",

              eventOption: {
                comName: "",

                params: {
                  fieldKey: "schema::fieldKey",
                  fixedValue: "",
                },
              },

              uiButtonConfig: {},
            },
          ],
        },

        // 页面扩展组件
        componentConfig: {
          componentName: {
            title: "",
            showFooter: true,
            okText: "确认",
            cancelText: "取消",
            width: "600px",
            eventKey: "",
          },
        },
      },

      iframeConfig: {
        path: "",
      },
    },
  ],
};

本章小结

Mint DSL 的设计并不是简单地把页面代码转换成配置,而是从中后台页面的共性出发,逐层抽象出:

  • 站点;
  • 菜单;
  • 页面模块;
  • 数据模型;
  • 字段展示方式;
  • 页面操作;
  • 事件行为;
  • 扩展组件。

其中,JSON Schema 负责描述数据本身,searchOptiontableOption 负责描述字段在不同区域中的展示方式,searchConfigtableConfig 负责描述页面级布局与操作,componentConfig 则负责承接标准页面之外的扩展能力。

领域模型可以沉淀这些通用配置,项目模型则通过继承和覆盖调整具体行为。

下一章节将继续介绍模板解析器如何读取这份 DSL,并将配置真正转换成可以交互的中后台页面。