前端 SPI 注册 + 渲染

在阅读本篇文章之前,您需要学习以下知识点:

1: TS 结合 Vue 实现动态注册和响应式管理

前端开发者在使用 oinone 平台的时候会发现,不管是自定义字段还是视图,对应的 typescript 都会用到@SPI.ClassFactory(参数),然后在对用的class中重写initialize方法`:

@SPI.ClassFactory(参数)
export class CustomClass extends xxx {
  public initialize(props) {
    super.initialize(props);
    this.setComponent(FormString);
    return this;
  }
}

本文将带您熟悉 oinone 前端的 SPI 注册机制以及 TS + Vue 的渲染过程。

不管是自定义字段还是视图@SPI.ClassFactory(参数)都是固定写法,区别在于参数不同,这篇文章里面详细描述了参数的定义

SPI 注册机制

有自定义过字段、视图经验的开发者可能会发现,字段(表单字段)SPI 注册用的是FormFieldWidget.Token生成对应的参数,视图 SPI 注册用的是BaseElementWidget.Token,那么为什么要这样定义呢?

大家可以想象成现在有一个大的房子,房子里面有很多房间,每个房间都有自己的名字,比如FormFieldWidget就是房间的名字,BaseElementWidget也是房间的名字,这样一来我们就可以根据不同的房间存放不同的东西。

下面给大家展示下伪代码实现:


class SPI {
  static container = new Map<string, WeakMap<object, object>>()

  static ClassFactory(token) {
    return (target) => {
      if(!SPI.container.get(token.type)) {
        SPI.container.set(token.type, new WeakMap())
      }

      const services = SPI.container.get(token.type)
      services?.set(token, target)
    }
  }
}

class FormFieldWidget {
  static Token(options) {
    return {
      ...options,
      type: 'Field'
    }
  }

  static Selector(options) {
   const fieldWidgets =  SPI.container.get('Field')

   if(fieldWidgets) {
    return fieldWidgets.get(options)!
   }

   return null
  }
}

@SPI.ClassFactory(FormFieldWidget.Token({
  viewType: 'Form',
  ttype: 'String',
  widget: 'Input'
}))
class StringWidget {
}

// 字段元数据
const fieldMeta = {
  name: "name",
  field: "name",
  mode: 'demo.model',
  widget: 'Input',
  ttype: 'String',
  viewType: 'Form'
}

// 找到对应的widget
const widget = FormFieldWidget.Selector({
  viewType: fieldMeta.viewType,
  ttype: fieldMeta.ttype,
  widget: fieldMeta.widget,
})

在上述代码中,我们主要是做了这么写事情:

1.SPI class

class SPI {
  static container = new Map<string, WeakMap<object, object>>()
}
  • SPI 类是一个静态类,用于管理服务的注册和获取。
  • container 是一个静态属性,类型是 Map,它的键是字符串,值是 WeakMap。这个结构允许我们为每个服务类型(例如,Field)管理多个服务实例。

2.ClassFactory 方法

  static ClassFactory(token) {
    return (target) => {
      if (!SPI.container.get(token.type)) {
        SPI.container.set(token.type, new WeakMap())
      }

      const services = SPI.container.get(token.type)
      services?.set(token, target)
    }
  }
  • ClassFactory 是一个静态方法,接受一个 token 作为参数,返回一个函数.
  • 当这个返回的函数被调用时,它会检查 SPI.container 中是否存在对应 token.type 的条目:
    • 如果不存在,则创建一个新的 WeakMap 并将其存入 container 中。
  • 然后,它会从 container 中获取该服务类型的 WeakMap 并将 tokentarget(即 clas)存入其中。这样,服务的注册就完成了。

3.FormFieldWidget class

class FormFieldWidget {
  static Token(options) {
    return {
      ...options,
      type: 'Field'
    };
  }
}
  • FormFieldWidget 是一个用于定义表单字段的类。
  • Token 是一个静态方法,接受 options 作为参数,返回一个对象,并在其中添加 type 属性,默认为 'Field'。这个方法用于创建服务的唯一标识。
  static Selector(options) {
    const fieldWidgets = SPI.container.get('Field')

    if (fieldWidgets) {
      return fieldWidgets.get(options)!
    }

    return null
  }
  • Selector 是一个静态方法,用于从 SPI.container 中获取与给定 options 对应的字段 Widget。
  • 它首先获取 containerField 类型的 WeakMap,然后尝试获取与 options 关联的实例。如果找到了,返回该实例;否则返回 null。

4. 注册一个 Widget

@SPI.ClassFactory(
  FormFieldWidget.Token({
    viewType: 'Form',
    ttype: 'String',
    widget: 'Input'
  })
)
class StringWidget {}
  • 这里使用了装饰器 @SPI.ClassFactory(...) 来注册一个名为 StringWidget 的类。
  • FormFieldWidget.Token 生成的 token 包含 viewType、ttype 和 widget 等信息,用于唯一标识这个 Widget。
  • StringWidget 类被定义时,ClassFactory 会被调用,StringWidget 将被注册到 SPI.container 中,作为 'Field' 类型的一部分。

5. 获取 Widget

// 字段元数据
const fieldMeta = {
  name: 'name',
  field: 'name',
  mode: 'demo.model',
  widget: 'Input',
  ttype: 'String',
  viewType: 'Form'
};

// 找到对应的widget
const widget = FormFieldWidget.Selector({
  viewType: fieldMeta.viewType,
  ttype: fieldMeta.ttype,
  widget: fieldMeta.widget
});
  • fieldMeta 是一个字段元数据对象,它包含字段的类型、视图类型、字段类型等。
  • FormFieldWidget.Selector 方法使用 fieldMeta 中的信息来查找对应的 Widget。

这段代码实现了一个简单的依赖注入,允许开发者通过 SPI 类注册和获取表单字段的 Widget。通过使用 WeakMap,它确保在不再需要服务时可以有效地回收内存。FormFieldWidget 提供了定义服务 token 和选择服务实例的方法,装饰器用于简化服务的注册过程。

如果您想要更深入的学习依赖注入,可参考这篇文章inversify

渲染机制

当我们使用 ts+vue 自定义一个字段或者视图的时候,oinone 底层会将 ts 中的 class 渲染成 vue 组件,然后再将 setComponent 中的组件作为子组件,大家可以使用 vue 调试功能看到对应的功能,那么这功能是怎么实现的呢?

在日常的 Vue 开发中,我们通常会使用 .vue 文件,这些文件中包含模板语法或 TSX 写法。无论采用哪种方式,在运行时都会被转换为 render 函数,本质上是将模板或 TSX 语法转化为 JavaScript 代码,以便在浏览器中运行。

// myComponent.ts

const myComponent = defineComponent({
  setup() {
    const name = ref('');

    return { name };
  },
  render() {
    return createVNode('div', null, this.name);
  }
});

在上面的代码中,我们创建了 myComponent.ts 文件,通过 defineComponent 定义了一个组件。在 setup 函数中,我们定义了一个响应式变量 name,最后在 render 函数中返回一个 div 标签,内容为 name 的值。可以看到,这个文件并不是 .vue 或 .tsx 文件,而是一个普通的 TS 文件。

接下来,我们定义一个 VueWidget 类,以便在其中使用组件

class VueWidget {
  component = null;
  props = {};

  setComponent() {
    this.component = myComponent;
  }

  render() {
    return defineComponent({
      setup() {
        const name = ref('');

        return { name };
      },
      render() {
        return createVNode(this.component, this.props);
      }
    });
  }
}

const widget = new VueWidget();

const vnode = widget.setComponent(myComponent).render();

// 这个时候就可以拿到这个vnode去做渲染了

在上述代码中,我们定义了 VueWidget 类,其中包含一个 setComponent 方法来设置当前组件。通过调用 render 方法,我们可以创建一个新的组件实例,并获取其虚拟节点(vnode)进行渲染。

oinone 平台的前端代码,所有的 class 基层都会继承 VueWidget,这就是为什么 ts 中的 class 会被渲染成 vue 组件的原因。

响应式数据传递

当我们写好了对应的 class + vue 时,通常会遇到属性传递的问题,在前端代码中,我们会使用 @Widget.Reactive | @Widget.Method 来定义一个响应式的属性、方法,这样对应的 vue 组件里面只需要定义对应的 Props 来接收,然后就可以直接使用。

@SPI.ClassFactory(参数)
export class CustomClass extends xxx {
  public initialize(props) {
    super.initialize(props);
    this.setComponent(FormString);
    return this;
  }

  @Widget.Reactive()
  private userName = '张三'

  @Widget.Method()
  private updateUserName(userName:string) {
    this.userName = userName
  }
}

// FormString.vue
<template>
  <div>{{ userName }}</div>
  <button @click="updateUserName('李四')">更新用户名</button>
</template>

<script>
export default {
  props: ['userName', 'updateUserName']
}
</script>

当开发者在class中使用 @Widget.xxx 注解时,底层会收集依赖,并将数据传递到对应的 Vue 组件。在 Vue 组件中,开发者可以直接使用这些属性和方法,无需自行定义。这种方式大大简化了数据管理和组件间的交互。

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

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

(0)
汤乾华的头像汤乾华数式员工
上一篇 2024年9月26日 am9:19
下一篇 2024年9月26日 pm4:20

相关推荐

  • 「前端」关闭源码依赖

    问题背景 在 5.x 版本的 oinone 前端架构中,我们开放了部分源码,但是导致以下性能问题: 1.构建耗时增加​​:Webpack 需要编译源码文件 2.​​加载性能下降​​:页面需加载全部编译产物 3.​​冷启动缓慢​​:开发服务器启动时间延长 以下方案通过关闭源码依赖。 操作步骤 1. 添加路径修正脚本 1: 在当前项目工程根目录中的scripts目录添加patch-package-entry.js脚本,内容如下: const fs = require('fs'); const path = require('path'); const targetPackages = [ '@kunlun+vue-admin-base', '@kunlun+vue-admin-layout', '@kunlun+vue-router', '@kunlun+vue-ui', '@kunlun+vue-ui-antd', '@kunlun+vue-ui-common', '@kunlun+vue-ui-el', '@kunlun+vue-widget' ]; // 递归查找目标包的 package.json function findPackageJson(rootDir, pkgName) { const entries = fs.readdirSync(rootDir); for (const entry of entries) { const entryPath = path.join(rootDir, entry); const stat = fs.statSync(entryPath); if (stat.isDirectory()) { if (entry.startsWith(pkgName)) { const [pkGroupName, name] = pkgName.split('+'); const pkgDir = path.join(entryPath, 'node_modules', pkGroupName, name); const pkgJsonPath = path.join(pkgDir, 'package.json'); if (fs.existsSync(pkgJsonPath)) { return pkgJsonPath; } } // 递归查找子目录 const found = findPackageJson(entryPath, pkgName); if (found) return found; } } return null; } // 从 node_modules/.pnpm 开始查找 const pnpmDir = path.join(__dirname, '../', 'node_modules', '.pnpm'); for (const pkgName of targetPackages) { const packageJsonPath = findPackageJson(pnpmDir, pkgName); if (packageJsonPath) { try { const packageJSON = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); if (packageJSON.main === 'index.ts') { const libName = packageJSON.name.replace('@', '').replace('/', '-'); packageJSON.main = `dist/${libName}.esm.js`; packageJSON.module = `dist/${libName}.esm.js`; const typings = 'dist/types/index.d.ts'; packageJSON.typings = typings; const [pwd] = packageJsonPath.split('package.json'); const typingsUrl = path.resolve(pwd, typings); const dir = fs.existsSync(typingsUrl); if (!dir)…

    2025年4月17日
    52200
  • 前端自定义组件之左右滑动

    本文将讲解如何通过自定义,实现容器内的左右两个元素,通过左右拖拽分隔线,灵活调整宽度。其中左右元素里的内容都是界面设计器拖出来的。 实现路径 1. 界面设计器拖出页面 我们界面设计器拖个布局容器,然后在左右容器里拖拽任意元素。完成后点击右上角九宫格,选中布局容器,填入组件 api 名称,作用是把布局容器切换成我们自定义的左右滑动组件,这里的 api 名称和自定义组件的 widget 对应。最后发布页面,并绑定菜单。 2. 组件实现 widget 组件重写了布局容器,核心函数 renderLeft、renderRight,通过 DslRender.render 方法渲染界面设计器拖拽的元素。 import { BasePackWidget, DefaultContainersWidget, DslDefinition, DslRender, SPI, Widget } from '@oinone/kunlun-dependencies'; import LeftRightSlide from './LeftRightSlide.vue'; // 拿到界面设计器配置的子容器元素 function fetchContainerChildren(widgets?: DslDefinition[], level = 3): DslDefinition[] { if (!widgets) { return []; } const children: DslDefinition[] = []; for (const widget of widgets) { if (widget.widget === 'container') { children.push(widget); } else if (level >= 1) { fetchContainerChildren(widget.widgets, level – 1).forEach((child) => children.push(child)); } } return children; } @SPI.ClassFactory(BasePackWidget.Token({ widget: 'LeftRightSlide' })) export class LeftRightSlideWidget extends DefaultContainersWidget { public initialize(props) { super.initialize(props); this.setComponent(LeftRightSlide); return this; } // 获取容器的子元素 public get containerChildren(): DslDefinition[] { return fetchContainerChildren(this.template?.widgets); } // 初始宽度配置 @Widget.Reactive() public get initialLeftWidth() { return this.getDsl().initialLeftWidth || 400; } // 最小左宽度配置 @Widget.Reactive() public get minLeftWidth() { return this.getDsl().minLeftWidth || 200; } // 最小右宽度配置 @Widget.Reactive() public get minRightWidth() { return this.getDsl().minRightWidth || 200; } // 根据容器子元素渲染左侧 @Widget.Method() public renderLeft() { // 把容器的第一个元素作为左侧 const containerLeft = this.containerChildren[0]; if (containerLeft) { return DslRender.render(containerLeft); } } // 根据容器子元素渲染右侧 @Widget.Method() public renderRight() { // 把容器的第二个元素作为右侧 const containerRight = this.containerChildren[1]; if…

    2025年7月8日
    67200
  • 【界面设计器】自定义字段组件实战——千分位输入框

    阅读之前 此文章为实战教程,已假定你熟悉了【界面设计器】较为完整的【自定义组件】相关内容。 如果在阅读过程中出现的部分概念无法理解,请自行学习相关内容。【前端】文章目录 业务背景 用户在输入【金额】字段时,实时展示千分位格式。 业务分析 从需求来看,我们需要实现一个【千分位】组件,并且该组件允许在【表单】视图中使用。 扩展实现:该组件虽然仅要求在【表单】中使用,但也可以在【搜索】中使用完全相同的实现,因此这里我们在注册时会增加【搜索】视图,将【千分位】组件应用在【搜索】中。对于【表格】、【详情】和【画廊】来说,该组件是没有【输入】行为的展示组件,在这里我们不进行演示。 准备工作 此处你应该已经在某个业务模型下,可以完整执行当前模型的全部【增删改查】操作。 业务模型定义 (以下仅展示本文章用到的模型字段,忽略其他无关字段。) 名称 API名称 业务类型 是否多值 长度(单值长度) 编码 code 文本 否 128 名称 name 文本 否 128 金额 money 金额 否 – 创建组件、元件 准备工作完成后,我们需要根据【业务背景】确定【组件】以及【元件】相关信息,并在【界面设计器】中进行创建。 以下操作过程将省略详细步骤,仅展示可能需要确认的关键页面。 创建千分位组件 创建千分位元件 启动SDK工程进行组件基本功能开发 (npm相关操作请自行查看SDK工程中内置的README.MD) 关键点详解 数据交互类型的字段组件(以下简称展示组件)与仅展示类型的字段组件(以下简称交互组件)有一些差别。 通常情况下,在展示组件中仅需使用value属性即可展示相关内容。在交互组件中除了value用于展示外,还需使用change、focus以及blur处理用户输入时的基本交互逻辑。 数据交互方法主要功能说明: change方法:当值发生变更时调用,根据字段相关配置将值回填至表单中。 focus方法:当组件获取焦点时调用,记录当前值,并在调用blur方法时进行处理。 blur方法:当前组件失去焦点时调用,根据focus方法记录的值,进行失焦触发逻辑的执行。 这里的三个数据交互方法仅仅是对用户行为的抽象,而并非限定其使用场景。 通常来说,这三个方法的调用顺序为:focus –> change –> blur。 如在日期时间组件中,面板打开时调用了focus方法,面板值发生变更时,调用了change方法,面板关闭时调用了blur方法。 如在地图组件中,选中地图上的某个点时仅会调用change方法,用户交互上并不能体现出focus和blur的行为。因此对于这个组件而言,只会有change方法被执行。 代码实现参考 PS:oio-input-number样式必须升级到4.6.x以上的最新版本才支持 Thousandth.vue <template> <a-input-number class="oio-input-number" :value="inputValue" :formatter="formatter" :parser="parser" @update:value="change" @focus="focus" @blur="blur" /> </template> <script lang="ts"> import { InputNumber as AInputNumber } from 'ant-design-vue'; import { computed, defineComponent } from 'vue'; export default defineComponent({ name: 'Thousandth', components: { AInputNumber }, props: { value: { type: [String, Number] }, change: { type: Function }, focus: { type: Function }, blur: { type: Function } }, setup(props) { const inputValue = computed(() => { return props.value; }); const formatter = (value) => { return `${value}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','); }; const parser = (value) => { return value.replace(/\$\s?|(,*)/g, ''); }; return { inputValue, formatter, parser }; } }); </script> FormMoneyThousandthFieldWidget.ts import { FormFieldWidget, ModelFieldType, SPI, ViewType } from '@kunlun/dependencies'; import Thousandth from './Thousandth.vue'; @SPI.ClassFactory( FormFieldWidget.Token({ viewType: [ViewType.Form, ViewType.Search], ttype: ModelFieldType.Currency, widget:…

    2024年4月19日
    1.2K00
  • 【前端】项目开发前端知识要点地图

    概述 下面整理了目前现有的所有文章,并提供了基本的学习路径。所有使用*标记的文章属于推荐必读文章。 目录 基础篇 【路由】浏览器地址栏url参数介绍 母版-布局-DSL 渲染基础(v4)* 组件SPI机制(v4)* 组件数据交互基础(v4)* 组件生命周期(v4) 入门篇 自定义视图组件(v4)* 如何通过浏览器开发者工具提高调试效率* 如何提高自定义组件的开发效率* 自定义组件之自动渲染(组件插槽的使用)(v4)* GraphQL请求详解(v4)* 上下文在字段和动作中的应用 如何实现页面间的跳转 如何自定义指定页面的样式 进阶篇 自定义组件之手动渲染基础(v4) 自定义组件之手动渲染弹出层(v4) 自定义组件之手动渲染任意视图(v4) 【前端】IOC容器(v4) 最佳实践篇 【前端】工程结构最佳实践(v4)* 【前端】移动端工程结构最佳实践(v4)* 界面设计器实战篇 基础篇 【界面设计器】模型增删改查基础 【界面设计器】他表字段 【界面设计器】左树右表 【界面设计器】树形表格 【界面设计器】树下拉/级联选择 【界面设计器】自定义字段组件基础 展示篇 【界面设计器】自定义字段组件实战——轮播图 【界面设计器】自定义字段组件实战——表格字段组合展示 【界面设计器】自定义字段组件实战——表格字段内嵌表格 交互篇 【界面设计器】自定义字段组件实战——千分位输入框 其他 前端低无一体使用教程 如何自定义表格字段? 【界面设计器】组件开发常见问题 【前端】低无一体部署常见问题 【前端】生产环境性能调优 API文档 OioProvider详解(v4.3.0)* 前端环境配置(v4)* 默认布局模板(v4) 表格主题配置(v4) 运行时上下文API文档(v4) Class Component(ts)(v4)

    2024年5月25日
    4.2K00
  • 前端自定义字段与视图最佳方案

    自定义视图获取数据 在某些情况下,oinone 提供的默认视图无法满足需求,这时我们就需要自定义视图。通常,虽然视图的 UI 不足以满足要求,但数据结构是不变的。此时,重点是修改页面 UI,数据的请求与回填可以利用平台默认的能力。 如何实现? 界面设计器的使用 你可以通过界面设计器先配置页面,平台在运行时会根据设计器生成对应的 GraphQL 请求,并自动回填数据。 视图的数据结构 视图的数据类型分为可以分为 Object 跟 List Object 代表当前视图的数据结构是对象 List 代表当前视图的数据结构是数组。 如果我们将 Object 跟 List 分的更细一点就变成了这样: 1: Object: 对象,代表当前视图的数据结构是单个对象,例如:表单视图、详情视图1: List: 对象数组,代表当前视图的数据结构数组,例如:表格视图、卡片视图、画廊视图 视图类型 平台组件 数据属性 表格视图 TableWidget dataSource 画廊视图 GalleryWidget dataSource 表单视图 FormWidget formData 详情视图 DetailWidget formData 自定义视图时,需要先确认当前视图的类型,再通过界面设计器进行页面配置。前端部分只需继承相应的组件,平台底层会自动处理接口数据的获取与回填。 表单视图示例: import Form from './Form.vue'; // 自定义表单视图 @SPI.ClassFactory( BaseElementWidget.Token({ widget: 'custom-form' }) ) export class CustomForm extends FormWidget { public initialize(props: Props) { super.initialize(props); this.setComponent(Form); return this; } } Vue 组件: <template></template> <script lang="ts"> export default defineComponent({ props: { formData: { // 当前表单的数据 type: Object, default: () => ({}) } } }); </script> 自定义layout // 原始的layout模版 <view type="FORM"> <element widget="actionBar" slot="actionBar" slotSupport="action"> <xslot name="actions" slotSupport="action" /> </element> <element widget="form" slot="form"> <xslot name="fields" slotSupport="pack,field" /> </element> </view> //自定义的layout模版 <view type="FORM"> <element widget="actionBar" slot="actionBar" slotSupport="action"> <xslot name="actions" slotSupport="action" /> </element> <element widget="custom-form" slot="form"> <xslot name="fields" slotSupport="pack,field" /> </element> </view> 其实就是把 widget="form" 改成 widget="custom-form" 表格视图示例: import Table from './Table.vue'; // 自定义表格视图 @SPI.ClassFactory( BaseElementWidget.Token({ widget: 'custom-table' }) ) export class CustomTable extends TableWidget { public initialize(props: Props) { super.initialize(props); this.setComponent(Table); return this; } } Vue 组件: <template></template> <script…

    2024年10月17日
    1.7K00

Leave a Reply

登录后才能评论