elpis-core 动态组件扩展设计

文章来源声明: 原文作者:flicoH; 来源站点:掘金; 原文链接:https://juejin.cn/post/7686043688787476543; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

把页面交互写进模型、用注册表连接声明与实现,是低代码后台降重的务实做法,适合中后台 CRUD 场景;扩展前建议先固化组件协议与注册表降级策略。

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

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

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

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

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

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

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

动态组件并不是孤立的 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. 组件通过统一命令回传结果,由页面协调表格刷新等后续动作。

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


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

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

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

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

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

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

3.1 Schema 投影

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

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

  <span>const</span> dtoSchema = {
    <span>type</span>: <span>'object'</span>,
    <span>properties</span>: {}
  }

  <span>for</span> (<span>const</span> key <span>in</span> _schema.<span>properties</span>) {
    <span>const</span> props = _schema.<span>properties</span>[key]

    <span>if</span> (props[<span>`<span>${comName}</span>Option`</span>]) {
      <span>let</span> dtoProps = {}

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

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

      dtoSchema.<span>properties</span>[key] = dtoProps
    }
  }

  <span>return</span> dtoSchema
}

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

<span>const</span> dtoComponents = {}

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

components.<span>value</span> = dtoComponents

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

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

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

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

<span>const</span> <span>ComponentConfig</span> = {
  <span>createForm</span>: { <span>component</span>: createForm },
  <span>editForm</span>: { <span>component</span>: editForm },
  <span>detailPanel</span>: { <span>component</span>: detailPanel }
}

<span>export</span> <span>default</span> <span>ComponentConfig</span>

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 指定目标组件:

{
  <span>label</span>: <span>'编辑'</span>,
  <span>eventKey</span>: <span>'showComponent'</span>,
  <span>eventOption</span>: {
    <span>comName</span>: <span>'editForm'</span>
  },
  <span>type</span>: <span>'warning'</span>
}

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

<span>const</span> <span>EventHandlerMap</span> = {
  showComponent
}

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

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

  comRef.<span>show</span>(rowData)
}

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

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

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

  1. 字段级动态组件: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"
/>

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

<span>const</span> <span>FormItemConfig</span> = {
  <span>input</span>: { <span>component</span>: input },
  <span>inputNumber</span>: { <span>component</span>: inputNumber },
  <span>select</span>: { <span>component</span>: select }
}

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

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

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

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

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

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

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

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

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

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

<span>emit</span>(<span>'command'</span>, {
  <span>event</span>: <span>'loadTableData'</span>
})

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

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

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

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

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

如果组件需要字段 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

<span>import</span> importPanel <span>from</span> <span>'./import-panel/import-panel.vue'</span>

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

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

{
  <span>label</span>: <span>'批量导入'</span>,
  <span>eventKey</span>: <span>'showComponent'</span>,
  <span>eventOption</span>: {
    <span>comName</span>: <span>'importPanel'</span>
  }
}

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

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

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

第一,注册表缺失时应提供明确降级。目前 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 投影这四类协议,新增业务能力就能以较小成本接入现有页面链路。