时光飞逝,「WIDE核心能力升级」系列终于迎来最终章。这篇文章让我们来了解一下WIDE核心引擎CodeE是如何诞生的?
1. 背景
随着业务发展,雷尔团队出现了多个低代码编辑器(大屏、3D编辑器等)的研发需求,为了解决各编辑器之间底层框架不统一、模块复用性低、物料无法流转等问题,CodeE
应运而生。
CodeE
是一个基于IOC设计原则
实现的低代码框架,其优势有:
- ioc设计模式:解耦各模块之间依赖、增强模块复用率、提升应用维护能力。
- 自定义schema:支持用户自定义项目schema,适配不同的业务场景。
- 骨架:提供开箱即用的布局骨架。
- 与vue版本无关
提示:与低代码相关的概念可以提前阅读阿里低代码引擎(感谢大佬)。
2. 设计与实现
2.1. 基于IOC设计实现低代码框架
(1)目前的问题
- 各个编辑器用户怎么自定义Project(项目schema)? 首先,不同编辑器的业务场景不同、编辑的对象也不一样。例如:3D编辑器需要存储相机、交互控制等配置信息,大屏编辑器只需要存储全局样式、组件绝对定位等信息;其次,部分老的编辑器还有历史债务,无法转换schema的问题。所以我们无法在框架内部限制项目schema,需要让用户自定义。
- Project、插件之间如何通信? 首先,如果Project由用户在框架外部定义,我们需要考虑Project如何合理调用框架内部功能;其次,用户自定义的插件经常有互相调用的需求,但加载顺序可能导致调用失败,需要设计机制确保多个插件既能实现自身逻辑的内聚性,又能与其他插件高效、稳定地协作,这一点至关重要。
以上两点是我们在设计低代码框架之初重点考虑的问题,至于其他提升团队协作、合理分离模块等通用研发需求在此不再赘述。所以,我们该如何解决上述问题?
直接说结论:由于团队有nestjs开发经验,并且参考过阿里低代码引擎,经过团队内部讨论我们最终敲定了用IOC设计模式。
(2)IOC是什么?
IOC(Inversion of Control)控制反转,是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度,其中最常见的方式就是依赖注入。随着spring框架的推广,IOC设计模式被使用于更多的框架上,例如:nestjs、angular等。其优势有:
- 对象之间不再直接依赖具体的实现,而是通过接口或抽象类进行交互。
- 依赖的管理由容器负责,通过注入机制可以灵活替换实现(如实现类的动态更改),从而方便扩展功能。
- 减少对象直接在代码中实例化的情况,代码更加优雅,减少重复。
- 通过依赖解析、延迟加载、生命周期管理和代理机制,能自动处理对象的加载顺序,开发者无需显式关注各个对象的初始化时机。只需正确声明依赖关系,容器会自动完成依赖注入和实例化。
可以看出,IOC设计模式能够完美贴合我们的需求。首先,能够帮助我们解耦项目schema模块和其他模块之间的强关联关系;其次,IOC容器能够自行管理对象之间的加载关系;最后,IOC非常适合复杂项目的解构,通过接口和抽象约定交互规范,可以让开发人员专注于自己模块的实现,减少冲突。
(3)实现
如下图所示,整个低代码框架以IOC容器为基底,实现了低代码核心类和项目协议基础类两大功能模块:
- 低代码核心类 :用于注册及管理
Project
(项目schema管理类)、插件、物料等资源,然后提供渲染组件; - 项目基础协议基础类 :主要提供了一些基础功能类,用户可以通过这些类创建
Project
(项目schema管理类),减少开发冗余的功能。
2.2. 低代码核心类
借助inversify我们创建了一个IOC容器,该容器默认采用单例模式:
javascript
import { Container } from 'inversify';
// ioc 容器, 默认单例模式
const container = new Container({
defaultScope: 'Singleton',
autoBindInjectable: true,
});
在此基础上我们分别实现了以下几个重要的功能类,基本都是对相关资源的管理类,实现了常见的crud等API,具体内容不再赘述:
- 物料(Materials) :用于管理页面搭建所需组件及其样式、属性和事件等配置;
- 骨架(Skeleton) :提供了引擎视图的管理和扩展能力,底层采用Grid布局实现,用户可以通过配置随意定义区块渲染视图;
- 插件(Plugins) :插件是扩展低代码框架的最小单元,通过
Plugin
我们可以实现添加物料,向骨架添加渲染视图等功能; - 快捷键(Hotkey) :用于注册和管理快捷键功能;
CodeEFactory
是一个入口类,注入了上述所有功能类。最终,我们只向用户提供了由该类new
的应用实例codeEFactory
,通过setProjectSchemaAndPlugin
方法用户可以注册Project(项目schema管理类)和插件;最后通过init
方法用户可以启动项目:
kotlin
// 使用 由inversify 提供的装饰器
import { injectable, inject } from 'inversify';
// CodeEFactory
@injectable()
class CodeEFactory {
private _app: any = null;
private _project = null;
@inject(Libs) public readonly libs: Libs;
@inject(Materials) public readonly materials: Materials;
@inject(Plugins) public readonly plugins: Plugins;
@inject(Skeleton) public readonly skeleton: Skeleton;
@inject(Hotkey) public readonly hotkey: Hotkey;
...
/**
* 设置项目类和插件类
* @param options - 引擎初始化配置
* @returns void
*/
setProjectSchemaAndPlugin(options: EngineInitOptions) {
const { project, plugins = [] } = options;
const projectInstance = container.get(project);
this._project = projectInstance;
plugins.forEach(plugin => {
this.plugins.register(plugin);
});
return {
project: projectInstance,
};
}
/**
* 引擎初始化方法-创建vue实例等。
* @param el - dom
*/
init(el: string) {
const app = new Vue2!({
render: h => {
return h(Workbench, {
props: { skeleton: this.skeleton },
});
},
});
app.$mount(el || '#app');
this._app = app;
...
}
}
/**
* 低代码应用实例
*/
export const codeEFactory = container.get(CodeEFactory);
2.2.1. 自定义Project
在低代码框架CodeE
的基础上,用户的Project
(项目schema管理类)如何自定义和使用呢?
首先,用户需要使用装饰器@provideProject()
创建Project
类,其中属性可以通过框架内部提供的BaseTree
、BaseArray
等项目协议基本类实现;也可以借助vue的响应式方法Ref
、Reactive
等实现,有助于组件模版渲染。
typescript
import { provideProject, BaseProject, BaseArray, ExtensionConfig, schemaKey } from '@vislab/codee-core';
import { Ref } from 'vue';
@provideProject()
export class Project extends BaseProject {
// 项目id
readonly id = Ref<string>('');
readonly config = new ProjectConfig();
// 项目页面
readonly pages = new BaseTree({ nodeKey: 'uuid' });
readonly assets = new BaseArray<Asset>();
readonly options = new BaseObject();
readonly globalStyle = new BaseObject(); // todo: new BaseStyle()
constructor() {
super();
}
}
然后,用户可以将Project注册到codeEFactory
上使用:
arduino
codeEFactory.setProjectSchemaAndPlugin({
// 项目
project: Project,
plugins: [
// 插件
...
],
});
2.2.2. 定义插件
使用装饰器@providePlugin()
创建,并且需要实现CodeEPlugin接口。在插件和Project中均可以通过注入使用Skeleton、Materials等类:
typescript
import { providePlugin, inject, CodeEPlugin } from '@vislab/codee-core';
import Project from './project.ts';
@providePlugin({ pluginName: 'headMenu' })
export class HeadMenuPlugin implements CodeEPlugin {
constructor(
@inject(Skeleton) private readonly skeleton: Skeleton,
@inject(Project) private readonly project: Project,
) {
}
init(): void {
// 该插件实现了向骨架注册视图组件的功能
const { project, skeleton } = this;
skeleton.getAreaByName('topArea').add({
name: 'topArea',
content: HeadMenu,
contentProps: {
project
}
})
}
destroy(): void {
throw new Error('Method not implemented.');
}
}
通过skeleton.getAreaByName
方法可以获取注册的相关视图区域,然后通过add
方法可以向该区域设置渲染组件和属性。
插件之间也可以通过注入互相调用,而且不用担心插件的初始化顺序:
less
// 插件A
@providePlugin({ pluginName: "Aplugin" })
export class Aplugin implements CodeEPlugin {
readonly items = shallowRef<ToolItem[]>([]);
constructor(
@inject(Skeleton) private readonly skeleton: Skeleton,
) {}
extend(items: ToolItem | ToolItem[]) {
if (Array.isArray(items)) {
this.items.value = [...this.items.value, ...items];
} else {
this.items.value = [...this.items.value, items];
}
}
}
// 插件B
@providePlugin({ pluginName: "Bplugin" })
export class Bplugin implements CodeEPlugin {
constructor(
@inject(Project) private readonly project: Project,
// 通过装饰器inject注入插件A
@inject(Aplugin) private readonly aPlugin: Aplugin
) {}
init(): void {
const { project, aPlugin } = this;
// 调用插件A的方法
aPlugin.extend({
content: Adapt,
contentProps: {
project,
}
})
}
}
2.3. 项目协议基本类
项目协议基本类主要方便用户创建Project
,在这里我们挑其中常用的BaseArray-基础数组类进行说明。
该类可以用于管理以数组类型存储的数据,比如,大屏编辑器中的图片、json等静态资源数据:
scala
import { provideProject, BaseProject, BaseArray } from '@vislab/codee-core';
@provideProject()
export class Project extends BaseProject {
readonly assets = new BaseArray<Asset>();
}
该类里面提供了和数组操作相关的大部分操作,而且相关操作默认返回了对应的redo和undo方法,方便用户做历史记录:
ini
export class BaseArray<T> implements IBase {
_produce(fn: (draft: T[]) => void) {
const oldValue = [...this[writableSymbol].value];
const newValue = produce(oldValue, fn);
this[writableSymbol].value = newValue;
return {
redo: () => (this[writableSymbol].value = newValue),
undo: () => (this[writableSymbol].value = oldValue),
};
}
splice(startIndex: number, deleteCount: number, ...items: T[]) {
const action = (value: T[]) => {
value.splice(startIndex, deleteCount, ...items);
};
return this._produce(action);
}
}
2.4. 其他
2.4.1. 如何自定义装饰器
如2.2.2所示,自定义插件需要使用的装饰器@providePlugin()
,用户可以通过该装饰器设置是否自动初始化、是否覆盖等与业务逻辑无关的数据:
php
@providePlugin({
pluginName: "Aplugin",
autoInit: false,
override: false;
})
其实,inversify中提供了两个装饰器@injectable()
、@inject()
供我们注册和注入类,但是如果想要装饰器携带参数并成功获取到参数,我们需要自定义装饰器。如下所示是插件装饰器的源码实现,其中有两个点需要关注:
- 使用Reflect.defineMetadata将参数和自定义插件关联
- 必须使用decorate(injectable(), target)将自定义插件注册到IOC容器上
typescript
import { decorate, injectable } from 'inversify';
import { PluginDecoratorOptions } from './type';
import { PLUGIN_METADATA_KEY } from './constants';
export function providePlugin(options: PluginDecoratorOptions) {
return function <T extends abstract new (...args: never) => unknown>(target: T): T {
if (Reflect.hasOwnMetadata(PLUGIN_METADATA_KEY, target)) {
throw new Error('plugin decorator can not be used twice');
}
Reflect.defineMetadata(PLUGIN_METADATA_KEY, options, target);
decorate(injectable(), target);
return target;
};
}
如此一来,我们就可以在插件注册的时候获取相关插件的参数:
kotlin
/**
* # 插件类
* 统一管理编辑器注册插件的类
*/
@injectable()
export class Plugins {
/**
* 插件注册
* @param pluginClass - 插件类
* @returns
*/
async register(pluginClass: CodeEPluginClass) {
const plugin = container.get(pluginClass);
const metaData = this.getPluginMeta(pluginClass);
const { pluginName, override = false } = metaData;
...
}
private getPluginMeta(pluginClass: CodeEPluginClass): PluginDecoratorOptions {
const data = Reflect.getMetadata(PLUGIN_METADATA_KEY, pluginClass);
return data;
}
}
2.4.2. 注册到容器上的类如何在组件里面使用
从上述内容可以看出,在IOC容器的基础上,用户只需要定义相关类即可,但是我们如何在vue组件中获取到数据呢?目前有两种方式:
(1)通过contentProps传递
在skeleton
上通过add
方法添加视图组件的时候可以通过contentProps
传递注入类的实例
less
@providePlugin({
pluginName: 'PluginToolbar',
})
export class PluginToolbar implements CodeEPlugin {
constructor(
@inject(Project) private readonly project: Project,
@inject(Skeleton) private readonly skeleton: Skeleton,
) {}
async init() {
const { project, skeleton } = this;
skeleton.getAreaByName(TOP_AREA)?.add({
name: 'toolbar',
content: ToolBar,
defaultActive: true,
contentProps: {
project,
}
});
}
destroy(): void {
throw new Error('Method not implemented.');
}
}
(2)通过容器获取相关类的实例
框架暴露了IOC容器,通过get
方法用户可以更方便的获取相关类的实例:
javascript
import { container } from '@vislab/codee-core';
import { Project } from './Project';
const project = container.get(Project);
3. 结语
历时数月,「WIDE核心能力升级」系列文章将以今天这篇画上句点。
从技术架构的革新到产品能力的重构再到用户体验的蜕变,我们尝试用文字记录的不只是代码与功能,更是产品团队如何用技术与用户思维重塑产品的历程。
这个系列虽终,但WIDE的进化不会停步。
敬请期待吧!
参考文献