自定义组件之手动渲染基础(v4)

阅读之前

你应该:

为什么需要手动渲染

自定义组件之自动渲染(组件插槽的使用)(v4)文章中,我们介绍了带有具名插槽的组件可以使用DSL模板进行自动化渲染,并且可以用相对简单的方式与元数据进行结合。

虽然自动化渲染在实现基本业务逻辑的情况下,有着良好的表现,但自动化渲染方式也有着不可避免的局限性。

比如:当需要多个视图在同一个位置进行切换

在我们的平台中,界面设计器的设计页面,在任何一个组件在选中后,需要渲染对应的右侧属性面板。每个面板的视图信息是保存在对应的元件中的。根据元件的不同,找到对应的视图进行渲染。在单个视图中使用自动化渲染是无法处理这一问题的,我们需要一种可以局部渲染指定视图的方式,来解决这一问题。

获取一个视图

使用ViewCache获取视图
export class ViewCache {
  /**
   * 通过模型编码和名称获取视图
   * @param model 模型编码
   * @param name 名称
   * @param force 强制查询
   * @return 运行时视图
   */
  public static async get(model: string, name: string, force = false): Promise<RuntimeView | undefined>

  /**
   * 通过模型编码、自定义名称和模板获取编译后的视图(此视图非完整视图,仅用于自定义渲染使用)
   * @param model 模型编码
   * @param name 名称(用作缓存key)
   * @param template 视图模板
   * @param force 强制查询
   * @return 运行时视图
   */
  public static async compile(
    model: string,
    name: string,
    template: string,
    force = false
  ): Promise<RuntimeView | undefined>
}
  • ViewCache#get:用于服务端定义视图,客户端直接获取完整视图信息。
  • ViewCache#compile:用于客户端定义视图,通过服务端编译填充元数据相关信息,但不包含视图其他信息。

自定义一个带有具名插槽的组件,并提供切换视图的相关按钮

以下是一个自定义组件的完整示例,其使用ViewCache#compile方法获取视图。

view.ts
const template1 = `<view>
    <field data="id" invisible="true" />
    <field data="code" label="编码" />
    <field data="name" label="名称" />
</view>`;

const template2 = `<view>
    <field data="id" invisible="true" />
    <field data="name" label="名称" />
    <field data="code" label="编码" />
</view>`;

export const templates = {
  template1,
  template2
};
ManualDemoWidget.ts
import {
  BaseElementWidget,
  createRuntimeContextForWidget,
  FormWidget,
  RuntimeView,
  SPI,
  ViewCache,
  ViewType,
  Widget
} from '@kunlun/dependencies';
import ManualDemo from './ManualDemo.vue';
import { templates } from './view';

@SPI.ClassFactory(BaseElementWidget.Token({ widget: 'ManualDemo' }))
export class ManualDemoWidget extends BaseElementWidget {
  private formWidget: FormWidget | undefined;

  public initialize(props) {
    super.initialize(props);
    this.setComponent(ManualDemo);
    return this;
  }

  @Widget.Method()
  public async resetTemplate(key: string): Promise<void> {
    // 获取运行时视图
    const view = await this.fetchViewByCompile(key);
    if (!view) {
      console.error('Invalid view');
      return;
    }

    // 销毁旧组件
    this.formWidget?.dispose();
    this.formWidget = undefined;

    // 创建运行时上下文
    const runtimeContext = createRuntimeContextForWidget(view);
    const runtimeContextHandle = runtimeContext.handle;

    // 获取初始化数据
    const formData = await runtimeContext.getInitialValue();

    // 提交数据源到当前视图
    this.reloadDataSource(formData);
    this.reloadActiveRecords(formData);

    // 创建所需组件
    this.formWidget = this.createWidget(FormWidget, 'formWidget', {
      metadataHandle: runtimeContextHandle,
      rootHandle: runtimeContextHandle,
      template: runtimeContext.viewTemplate,
      inline: true
    });
  }

  public async fetchViewByCompile(key: string): Promise<RuntimeView | undefined> {
    const template = templates[key];
    if (!template) {
      console.error('error template', key);
      return;
    }

    // 模型编码
    const model = ${model};

    // 通过编译获取视图(非完整视图数据)
    const view = await ViewCache.compile(model, key, template);
    if (!view) {
      return;
    }

    // 补充视图类型
    view.type = ViewType.Form;
    return view;
  }

  protected mounted() {
    // 挂载时初始化指定视图
    this.resetTemplate('template1');
  }
}
ManualDemo.vue
<template>
  <div class="manual-demo-wrapper" v-show="!invisible">
    <oio-button @click="() => switchTemplate('template1')">切换模版1</oio-button>
    <oio-button @click="() => switchTemplate('template2')">切换模版2</oio-button>
    <div class="manual-demo-widget">
      <slot name="formWidget" />
    </div>
  </div>
</template>
<script lang="ts">
import { OioButton } from '@kunlun/vue-ui-antd';
import { defineComponent } from 'vue';

export default defineComponent({
  name: 'ManualDemo',
  components: {
    OioButton
  },
  props: {
    invisible: {
      type: Boolean,
      default: undefined
    },
    resetTemplate: {
      type: Function
    }
  },
  setup(props) {
    const switchTemplate = (key: string) => {
      props.resetTemplate?.(key);
    };

    return {
      switchTemplate
    };
  }
});
</script>
视图DSL
<view type="FORM">
    <template slot="actionBar">
        <action name="create" label="创建" />
    </template>
    <template slot="form" widget="ManualDemo" />
</view>

在上述示例中,我们在view.ts定义了两个用于切换的视图模板,在视图DSL指定widget="ManualDemo"使用我们定义的组件,以此来实现我们的需求。

关键点详解

Class ComponentVue组件之间的交互

@Widget.Method()
public async resetTemplate(key: string): Promise<void> {
  ......
}
export default defineComponent({
  props: {
    resetTemplate: {
      type: Function
    }
  },
  setup(props) {
    const switchTemplate = (key: string) => {
      props.resetTemplate?.(key);
    };

    return {
      switchTemplate
    };
  }
});

使用@Widget.Method()装饰器,将resetTemplate方法通过props传递给Vue组件,通过Vue组件的用户行为(点击按钮)调用所需的执行逻辑。

在这个示例中,我们通过在setup中定义switchTemplate方法,来避免resetTemplate方法可能为造成的一些异常。在最佳实践中,我们通常不建议开发者直接使用props中定义的Function类型。在大多数情况下,在Class Component中定义的方法应该是尽可能抽象的,而不是为了某个Vue组件提供的特殊方法。

手动渲染常用步骤

  1. 获取运行时视图
  2. 销毁旧组件
  3. 创建运行时上下文
  4. 获取初始化数据
    • 模板中定义的初始化数据
    • 调用服务端接口获取
  5. 根据当前场景选择数据源持有者
    • 提交数据源到当前视图
    • 组件持有数据源
  6. 创建所需组件

一般情况下,我们仅需按照上述步骤就可以实现手动渲染功能。

特别的,我们为了保证组件的生命周期完整,并且在运行时不会出现异常挂载/卸载的情况,自动渲染手动渲染是不允许交叉使用的,并且根据组件所属的场景不同,手动渲染自动渲染更关注数据交互相关的内容。

有时可能会遇到数据交互相关的意外

  • 数据源无法共享导致的动作功能异常
  • 数据源未正确提交到所需组件,可能被某个具有数据源的组件拦截或穿透提交到更上层组件。

获取运行时视图

public async fetchViewByCompile(key: string): Promise<RuntimeView | undefined> {
  // 获取视图模板
  const template = templates[key];
  if (!template) {
    console.error('error template', key);
    return;
  }

  // 模型编码
  const model = ${model};

  // 通过编译获取视图(非完整视图数据)
  const view = await ViewCache.compile(model, key, template);
  if (!view) {
    return;
  }

  // 补充视图类型
  view.type = ViewType.Form;
  return view;
}

在这个示例中,我们通过客户端定义视图模板,传递给服务端进行编译得到运行时视图,并手动补充了视图类型。

但通常情况下,我们采用的是通过服务端定义视图模板,并根据上下文中提供的某些信息,动态获取这个视图的模型编码视图名称,并使用ViewCache#get方法获取的。

如下述方法定义:

public fetchView(model: string, name: string): Promise<RuntimeView | undefined> {
  return ViewCache.get(model, name);
}

创建运行时上下文

const runtimeContext = createRuntimeContextForWidget(view);
const runtimeContextHandle = runtimeContext.handle;
createRuntimeContextForWidget定义
/**
 * 为组件创建运行时上下文,一般用于直接创建组件的场景
 * 注:deep属性必须保持一致
 * @param view 运行时视图
 * @param options 创建可选项 默认值: mergeLayout = false, deep = true
 * @return 组件运行时上下文
 */
export function createRuntimeContextForWidget(
  view: RuntimeView,
  options?: { mergeLayout?: boolean; deep?: boolean }
): RuntimeContext

此处的运行时上下文是针对某个具体组件的模板进行创建的,它与其他通过自动化渲染方式获取的上下文有所不同。

一般的,我们将传入的运行时视图称为组件视图,由此生成的运行时上下文称为组件运行时上下文,将这个运行时上下文中获取的viewTemplate称为组件模板

template1为例:

<view>
    <field data="id" invisible="true" />
    <field data="code" label="编码" />
    <field data="name" label="名称" />
</view>

该模板的view标签并非真实渲染了一个视图组件,而是将其作为组件模板根标签,其标签名称在运行时是没有任何含义的。

对于上述创建的FormWidget组件来说,通过this.getDsl()方法获取的DslDefinition对象就是view标签

例如,我们将template1改为:

<view layout="horizontal">
    <field data="id" invisible="true" />
    <field data="code" label="编码" />
    <field data="name" label="名称" />
</view>

FormWidget组件中,通过this.getDsl().layout将获取到horizontal值。

创建所需组件

// 获取初始化数据
const formData = await runtimeContext.getInitialValue();

// 提交数据源到当前视图
this.reloadDataSource(formData);
this.reloadActiveRecords(formData);

// 创建所需组件
this.formWidget = this.createWidget(FormWidget, 'formWidget', {
  metadataHandle: runtimeContextHandle,
  rootHandle: runtimeContextHandle,
  template: runtimeContext.viewTemplate,
  inline: true
});

在示例中,我们将数据源提交到当前视图用于和其他组件共享数据源,并创建了一个平台提供的FormWidget组件。'formWidget'Vue组件中定义的插槽名称。

创建任何组件时,metadataHandlerootHandle template以及inline属性是必须的。

  • metadataHandlerootHandle:用于应用运行时上下文到当前组件。一般情况下,手动渲染组件的这两个值始终是保持一致的。
  • template:用于指定组件使用的组件模板
  • inline:用于决定当前组件是否与浏览器的URL进行交互。

获取组件模板的初始化数据

  • RuntimeContext#getDefaultValue:收集组件模板field标签定义的defaultValue属性,并返回一个对象
  • RuntimeContext#getInitialValue:在RuntimeContext#getDefaultValue方法的基础上,将field标签定义的relatedcompute属性进行了首次计算,并返回一个对象。(目前最新版本与RuntimeContext#getDefaultValue一致,但我们建议开发者使用该方法)

通常情况下,我们也需要调用服务端服务,来获取最终的初始化数据。在这里不做相关介绍。

根据当前场景选择数据源持有者

数据交互相关章节中,我们对数据源持有者有详细介绍,在这里不再赘述。

视图DSL定义中,我们使用ManualDemo组件的方式决定,我们在这里需要将数据源提交到当前视图,用于actionBar组件中定义的动作进行数据源共享。动作执行时的所需的数据源,就是我们这里提交给当前视图的数据源。

一般情况下,手动渲染组件的数据源不再通过组件的自动查询功能进行数据初始化,这又可能会导致数据交互流程变得难以理解。因此我们建议手动渲染组件的数据源总是在创建组件前就提前准备好的。

下面我们介绍如何让组件持有数据源,该方法并不适用于当前场景,但开发者有必要了解相关内容。

组件持有数据源
// 获取初始化数据
const formData = await runtimeContext.getInitialValue();

// 创建所需组件
this.formWidget = this.createWidget(FormWidget, 'formWidget', {
  metadataHandle: runtimeContextHandle,
  rootHandle: runtimeContextHandle,
  dataSource: formData,
  activeRecords: formData,
  template: runtimeContext.viewTemplate,
  inline: true
});

与之前不同的是,我们将dataSourceactiveRecords通过属性直接传递给组件实例即可。

在当前场景中,这样修改后的结果将导致页面中的按钮无法获取数据源,无法点击。

支持手动渲染的组件

在平台提供的所有组件中,并非所有组件都支持手动渲染。包括但不限于如下几个组件:

  • TableWidget:表格组件
  • FormWidget:表单组件
  • DetailWidget:表单组件(详情)

如何让组件支持手动渲染

支持手动渲染的组件与普通组件唯一的不同就是对metadataHandlerootHandle的处理。

Vue框架下,我们提供了mixin混入的方式,让开发者可以更简单的定义一个支持手动渲染的组件。

组件注册、组件定义都无需改变,仅需在对应的Vue组件中添加ManualWidget混入即可。如下所示:

<script lang="ts">
import { ManualWidget } from '@kunlun/dependencies';
import { defineComponent } from 'vue';

export default defineComponent({
  ......
  mixins: [ManualWidget],
  ......
});
</script>

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

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

(0)
数式-海波的头像数式-海波数式管理员
上一篇 2023年6月20日 pm4:07
下一篇 2023年11月2日 pm1:58

相关推荐

  • oio-modal 对话框

    API 参数 说明 类型 默认值 版本 cancelText 取消按钮文字 string| slot 取消 closable 是否显示右上角的关闭按钮 boolean true closeIcon 自定义关闭图标 VNode | slot – confirmLoading 确定按钮 loading boolean 无 destroyOnClose 关闭时销毁 Modal 里的子元素 boolean false footer 底部内容,当不需要默认底部按钮时,可以设为 :footerInvisible="true" slot 确定取消按钮 getTriggerContainer 指定 Modal 挂载的 HTML 节点 (instance): HTMLElement () => document.body keyboard 是否支持键盘 esc 关闭 boolean true mask 是否展示遮罩 boolean true maskClosable 点击蒙层是否允许关闭 boolean true enterText 确认按钮文字 string 确定 title 标题 string|slot 无 visible(v-model:visible) 对话框是否可见 boolean 无 width 宽度 string|number 520 wrapClassName 对话框外层容器的类名 string – zIndex 设置 Modal 的 z-index number 1000 cancelCallback 点击遮罩层或右上角叉或取消按钮的回调, return true则关闭弹窗 function(e) enterCallback 点击确定回调 function(e)

    2023年12月18日
    1.2K00
  • 【前端】IOC容器(v4)

    什么是IOC容器? IOC不是一种技术,只是一种思想,一个重要的面向对象编程的法则,它能指导我们如何设计出松耦合,更优良的程序。传统应用程序都是由我们在类内部主动创建依赖对象,从而导致类与类之间高耦合,难于测试;有了IOC容器后,把创建和查找依赖对象的控制权交给了容器,由容器进行注入组合对象,所以对象与对象之间是松散耦合,这样也方便测试,利于功能复用,更重要的使程序的整个体系结构变得非常灵活。在运行期,在外部容器动态的将依赖对象注入组件,当外部容器启动后,外部容器就会初始化。创建并管理对象实例,以及销毁,这种应用本身不负责依赖对象的创建和维护,依赖对象的创建和维护是由外部容器负责的称为控制反转。 IOC(控制反转)和DI(依赖注入) IOC(Inversion of Control, 控制反转):通过外部容器管理对象实例的一种思想。DI(Dependency Injection, 依赖注入):IOC的一种实现方式。 作者简述 IOC是Spring框架(一种以Java为语言开发的框架)的核心,并贯穿始终。其面向接口的开发能力,使得服务调用方和服务提供方可以做到完全解耦,只要遵循接口定义的规则进行调用,具体服务的实现可以是多样化的。 对于前端,我们使用inversify进行了IOC的实现。其强大的解耦能力可以使得平台进行大量的抽象,而无需关系具体的实现。 接下来,我们将介绍IOC在开发中的基本运用。 API 为了方便起见,我们将IOC相关功能与组件SPI的调用方式放在了一起。(更高版本的平台版本将自动获得该能力) export class SPI { /** * register singleton service */ public static Service; /** * autowired service property/parameter in service */ public static Autowired; /** * service construct after execute method */ public static PostConstruct; /** * autowired service in widget */ public static Instantiate; /** * autowired services in widget */ public static Instantiates; /** * service construct after execute method in widget */ public static InstantiatePostConstruct; } 创建第一个服务 service/ProductService.ts import { ServiceIdentifier } from '@kunlun/dependencies'; /** * 产品 */ export interface Product { id: string; name: string; } /** * 产品服务 */ export interface ProductService { /** * 获取产品列表 */ getProducts(): Promise<Product[]>; /** * 通过ID获取产品 * @param id 产品ID */ getProductById(id: string): Promise<Product | undefined>; } /** * 产品服务Token */ export const ProductServiceToken = ServiceIdentifier<ProductService>('ProductService'); service/impl/ProductServiceImpl.ts import { SPI } from '@kunlun/dependencies'; import { Product, ProductService, ProductServiceToken } from '../ProductService'; @SPI.Service(ProductServiceToken) export class ProductServiceImpl implements ProductService { public async getProducts(): Promise<Product[]> { // request api get products return []; } public async getProductById(id:…

    前端 2023年11月1日
    1.0K00
  • 如何自定义表格字段?

    目录 一、表单字段注册vue组件实现机制 二、表格字段注册vue组件实现机制 三、机制对比分析 四、表格字段完整案例 表单字段注册vue组件实现机制 核心代码 public initialize(props) { super.initialize(props); this.setComponent(VueComponent); return this; } 表格字段注册vue组件实现机制 核心代码 @Widget.Method() public renderDefaultSlot(context: RowContext) { const value = this.compute(context); return [createVNode(CustomTableString, { value })]; } 因为表格有行跟列,每一列都是一个单独的字段(对应的是TS文件),但是每列里面的单元格承载的是Vue组件,所以通过这种方式可以实现表格每个字段对应的TS文件是同一份,而单元格的组件入口是通过renderDefaultSlot函数动态渲染的vue组件,只需要通过createVNode创建对应的vue组件,然后将props传递进去就行 上下文接口 interface RowContext<T = unknown> { /** * 当前唯一键, 默认使用__draftId, 若不存在时,使用第三方组件内置唯一键(如VxeTable使用{@link VXE_TABLE_X_ID}) */ key: string; /** * 当前行数据 */ data: Record<string, unknown>; /** * 当前行索引 */ index: number; /** * 第三方组件原始上下文 */ origin: T; } 机制对比分析 表单字段 vs 表格字段渲染机制对比表 对比维度 表单字段实现方案 表格字段实现方案 绑定时机 initialize 阶段静态绑定 renderDefaultSlot 阶段动态创建 组件声明方式 this.setComponent(Component) createVNode(Component, props) 上下文传递 通过类成员变量访问 显式接收 RowContext 参数 渲染控制粒度 字段级(表单控件) 单元格级 表格字段完整案例 import { SPI, ViewType, BaseFieldWidget, Widget, TableNumberWidget, ModelFieldType, RowContext } from '@kunlun/dependencies'; import CustomTableString from './CustomTableString.vue'; import { createVNode } from 'vue'; @SPI.ClassFactory( BaseFieldWidget.Token({ ttype: ModelFieldType.String, viewType: [ViewType.Table], widget: 'CustomTableStringWidget' }) ) export class CustomTableStringWidget extends BaseTableFieldWidget { @Widget.Method() public renderDefaultSlot(context:RowContext) { const value = this.compute(context); // 当前字段的值 const rowData = context.data // 当前行的数据 const dataSource = this.dataSource // 表格数据 if (value) { // 自定义组件入口在此处 return [createVNode(CustomTableString, { value })]; } return []; } } <template> <div>当前值: {{value}}</div> </template> <script lang="ts"> import { defineComponent } from 'vue'…

    2023年11月6日
    1.5K00
  • 页面出现中文乱码,该怎么解决?

    可能性1: 后端读取视图的xml解析时,由于系统缺少中文字体,导致解析后出现乱码,这种问题常见于采用docker镜像部署的情况,很多基础镜像不带中文字体。 解决方案:在物理系统或者docker镜像内安装中文字体 可能性2: win环境下未指定文件的编码类型 解决方案: 启动命令中加上-Dfile.encoding=UTF-8参数 # 示例命令 java -jar -Dfile.encoding=UTF-8 pamirs-demo-boot-1.0.0-SNAPSHOT.jar -Plifecycle=INSTALL

    2023年11月1日
    1.1K00
  • 左树右表默认选择第一行

    import { BaseElementWidget, Widget, SPI, ViewType, TableSearchTreeWidget } from '@kunlun/dependencies'; @SPI.ClassFactory( BaseElementWidget.Token({ viewType: ViewType.Table, widget: 'tree', model: '改成当前视图的模型' }) ) export class CustomTableSearchTreeWidget extends TableSearchTreeWidget { protected hasExe = false; @Widget.Watch('rootNode.children.length') protected watchRootNode(len) { if (len && !this.hasExe) { this.hasExe = true; const firstChild = this.rootNode?.children?.[0]; if (firstChild) { this.onNodeSelected(firstChild); this.selectedKeys = [firstChild.key]; } } } }

    2024年11月26日
    1.2K00

Leave a Reply

登录后才能评论