组件SPI机制(v4)

阅读之前

你应该:

组件SPI简介

不论是母版布局还是DSL,所有定义在模板中的标签都是通过组件SPI机制获取到对应Class Component(ts)并继续执行渲染逻辑。

基本概念:

  • 标签:xml中的标签,json中的dslNodeType属性
  • Token组件:用于收集一组Class Component(ts)的基础组件。通常该基础组件包含了对应的一组基础能力(属性、函数等)
  • 维度(dsl属性):用于从Token组件收集的所有Class Component(ts)组件中查找最佳匹配的参数。

组件SPI机制将通过指定维度按照有权重的最长路径匹配算法获取最佳匹配的组件。

组件注册到指定Token组件

BaseFieldWidget这个SPIToken组件为例,可以用如下方式,注册一个可以被field标签处理的自定义组件:

(以下示例仅仅为了体现SPI注册的维度,而并非实际业务中使用的组件代码)

注册一个String类型组件

维度:

  • 视图类型:表单(FORM)
  • 字段业务类型:String类型

说明:

  • 该字段组件可以在表单(FORM)视图中使用
  • 并且该字段的业务类型是String类型
@SPI.ClassFactory(
  BaseFieldWidget.Token({
    viewType: ViewType.Form,
    ttype: ModelFieldType.String
  })
)
export class FormStringFieldWidget extends BaseFieldWidget {
  ......
}
注册一个多值String类型组件

维度:

  • 视图类型:表单(FORM)
  • 字段业务类型:String类型
  • 是否多值:是

说明:

  • 该字段组件可以在表单(FORM)视图中使用
  • 并且该字段的业务类型是String类型
  • 并且该字段为多值字段
@SPI.ClassFactory(
  BaseFieldWidget.Token({
    viewType: ViewType.Form,
    ttype: ModelFieldType.String,
    multi: true
  })
)
export class FormStringMultiFieldWidget extends BaseFieldWidget {
  ......
}
注册一个String类型Hyperlinks组件

维度:

  • 视图类型:表单(FORM)
  • 字段业务类型:String类型
  • 组件名称:Hyperlinks

说明:

  • 该字段组件仅可以在表单(FORM)视图中使用
  • 并且该字段的业务类型是String类型
  • 并且组件名称必须指定为Hyperlinks
@SPI.ClassFactory(
  BaseFieldWidget.Token({
    viewType: ViewType.Form,
    ttype: ModelFieldType.String,
    widget: 'Hyperlinks'
  })
)
export class FormStringHyperlinksFieldWidget extends BaseFieldWidget {
  ......
}

当上述组件全部按顺序注册在BaseFieldWidget这个SPIToken组件中时,将形成一个以BaseFieldWidget为根节点的树:

image.png

``` mermaid
graph TD
BaseFieldWidget ---> FormStringFieldWidget
BaseFieldWidget ---> FormStringMultiFieldWidget
FormStringFieldWidget ---> FormStringHyperlinksFieldWidget
```

树的构建

上述形成的组件树实际并非真实的存储结构,真实的存储结构是通过维度进行存储的,如下图所示:

(圆角矩形表示维度上的属性和值,矩形表示对应的组件)

image.png

``` mermaid
graph TD
viewType([viewType: ViewType.Form]) --->
ttype([ttype: ModelFieldType.Strng]) --->
multi([multi: true]) & widget([widget: 'Hyperlinks'])

direction LR
ttype ---> FormStringFieldWidget
multi ---> FormStringMultiFieldWidget
widget ---> FormStringHyperlinksFieldWidget
```

有权重的最长路径匹配

同样以上述BaseFieldWidget组件为例,该组件可用的维度有:

  • viewType:ViewType[Enum]
  • ttype:ModelFieldType[Enum]
  • multi:[Boolean]
  • widget:[String]
  • model:[String]
  • viewName:[String]
  • name:[String]

field标签被渲染时,我们会组装一个描述当前获取维度的对象:

{
    "viewType": "FORM",
    "ttype": "STRING",
    "multi": false,
    "widget": "", // 在dsl中定义的任意值
    "model": "", // 在dsl编译后自动填充
    "viewName": "", // 当前视图名称
    "name": "" // 字段的name属性,在dsl编译后自动填充
}

当我们需要使用FormStringHyperlinksFieldWidget这个组件时,我们在dsl中会使用如下方式定义:

<view type="FORM" title="演示表单" name="演示模型form" model="demo.DemoModel">
    <field data="name" widget="Hyperlinks" />
</view>

此时,我们虽然没有在dsl中定义维度中的其他信息,但在dsl返回到前端时,经过了后端编译填充了对应元数据相关属性,我们可以得到如下所示的对象:

{
    "viewType": "FORM",
    "ttype": "STRING",
    "multi": false,
    "widget": "Hyperlinks",
    "model": "demo.DemoModel",
    "viewName": "演示模型form",
    "name": "name"
}

通过上述定义的对象,我们在存储结构中按照指定的维度顺序进行查找,就可以获取到我们需要的组件了。

查找过程简述:

  • 匹配第一层viewType为FORM或包含FORM的节点
  • 匹配第二层ttype为STRING或包含STRING的节点(此时,FormStringFieldWidget被插入到待返回队列首位)
  • 匹配第三层multi为false的节点。(此时,没有任何节点匹配,继续匹配当前层)
  • 匹配第三层widget为Hyperlinks的节点。(此时,FormStringHyperlinksFieldWidget被插入到待返回队列首位)
  • 第四层为空,不再继续向下查找。
  • 返回待返回队列首项。

特殊的默认组件

BasePackWidget为例,平台提供的DefaultGroupWidget组件是这样注册的:

@SPI.ClassFactory(BasePackWidget.Token({}))
export class DefaultGroupWidget extends BasePackWidget {
  ......
}

该组件中不包含任何维度属性,我们无法将它添加到树中的任何一个节点,所以我们称这个组件在BasePackWidget这个SPIToken中为默认组件,任何SPIToken中的默认组件有且仅有一个。

因此,在dsl中,我们可以用如下方式直接使用这个默认组件:

<view type="FORM" title="演示表单" name="演示模型form" model="demo.DemoModel">
    <pack>
        <field data="name" widget="Hyperlinks" />
    </pack>
</view>

通常情况下,我们希望dsl尽可能提供足够清楚的描述,因此,有时也可能看到这样的dsl模板:

<view type="FORM" title="演示表单" name="演示模型form" model="demo.DemoModel">
    <pack widget="group">
        <field data="name" widget="Hyperlinks" />
    </pack>
</view>

由于平台并没有提供widget="group"这个组件,因此,这两个dsl模板的最终执行结果是完全一致的。

精确匹配和模糊匹配

当我们希望一个组件可以在多个视图中共用时,我们通常使用这样的注册方式:

维度:

  • 视图类型:表单(FORM)、搜索(SEARCH)
  • 字段业务类型:String类型

说明:

  • 该字段组件可以在表单(FORM)搜索(SEARCH)视图中使用
  • 并且该字段的业务类型是String类型
@SPI.ClassFactory(
  BaseFieldWidget.Token({
    viewType: [ViewType.Form, ViewType.Search],
    ttype: ModelFieldType.String
  })
)
export class FormStringFieldWidget extends BaseFieldWidget {
  ......
}

这样,我们就可以在多个视图中使用同一个组件。

母版中的标签

母版中的所有标签均使用MaskWidget作为SPIToken组件。
(旧版使用ViewWidget作为SPIToken组件,使用方式与下方描述完全不同,可略过组件注册相关内容)

维度:

  • dslNodeType:xml中的标签
  • widget:组件名称

标签组件概览

为了提供更好的灵活性,平台提供的所有标签组件,均支持classstyle属性,在无法满足业务需求的情况下,可以使用这些特性进行处理。

标签 描述
mask 母版根标签
multi-tabs 多选项卡
header 顶部栏
container 水平布局容器
sidebar 侧边栏
content 主内容区
block 块(div)
breadcrumb 面包屑
widget 母版通用组件

母版通用组件

母版通用组件全部使用widget作为标签,使用widget属性查找对应的组件。

例如:

<widget widget="app-switcher" />
标签 功能
app-switcher 应用切换组件
divider 分割线
notification 用户消息通知组件
language 多语言切换组件
user 用户信息展示组件
nav-menu 导航菜单
main-view 主视图组件;用于渲染布局和DSL等相关内容

默认母板

<mask>
    <multi-tabs />
    <header>
        <widget widget="app-switcher" />
        <block>
            <widget widget="notification" />
            <widget widget="divider" />
            <widget widget="language" />
            <widget widget="divider" />
            <widget widget="user" />
        </block>
    </header>
    <container>
        <sidebar>
            <widget widget="nav-menu" height="100%" />
        </sidebar>
        <content>
            <breadcrumb />
            <block width="100%">
                <widget width="100%" widget="main-view" />
            </block>
        </content>
    </container>
</mask>

母版标签注册

例如mask标签的注册:

@SPI.ClassFactory(MaskWidget.Token({ dslNodeType: 'mask' }))
export class MaskRootWidget extends MaskWidget {
  ......
}

在母版中是这样使用的:

<mask>
  ......
</mask>

例如multi-tabs标签的注册:

@SPI.ClassFactory(MaskWidget.Token({ dslNodeType: 'multi-tabs' }))
export class MultiTabsWidget extends MaskWidget {
  ......
}

在母版中是这样使用的:

<mask>
  <multi-tabs />
  ......
</mask>

通用母版组件的注册

例如widget="main-view"这样的组件注册:

@SPI.ClassFactory(MaskWidget.Token({ widget: 'main-view' }))
export class MainViewWidget extends MaskWidget {
  ......
}

在母版中是这样使用的:

<mask>
  <widget widget="main-view" />
  ......
</mask>

替换母版中的平台内置组件

当使用与平台内置组件注册条件一致的SPIToken进行注册时,将实现内置组件的替换。

以多选项卡(multi-tabs)为例:

@SPI.ClassFactory(MaskWidget.Token({ dslNodeType: 'multi-tabs' }))
export class CustomMultiTabsWidget extends MaskWidget {
  ......
}

标签组件和通用母版组件的区别

使用标签组件时,该组件将完全控制当前组件的渲染逻辑,与框架本身的渲染逻辑是完全一致的。

使用通用模板组件时,该组件将被默认包裹在一个特定div标签下。

下面我们通过示例来了解一下。

先定义一个vue组件,在ts组件中通过不同的注册方式,将获得不同的渲染结果。

CustomMaskHelloWorld.vue
<template>
  <div>hello world !</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'CustomMaskHelloWorld'
});
</script>

使用标签组件

CustomMaskHelloWorldWidget.ts
@SPI.ClassFactory(MaskWidget.Token({ dslNodeType: 'custom-mask-widget' }))
export class CustomMaskHelloWorldWidget extends MaskWidget {
  public initialize(props) {
    super.initialize(props);
    this.setComponent(CustomMaskHelloWorld);
    return this;
  }
}
<mask>
  <custom-mask-widget />
  ......
</mask>
最终渲染结果
<div class="k-layout-mask">
  <div>hello world !</div>
  ......
</div>

使用通用母版组件注册

CustomMaskHelloWorldWidget.ts
@SPI.ClassFactory(MaskWidget.Token({ widget: 'custom-mask-widget' }))
export class CustomMaskHelloWorldWidget extends MaskWidget {
  public initialize(props) {
    super.initialize(props);
    this.setComponent(CustomMaskHelloWorld);
    return this;
  }
}
<mask>
  <widget widget="custom-mask-widget" />
  ......
</mask>
最终渲染结果
<div class="k-layout-mask">
  <div class="k-layout-widget">
    <div>hello world !</div>
  </div>
  ......
</div>

布局中的标签

标签 Token组件 维度(dsl属性) 可选项 描述
view BaseView type ViewType[Enum] 视图标签;主要用于元数据隔离;
pack BasePackWidget viewType
widget
inline
ViewType[Enum]
[String]
[Boolean]
容器类组件标签
element BaseElementWidget viewType
widget
inline
ViewType[Enum]
[String]
[Boolean]
任意元素组件标签

默认布局

平台根据不同的视图类型内置了一些默认布局模板。参考文档

DSL中的标签

标签 Token组件 维度(dsl属性) 可选项 描述
view BaseView type ViewType[Enum] 视图标签;主要用于元数据隔离;
field BaseFieldWidget viewType
ttype
multi
widget
model
viewName
name
ViewType[Enum]
ModelFieldType[Enum]
[Boolean]
[String]
[String]
[String]
[String]
字段元数据标签
action BaseActionWidget viewType
actionType
target
widget
viewName
model
name
ViewType[Enum]
ActionType[Enum]
ViewActionTarget[Enum]
[String]
[String]
[String]
[String]
动作元数据标签
pack BasePackWidget viewType
widget
inline
ViewType[Enum]
[String]
[Boolean]
容器类组件标签
element BaseElementWidget viewType
widget
inline
ViewType[Enum]
[String]
[Boolean]
任意元素组件标签

在上表中,我们可以看到,pack组件element组件的获取维度虽然是类似的,但我们依然将其进行了拆分。原因在于,当pack组件中不包含任何元素或所有元素都隐藏时,我们希望pack组件可以同时隐藏,但element组件则无法确定是否需要这样的特性,因此element组件默认没有进行这样的隐藏处理。

写在最后

在渲染母版布局DSL时,组件SPI机制使得动态组件可以按照设计好的维度进行获取,使得动态渲染可以按照一定的规范进行二次改造。

为了使得动态渲染可以更加灵活,我们提供了自定义标签注册自定义创建SPIToken等功能,开发者们完全可以根据自己的理解设计出一套全新的模板语法,以弥补我们在平台内置方面的不足。

HTML标签、Vue组件,都是按照标签进行简单的获取组件,这在框架层面来说是完全足够的。但美中不足的是,如果全部使用标签形式来设计我们的模板语法,会导致模板语法难以阅读和理解。每个人都需要知道这个标签背后的实现逻辑才可能清楚的理解这段模板所描述的主要内容,再加上开发人员的独特理解风格,最终必然会导致对这段模板语法的解释只能由为数不多的开发人员理解。为了避免这些语法理解上的差异化,使用被设计好的维度来保证每个人的理解是尽可能保持一致的。

例如一个“糟糕”的模板:

<view type="FORM" title="演示表单" name="演示模型form" model="demo.DemoModel">
    <field data="id" invisible="true" />
    <my-widget1>
        <field data="name" />
    </my-widget1>
    <my-widget2>
        <field data="isEnabled" />
    </my-widget2>
</view>

这段模板很难推断my-widget1my-widget2这两个组件所承担的主要功能。

但如果使用这样的模板进行描述:

<view type="FORM" title="演示表单" name="演示模型form" model="demo.DemoModel">
    <field data="id" invisible="true" />
    <pack widget="my-widget1">
        <field data="name" />
    </pack>
    <pack widget="my-widget2">
        <field data="isEnabled" />
    </pack>
</view>

我们虽然同样无法确定这两个组件所承担的具体功能,但pack标签的特性将告诉我们,这两个组件至少是一个容器类的组件,它本身所承担的是描述表单布局相关功能的组件。

不过,显而易见的是,虽然我们提供了一整套标准的模板语法来描述一个页面是如何呈现的,但仍然无法阻止开发人员在实现一个具体功能的组件时,一定要按照设计规范来编码。

因此,我们希望所有的开发者们,可以遵循这套设计规范来定义组件以及实现组件。

Oinone社区 作者:oinone原创文章,如若转载,请注明出处:https://doc.oinone.top/frontend/25.html

访问Oinone官网:https://www.oinone.top获取数式Oinone低代码应用平台体验

(1)
oinone的头像oinone
上一篇 2023年6月20日 pm4:07
下一篇 2023年11月2日 pm1:58

相关推荐

  • OioMessage 全局提示

    全局展示操作反馈信息。 何时使用 可提供成功、警告和错误等反馈信息。 顶部居中显示并自动消失,是一种不打断用户操作的轻量级提示方式。 API 组件提供了一些静态方法,使用方式和参数如下: OioMessage.success(title, options) OioMessage.error(title, options) OioMessage.info(title, options) OioMessage.warning(title, options) options 参数如下: 参数 说明 类型 默认值 版本 duration 默认 3 秒后自动关闭 number 3 class 自定义 CSS class string –

    2023年12月18日
    68600
  • oio-pagination 分页

    API 参数 说明 类型 默认值 版本 currentPage(v-model:currentPage) 当前页数 number – defaultPageSize 默认的每页条数 number 15 disabled 禁用分页 boolean – pageSize 每页条数 number – pageSizeOptions 指定每页可以显示多少条 string[] [’10’, ’15’, ’30’, ’50’, ‘100’, ‘200’] showQuickJumper 是否可以快速跳转至某页 boolean false showSizeChanger 是否展示 pageSize 切换器,当 total 大于 50 时默认为 true boolean – total 数据总数 number 0 事件 事件名称 说明 回调参数 change 页码或 pageSize 改变的回调,参数是改变后的页码及每页条数 Function(page, pageSize) noop

    2023年12月18日
    58700
  • oio-switch 开关

    API 参数 说明 类型 默认值 版本 autofocus 组件自动获取焦点 boolean false checked(v-model: checked ) 指定当前是否选中 checkedValue | unCheckedValue false checkedChildren 选中时的内容 slot checkedValue 选中时的值 boolean | string | number true disabled 是否禁用 boolean false loading 加载中的开关 boolean false unCheckedChildren 非选中时的内容 slot unCheckedValue 非选中时的值 boolean | string | number false 事件 事件名称 说明 回调参数 change 变化时回调函数 Function(checked: boolean | string | number, event: Event)

    2023年12月18日
    57200
  • 自定义的「视图、字段」调用界面设计器配置的按钮(包含权限控制)

    我们在业务开发中,经常会遇到自定义的视图或字段。有时候期望点击某一块区域的时候,打开一个弹窗或者是跳转新页面,但是希望这个动作是界面设计器拖拽进来的。 这篇文章详细的讲解了自定义的视图、字段怎么执行界面设计器拖出来的按钮。 自定义视图 1: 先设计一个页面,把对应的动作拖拽进来,可以不需要配置字段2: 将该页面绑定菜单 3: 自定义对应的页面 当我们自定义视图的时候,首先会注册一个视图,下面是我自定义的一个表单视图 registerLayout( `<view type="FORM"> <element widget="actionBar" slot="actionBar" slotSupport="action"> <xslot name="actions" slotSupport="action" /> </element> <element widget="MyWidget"> </element> </view>`, { moduleName: 'ys0328', model: 'ys0328.k2.Model0000000453', actionName: 'MenuuiMenu78ec23b054314ff5a12b4fe95fe4d7b5', viewType: ViewType.Form } ); 我自定义了一个叫做MyWidget的 element,下面是对应的ts代码 @SPI.ClassFactory(BaseElementWidget.Token({ widget: 'MyWidget' })) export class MyWidgetManageWidget extends BaseElementWidget { public initialize(props: BaseElementWidgetProps): this { super.initialize(props); this.setComponent(MyVue); return this; } } 这是对应的 vue 文件: MyVue.vue <template> <div @click="onClick">点击打开弹窗</div> </template> <script lang="ts"> import { defineComponent } from 'vue'; export default defineComponent({ props: ['onClick'] }); </script> 这个时候,我希望点击的时候,执行 onClick,打开一个弹窗,这时只需要在对应的 ts 文件中写对应的代码逻辑即可: @SPI.ClassFactory(BaseElementWidget.Token({ widget: 'MyWidget' })) export class MyWidgetManageWidget extends BaseElementWidget { // 获取当前页面所有的按钮 @Widget.Reactive() public get modelActions() { return this.metadataRuntimeContext.model.modelActions || [] } // 用来解析上下文表达式的,如果不需要,可以删除 public executeCustomExpression<T>( parameters: Partial<ExpressionRunParam>, expression: string, errorValue?: T ): T | string | undefined { const scene = this.scene; return Expression.run( { activeRecords: parameters.activeRecords, rootRecord: parameters.rootRecord, openerRecord: parameters.openerRecord, scene: parameters.scene || scene, activeRecord: parameters.activeRecord } as ExpressionRunParam, expression, errorValue ); } // 点击事件 @Widget.Method() public onClick() { // 找到对应的按钮 const action = this.modelActions.find((a) => a.label === '点击打开弹窗'); // 执行按钮 executeViewAction(action); /** * 如果跳转动作打开的视图需要通过id去查询数据,可以这样配置 */ // executeViewAction(action, undefined, undefined, {id: 'xxxx'});…

    2023年11月8日
    81801
  • 自定义视图部分区域渲染设计器的配置

    自定义视图与界面设计器配置对接 在日常开发中,我们经常会遇到自定义视图的需求。自定义视图不仅需要与平台机制结合,还要实现与界面设计器中配置的字段和动作的无缝对接。本文将介绍如何将自定义视图与界面设计器中配置的字段和动作的无缝对接,实现字段和动作的渲染。 用大白话来讲就是:当前页面一部分是自定义的,一部分是设计器生成的 代码地址 目录 自定义表单视图与字段、动作的结合 自定义表格视图与字段、动作的结合 自定义表单视图与字段、动作的结合 首先需要在界面设计器配置好对应界面,虽然配置的页面样式跟期望展示的 UI 的不一样,但是数据的分发、汇总以及动作的交互也是一致的,所以我们可以通过自定义的方式替换这个页面的 UI,但是数据以及动作,是完全可以通过平台的能力获取的。 注册表单对应的 SPI // CustomFormWidget.ts import CustomForm from './CustomForm.vue'; @SPI.ClassFactory( BaseElementWidget.Token({ viewType: ViewType.Form, widget: 'CustomFormWidget' }) ) export class CustomFormWidget extends FormWidget { public initialize(props: BaseElementObjectViewWidgetProps): this { super.initialize(props); this.setComponent(CustomForm); return this; } } <!– CustomForm.vue –> <template> <div class="custom-form-container"> <div class="custom-form-tip">自定义视图</div> </div> </template> <script lang="ts"> import { DslRender, DslRenderDefinition, PropRecordHelper } from '@kunlun/dependencies'; import { createVNode, defineComponent, onMounted, PropType, ref, VNode } from 'vue'; export default defineComponent({ inheritAttrs: false, props: { formData: { type: Object as PropType<Record<string, any>>, default: () => ({}) } } }); </script> 在上述的代码中,我们继承的是 FormWidget,那么这个页面会自动发起对应的请求,所有的数据都在 formData 中。 表单视图渲染动作 在最开始我们讲到过,当前页面是在界面设计器配置过,所有在CustomFormWidget里面是可以拿到当前页面配置的元数据信息,所以我们可以拿到界面设计器配置的字段跟动作 /// CustomFormWidget.ts @Widget.Method() protected renderActionVNodes() { // 从dsl中获取actionBar,actionBar里面包含了界面设计器配置的动作 const actionBar = this.metadataRuntimeContext.viewDsl?.widgets.find((w) => w.slot === 'actionBar'); if (actionBar) { // actionBar.widgets 就是界面设计器配置的动作,借助平台提供的DslRender.render方法,将对应的dsl转换成VNode return actionBar.widgets.map((w, index) => DslRender.render({ …w, __index: index }) ); } return null; } 因为 renderActionVNodes 方法返回的是 VNode,所以我们必须借助 vue 的 render 函数才能渲染。 <!– ActionRender.vue –> <script lang="ts"> import { defineComponent } from 'vue'; export default defineComponent({ inheritAttrs: false, props: { renderActionVNodes: { type: Function, required: true } }, render() { return this.renderActionVNodes(); } });…

    2024年9月12日
    1.1K01

Leave a Reply

登录后才能评论