各位大佬早上好、中午好、晚上好!
志哥有多年的低代码经验,了解了国内的低代码平台好像在线编写html,css,js的功能都没有实现,是这个功能比较鸡肋还是比较难实现??
在我们公司自研的低代码平台中,有个需求是在内置组件无法满足业务需求的情况下,需要快速进行组件的设计开发,目前有三种方案:
- 插件组件:使用自定义脚手架搭建一个模板项目用于开发自定义组件,然后打包为umd.js格式,上传到代码库,基座项目通过远程组件的方式进行组件的加载渲染。
- 微前端组件 :可以使用qiankun、micro app或者module federation等微前端解决方案加载渲染微前端页面或者组件。
- 在线设计组件:在线编写组件的module、html、css、js动态生成组件,动态进行组件渲染。
本文将从项目的创建到设计器的开发,组件的加载,渲染等步骤向你一步步揭开在线设计组件的面纱。
此demo所使用的技术栈为:
- Angular15:开发框架。
- Rxjs:异步编程解决方案,如果不了解可以查看志哥的另一篇介绍Rxjs的文章。
- ace-builds:一个在线代码编写的库。
相信我,你看得懂的!!
项目初始化
1. 使用脚手架创建项目,并安装ace-builds库,然后初始化ace-builds库
sh
ng new online-component-design
项目创建好了安装ace-builds
库
sh
yarn add ace-builds lodash
初始化ace-builds
库,在angular.json中将html
,css
,javascript
的样式导入到assets中:
json
"assets": [
"src/favicon.ico",
"src/assets",
{
"glob": "worker-html.js",
"input": "./node_modules/ace-builds/src-noconflict/",
"output": "/"
},
{
"glob": "worker-css.js",
"input": "./node_modules/ace-builds/src-noconflict/",
"output": "/"
},
{
"glob": "worker-json.js",
"input": "./node_modules/ace-builds/src-noconflict/",
"output": "/"
},
{
"glob": "worker-javascript.js",
"input": "./node_modules/ace-builds/src-noconflict/",
"output": "/"
}
],
写个工具函数用于加载ace-builds
:
js
import { Observable } from 'rxjs/internal/Observable';
import { forkJoin, from, of } from 'rxjs';
import { map, mergeMap, tap } from 'rxjs/operators';
let aceDependenciesLoaded = false;
let aceModule: any;
function loadAceDependencies(): Observable<any> {
if (aceDependenciesLoaded) {
return of(null);
} else {
const aceObservables: Observable<any>[] = [];
aceObservables.push(from(import('ace-builds/src-noconflict/ext-language_tools')));
aceObservables.push(from(import('ace-builds/src-noconflict/ext-searchbox')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-java')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-css')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-json')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-javascript')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-text')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-markdown')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-html')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-c_cpp')));
aceObservables.push(from(import('ace-builds/src-noconflict/mode-protobuf')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/java')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/css')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/json')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/javascript')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/text')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/markdown')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/html')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/c_cpp')));
aceObservables.push(from(import('ace-builds/src-noconflict/snippets/protobuf')));
aceObservables.push(from(import('ace-builds/src-noconflict/theme-textmate')));
aceObservables.push(from(import('ace-builds/src-noconflict/theme-github')));
return forkJoin(aceObservables).pipe(
tap(() => {
aceDependenciesLoaded = true;
})
);
}
}
export function getAce(): Observable<any> {
if (aceModule) {
return of(aceModule);
} else {
// @ts-ignore
return from(import('ace')).pipe(
mergeMap((module) => {
return loadAceDependencies().pipe(
map(() => module)
);
}),
tap((module) => {
aceModule = module;
})
);
}
}
使用方式:
js
getAce().subscribe(
(ace) => {
...
}
);
2. 将页面分为在线组件设计器和渲染器
添加两个路由模块:
设计器
js
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { OnlineDesignComponent } from "./design.component";
const routes: Routes = [
{
path: 'design',
component: OnlineDesignComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class OnlineDesignRoutingModule { }
设计器页面布局
如上,将页面分为四部分:
- html:书写html的地方
- css:书写样式的地方
- JavaScript:书写逻辑的地方
- settings:组件的一些配置项,目前先用json对象表示,实际项目中可以用JSON Schema编辑和渲染,配置demo点击这个链接
渲染器
js
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { OnlineRenderComponent } from "./render.component";
const routes: Routes = [
{
path: 'render',
component: OnlineRenderComponent
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class OnlineRenderRoutingModule { }
渲染器页面布局
组件操作有:组件设置,组件编辑,组件预览
页面布局完了,下一步进行编辑器组件封装🙅
编辑器组件封装
编辑器组件分为html
、css
、javascript
、json
组件,其实这四个组件大同小异,基本都差不多:
html:
html
<div class="code-edit-wraper">
<div #codeEditInput style="width: 100%;height: 100%;min-width: 500px;min-height: 500px;border: 1px solid #D7D2CC;"></div>
</div>
javascript:
js
import { Component, OnInit, OnDestroy, ViewChild, ElementRef, Input } from '@angular/core';
import { Ace } from 'ace-builds';
import { getAce } from "../../core/ace.models";
@Component({
selector: 'code-editor',
templateUrl: './code-editor.component.html',
styleUrls: ['./code-editor.component.less']
})
export class CodeEditorComponent implements OnInit, OnDestroy {
@ViewChild('codeEditInput', {static: true})
codeEditInputElmRef!: ElementRef;
editor!: Ace.Editor;
@Input() content: any = '';
@Input() mode: string = 'javascript';
get inputValue() {
return this.editor.getValue();
}
constructor() { }
ngOnInit(): void {
this.createEditor(this.codeEditInputElmRef, this.content);
}
ngOnDestroy(): void {
}
createEditor(editorElementRef: ElementRef, content: string | undefined): void {
const editorElement = editorElementRef.nativeElement;
let editorOptions: Partial<Ace.EditorOptions> = {
mode: `ace/mode/${this.mode}`,
// theme: 'ace/theme/github',
fontSize: 16, // 编辑器内字体大小
showGutter: true,
showPrintMargin: false,
};
const advancedOptions = {
enableSnippets: true,
enableBasicAutocompletion: true,
enableLiveAutocompletion: true
};
editorOptions = {...editorOptions, ...advancedOptions};
getAce().subscribe(
(ace) => {
this.editor = ace.edit(editorElement, editorOptions);
this.editor.session.setUseWrapMode(true);
if(content) this.editor.setValue(content, -1);
}
);
}
}
不出意外的话,整个项目的初始化过程完成:
动作组件渲染
到这里我们可以拿到组件设计时的数据:
1. 组件JavaScript
和html
解析
JavaScript的解析 我们希望能够注入一些(数据、服务、接口、组件、工具函数 )供组件消费。我们希望通过self.ctx可以拿到我们注入的的上下文信息:
上面的截图我们可以注入了包括且不限于:
- 组件的上级容器
- 注入器
- 日期操作服务
- 网络请求服务http
- ng-zorro的消息服务message
- 路由
- rxjs
- utils
文末会演示使用内置组件进行二次编辑的例子。
我们拿到用户编写的js字符串:
通过new Function的方式注入context和执行用户编写的js。
js
private createWidgetControllerDescriptor(widget: Widget, name: string) {
let widgetTypeFunctionBody = `return function _${name.replace(/-/g, '_')} (ctx) {\n` +
' var self = this;\n' +
' self.ctx = ctx;\n\n';
widgetTypeFunctionBody += widget.javascriptTemplate;
widgetTypeFunctionBody += '\n};\n';
// console.log('widgetTypeFunctionBody >>:', widgetTypeFunctionBody);
const widgetTypeFunction = new Function(widgetTypeFunctionBody);
const widgetType = widgetTypeFunction.apply(this);
const result = {
widgetTypeFunction: widgetType
};
return result;
}
上面的方法中我们先通过创建一个自定义函数体(绑定ctx)+用户js,通过new Function执行函数体widgetTypeFunctionBody
创建函数并返回,然后将此函数放到widget的widgetTypeFunction
上。
下一步解析html,这一步就是创建组件的过程,我这边使用Angular技术栈创建一个动态组件,其他技术栈的同学可以参考此方法实现。
创建动态组件我把他封装了一个服务dynamic-component-factory.service.ts
:
- 调用这个服务的核心方法createDynamicComponentFactory返回了一个组件工厂对象。通过
js
viewContainerRef.createComponent(this.widgetInfo.componentFactory, 0, injector);
可以创建一个组件。
- 第三个参数,传入了modules:modules里可以注入任何需要的服务,指令,组件等
- 看到我圈起来的红色框框组件,这个组件就是可以将上面提到的各种服务继承到目标组件
js
@Directive()
// tslint:disable-next-line:directive-class-suffix
export class DynamicWidgetComponent implements IDynamicWidgetComponent, OnInit, OnDestroy {
[key: string]: any;
validators = Validators;
constructor(
@DInject(FormBuilder) public fb: FormBuilder,
@DInject(Injector) public readonly $injector: Injector,
@DInject('widgetContext') public readonly ctx: WidgetContext) {
this.ctx.$injector = $injector;
this.ctx.date = $injector.get(DatePipe);
this.ctx.http = $injector.get(HttpClient);
this.ctx.sanitizer = $injector.get(DomSanitizer);
this.ctx.router = $injector.get(Router);
this.ctx.message = $injector.get(NzMessageService);
this.ctx.$scope = this;
}
ngOnInit() {
}
ngOnDestroy(): void {
}
}
通过以上操作我们已经拿到了工厂组件,下一步就是解析工厂组件和渲染到锚点上
1. 解析工厂组件
和css
拿到工厂组件widgetInfo
然后实例化上下文new WidgetContext(this.widgetInfo)
js
init() {
this.customComponentService.getWidgetInfo(this.widget).subscribe({
next: (widgetInfo) => {
console.log('widgetInfo >>:', widgetInfo);
this.widgetInfo = widgetInfo;
this.widgetContext = new WidgetContext(this.widgetInfo);
this.loadFromWidgetInfo();
},
error: err => {
console.log(err)
}
})
}
解析之前保存到widget上的widgetTypeFunction
方法,并执行;
js
private loadFromWidgetInfo() {
this.widgetContext.widgetNamespace = `widget-type-${this.widget.id}`;
const elem = this.elementRef.nativeElement;
elem.classList.add('custom-widget');
elem.classList.add(this.widgetContext.widgetNamespace);
this.widgetType = this.widgetInfo.widgetTypeFunction;
if (!this.widgetType) {
this.widgetTypeInstance = {};
} else {
try {
// 这一步是核心
this.widgetTypeInstance = new this.widgetType(this.widgetContext);
} catch (e) {
this.widgetTypeInstance = {};
}
}
// console.log('this.widgetTypeInstance >>:', this.widgetTypeInstance);
if (!this.widgetTypeInstance.onInit) {
this.widgetTypeInstance.onInit = () => { };
}
if (!this.widgetTypeInstance.onDestroy) {
this.widgetTypeInstance.onDestroy = () => { };
}
// 这个创建组件方法
this.configureDynamicWidgetComponent();
// 执行里面的生命周期
// 可以加入更多的生命周期函数
this.widgetTypeInstance.onInit();
}
创建组件
js
private configureDynamicWidgetComponent() {
const viewContainerRef = this.customWidgetAnchor.viewContainerRef;
viewContainerRef.clear();
const injector: Injector = Injector.create(
{
providers: [
{
provide: 'widgetContext',
useValue: this.widgetContext
},
],
parent: this.injector
}
);
this.widgetContext.$containerParent = this.elementRef.nativeElement.querySelector('#custom-widget-container');
try {
// 这一步是创建组件,并注入
this.dynamicWidgetComponentRef = viewContainerRef.createComponent(this.widgetInfo.componentFactory, 0, injector);
// console.log("🚀 ~ this.dynamicWidgetComponentRef:", this.dynamicWidgetComponentRef)
this.cd.detectChanges();
} catch (e) {
console.error(e);
if (this.dynamicWidgetComponentRef) {
this.dynamicWidgetComponentRef.destroy();
this.dynamicWidgetComponentRef = null;
}
viewContainerRef.clear();
}
if (this.dynamicWidgetComponentRef) {
this.dynamicWidgetComponent = this.dynamicWidgetComponentRef.instance;
console.log("🚀 ~ this.dynamicWidgetComponent:", this.dynamicWidgetComponent)
// 这里可以将更多信息绑定到ctx中
// this.widgetContext.$container = this.dynamicWidgetComponentRef.location.nativeElement;
// 这是解析css
this.parserCss();
}
}
解析css 这里我在网上找了一个解析css的库,并拷贝到源码里了,这个库主要目的是解析和操作CSS样式:
- 正则表达式:定义了多个正则表达式,用于匹配CSS中的不同元素,如媒体查询、关键帧、CSS声明等。
- CSS解析 :
fi
构造器包括多个方法用于解析CSS字符串,将其转换为JavaScript对象,分离出CSS的各种声明(如@media
、@keyframes
等)。 - CSS操作:提供了对CSS对象进行操作的方法,如删除、压缩、合并等。
- 编辑器支持:包括将CSS对象转换为适合编辑器显示的字符串格式的方法。
- 命名空间处理:提供了添加和清除CSS选择器的命名空间的方法,用于避免样式冲突。
- 样式注入 :提供了将处理后的CSS字符串注入到HTML文档中的方法,通过创建
<style>
标签来实现。
拿到用户编写的css直接执行这个函数:
js
private parserCss() {
const namespace = `${this.widgetInfo.widgetName}-${Math.random()}`;
const customCss = this.widgetInfo.cssTemplate;
this.cssParser.cssPreviewNamespace = namespace;
this.cssParser.createStyleElement(namespace, customCss, 'nonamespace');
}
我们来看下实际运行效果:
编辑settings,让组件表现不同的行为。这一步其实就是组件实例化的过程。
志哥我想说
完成以上JavaScript、settings、html、css的解析,我们的组件在线编写和渲染也差不多完成了,这个只是示例,实际生产环境需要考虑更多情况和边界条件,各位可以参考此实现方式,给做低代码的同学提供一个设计思路,如有更多实现方式,欢迎讨论。
本文所有的代码均已开源放到gitHub上了,欢迎各位大佬食用:
- 源码:github.com/zzhimin/onl...
- 本来想部署到gitHub Pages上的,本地没问题,部署上去报错了。想本地查看效果的:
js
git clone https://github.com/zzhimin/online-component-design.git
yarn
yarn start
如果觉得有帮助可以加个关注。