elpis-core 动态组件扩展设计

27 阅读8分钟

1. 设计背景:让 Schema 页面具备业务交互能力

elpis-core 的 Schema 页面从“动态表格 + 动态搜索”继续扩展为可完成增、查、改等业务操作的页面。框架新增了 createFormeditFormdetailPanel 三类动态组件,并用领域模型中的配置决定组件是否出现、展示哪些字段以及如何调用接口。

这套设计要解决的核心问题是:同一类后台页面通常拥有相似的新增抽屉、编辑表单和详情面板,如果每个业务模块都重新编写 Vue 页面,会出现大量重复代码。elpis-core 将稳定的交互流程沉淀为通用组件,将变化的字段和行为放入 DSL,使业务方主要维护模型配置。

当前形成了两级动态扩展机制:

  • 页面级动态组件:createFormeditFormdetailPanel,由 ComponentConfig 注册。
  • 表单项动态组件:inputinputNumberselect,由 FormItemConfig 注册。

它们共享相同的设计思想:模型声明组件名称,注册表把名称映射为 Vue 组件,渲染器使用 <component :is="..."> 在运行时选择实现。

2. 总体架构:配置、投影、渲染与事件回流

动态组件并不是孤立的 Vue 组件,而是领域模型、Schema 投影、组件注册、事件分发和 REST 接口共同组成的运行链路。

flowchart TD
  A[&#34;领域模型<br/>model/buiness/model.js&#34;] --> B[&#34;useSchema<br/>读取并拆分 Schema&#34;]
  B --> C[&#34;components<br/>{ schema, config }&#34;]
  C --> D[&#34;ComponentConfig<br/>页面组件注册表&#34;]
  D --> E[&#34;schema-view<br/>component :is 动态渲染&#34;]
  E --> F[&#34;createForm / editForm / detailPanel&#34;]
  F --> G[&#34;SchemaForm<br/>字段级动态渲染&#34;]
  G --> H[&#34;FormItemConfig<br/>input / inputNumber / select&#34;]
  F --> I[&#34;REST API<br/>POST / GET / PUT&#34;]
  I --> J[&#34;command: loadTableData&#34;]
  J --> K[&#34;刷新 SchemaTable&#34;]

这条链路可以分成四层:

  1. 模型层描述页面能力和字段差异。
  2. useSchema 将一份总 Schema 投影为表格、搜索和各动态组件所需的子 Schema。
  3. 注册表和 Vue 动态组件完成实现选择与渲染。
  4. 组件通过统一命令回传结果,由页面协调表格刷新等后续动作。

3. DSL 设计:一份字段模型生成多种组件视图

动态组件配置位于 schemaConfig.schema 中。componentConfig 声明页面需要哪些业务组件;每个字段的 {comName}Option 声明该字段是否参与对应组件,以及使用什么控件。

下面是当前商品模型的精简版本:

schemaConfig: {
  api: '/api/proj/product',
  schema: {
    type: 'object',
    properties: {
      product_id: {
        type: 'string',
        label: '商品ID',
        editFormOption: {
          comType: 'input',
          disabled: true
        },
        detailPanelOption: {
          comType: 'input',
          disabled: true
        }
      },
      product_name: {
        type: 'string',
        label: '商品名称',
        minLength: 3,
        maxLength: 10,
        createFormOption: {
          comType: 'input',
          default: '测试'
        },
        editFormOption: {
          comType: 'input'
        },
        detailPanelOption: {
          comType: 'input',
          disabled: true
        }
      }
    },
    componentConfig: {
      createForm: {
        title: '新增商品',
        saveBtnText: '确认新增'
      },
      editForm: {
        mainKey: 'product_id',
        title: '编辑商品',
        saveBtnText: '确认修改'
      },
      detailPanel: {
        mainKey: 'product_id',
        title: '商品详情',
        saveBtnText: '收起'
      }
    }
  }
}

这里存在两个互相配合的维度:

  • componentConfig.createForm 决定实例化新增组件,并为它提供标题、按钮文案等组件级配置。
  • product_name.createFormOption 决定商品名称字段进入新增表单,并使用 input 控件。

因此,同一个 product_name 字段可以同时拥有 tableOptionsearchOptioncreateFormOptioneditFormOptiondetailPanelOption。框架不是复制五份字段定义,而是从同一份模型中提取五个面向不同场景的视图。这是动态组件设计与前文 DSL、JSON Schema 思路的连接点。

3.1 Schema 投影

useSchema 中的 buildDtoSchema 根据组件名称查找对应的 Option 字段,并将它统一转换为组件消费的 option

const buildDtoSchema = (_schema, comName) => {
  if (!_schema?.properties) { return {} }

  const dtoSchema = {
    type: 'object',
    properties: {}
  }

  for (const key in _schema.properties) {
    const props = _schema.properties[key]

    if (props[`${comName}Option`]) {
      let dtoProps = {}

      for (const pKey in props) {
        if (pKey.indexOf('Option') < 0) {
          dtoProps[pKey] = props[pKey]
        }
      }

      dtoProps = Object.assign({}, dtoProps, {
        option: props[`${comName}Option`]
      })

      dtoSchema.properties[key] = dtoProps
    }
  }

  return dtoSchema
}

随后,componentConfig 中的每一项都会生成 { schema, config }

const dtoComponents = {}

for (const comName in componentConfig) {
  dtoComponents[comName] = {
    schema: buildDtoSchema(configSchema, comName),
    config: componentConfig[comName]
  }
}

components.value = dtoComponents

createForm 为例,投影结果只保留具有 createFormOption 的字段。字段类型、长度和标签等通用信息被保留,其他场景的 Option 被清除,createFormOption 则被重命名为统一的 option。这样 SchemaForm 不需要知道数据来自新增表单还是编辑表单。

4. 页面级动态组件:注册表与统一调用协议

页面级组件由 component-config.js 集中注册:

import createForm from './create-form/create-form.vue'
import editForm from './edit-form/edit-form.vue'
import detailPanel from './detail-panel/detail-panel.vue'

const ComponentConfig = {
  createForm: { component: createForm },
  editForm: { component: editForm },
  detailPanel: { component: detailPanel }
}

export default ComponentConfig

schema-view.vue 遍历 components,再根据同名键从注册表找到实现:

<component
  v-for="(item, key) in components"
  :key="key"
  :is="ComponentConfig[key]?.component"
  :schema="item.schema"
  :option="item.config"
  ref="comListRef"
  @command="onComponentCommand"
/>

这里形成了一个简单的组件插件机制。DSL 中的 createForm 是稳定标识,注册表控制它对应哪个 Vue 组件。业务模型不需要导入组件文件,组件实现也不需要了解商品、订单等具体领域。

4.1 按钮事件如何打开组件

表格按钮通过 eventKey 描述动作,通过 eventOption.comName 指定目标组件:

{
  label: '编辑',
  eventKey: 'showComponent',
  eventOption: {
    comName: 'editForm'
  },
  type: 'warning'
}

TablePanel 先处理自己能够完成的 remove 事件,无法处理的事件上抛给 SchemaView。页面再通过事件映射调用 showComponent

const EventHandlerMap = {
  showComponent
}

function showComponent({ btnConfig, rowData }) {
  const { comName } = btnConfig.eventOption
  if (!comName) { return }

  const comRef = comListRef.value.find(item => item.name === comName)
  if (!comRef || typeof comRef.show !== 'function') { return }

  comRef.show(rowData)
}

每个动态组件通过 defineExpose 暴露相同的最小协议:

defineExpose({
  name,
  show
})

name 用于定位组件,show(rowData) 用于打开组件。新增按钮没有行数据,编辑和详情按钮会把当前行传入组件。这个协议让页面调度器不需要判断组件的具体类型。

5. 字段级动态组件:SchemaForm 与 AJV 校验

SchemaForm 是新增和编辑组件共用的表单渲染器。它遍历子 Schema 的 properties,根据 option.comTypeFormItemConfig 中选择字段组件:

<component
  v-for="(itemSchema, key) in schema.properties"
  ref="formComList"
  :key="key"
  :is="FormItemConfig[itemSchema.option?.comType].component"
  :schema-key="key"
  :schema="itemSchema"
  :model="model ? model[key] : undefined"
/>

当前注册表支持三种表单控件:

const FormItemConfig = {
  input: { component: input },
  inputNumber: { component: inputNumber },
  select: { component: select }
}

每个字段组件同样遵守统一协议:

  • validate():校验当前字段并展示错误信息。
  • getValue():返回 { [schemaKey]: value }

SchemaForm 聚合所有字段组件的结果:

const validate = () => {
  return formComList.value.every(item => item.validate())
}

const getValue = () => {
  return formComList.value.reduce((dto, component) => ({
    ...dto,
    ...component.getValue()
  }), {})
}

当前字段组件注入 AJV,并使用 typeminLengthmaxLengthminimummaximumpattern 等规则完成前端校验。这样,Schema 不仅决定控件类型,也逐步成为表单约束的单一来源。

5.1 新增、编辑和详情的接口语义

三个页面级组件复用 schemaConfig.api,通过 HTTP 方法区分操作:

动态组件打开时行为保存时行为完成后
createForm使用默认值初始化表单POST /api/proj/product关闭并刷新表格
editFormmainKey 执行 GET /api/proj/productPUT /api/proj/product关闭并刷新表格
detailPanelmainKey 执行 GET /api/proj/product关闭面板无数据变更

新增或编辑成功后,组件不直接操作表格,而是发出命令:

emit('command', {
  event: 'loadTableData'
})

SchemaView 接收命令后调用 tablePanelRef.loadTableData()。这种回流方式保持了组件边界:表单负责提交,页面负责协调,表格负责加载数据。

6. 如何扩展新的动态组件

假设需要增加一个“批量导入商品”组件 importPanel,可以沿用现有机制完成扩展。

第一步,在模型中声明组件级配置:

componentConfig: {
  importPanel: {
    title: '批量导入商品',
    accept: '.xlsx'
  }
}

如果组件需要字段 Schema,再为参与的字段增加 importPanelOption。如果它只上传文件,也可以只读取组件级配置,不创建字段投影。

第二步,实现组件并保持页面级协议:

<script setup>
import { ref } from 'vue'

const emit = defineEmits(['command'])
const name = ref('importPanel')
const visible = ref(false)

function show() {
  visible.value = true
}

function onImported() {
  emit('command', { event: 'loadTableData' })
}

defineExpose({ name, show })
</script>

第三步,将实现加入 ComponentConfig

import importPanel from './import-panel/import-panel.vue'

const ComponentConfig = {
  // 已有组件
  importPanel: { component: importPanel }
}

第四步,在按钮中声明打开动作:

{
  label: '批量导入',
  eventKey: 'showComponent',
  eventOption: {
    comName: 'importPanel'
  }
}

如果只是增加一种表单控件,例如日期选择器,则无需修改页面级注册表。实现字段组件的 validategetValue,将其加入 FormItemConfig,然后在模型里使用 comType: 'datePicker' 即可。

7. 当前实现的边界与演进建议

当前分支已经建立了可工作的扩展骨架,但在继续扩大组件数量前,还需要收紧以下边界。

第一,注册表缺失时应提供明确降级。目前 ComponentConfig[key]?.component 找不到实现时会得到空组件,表单中的 FormItemConfig[itemSchema.option?.comType].component 则可能直接报错。可以增加统一的组件解析函数,在开发环境报告未知的 comNamecomType

第二,动态组件协议可以显式化。现在页面默认组件会暴露 nameshow,字段组件默认暴露 validategetValue。可以用 TypeScript 类型、运行时断言或组件适配器固化协议,减少新增组件时的隐式约定。

第三,需要区分标准 JSON Schema 与 UI 扩展。typeminLengthminimum 等可交给 AJV;labelcreateFormOptioncomType 属于 Elpis DSL。当前 required 放在 properties 内部,并由 buildDtoSchema 特殊读取,与标准 JSON Schema 将 required 放在对象层级的结构不同。后续可调整为标准结构,再由投影逻辑为字段生成 option.required

第四,校验器实例和编译结果可以复用。目前各字段在校验时执行 ajv.compile。Schema 较大或校验频繁时,可以在 Schema 变化时编译一次,并缓存校验函数。

第五,组件配置和接口能力应同步校验。例如 editFormdetailPanel 依赖 mainKey,新增组件依赖 POST 接口。模型加载阶段可以检查必需字段,尽早暴露配置错误,而不是等用户点击按钮后才发现。

第六,注册方式可以逐步改为按需加载。当前组件使用静态 import,会进入页面构建依赖。组件数量增长后,可以将注册项改为 defineAsyncComponent(() => import(...)),使低频组件在使用时加载,同时保留同样的 DSL 名称和组件协议。

elpis-core 的动态组件扩展设计,本质上是将“页面会发生什么”写入模型,再通过两个注册表和两级动态渲染器把声明转换成 Vue 组件。它既保持了领域模型的集中表达,也给框架保留了明确的扩展入口。只要稳定组件标识、公开方法、事件命令和 Schema 投影这四类协议,新增业务能力就能以较小成本接入现有页面链路。