低代码场景:在线编写html,css,js(组件)并运行

各位大佬早上好、中午好、晚上好!

志哥有多年的低代码经验,了解了国内的低代码平台好像在线编写html,css,js的功能都没有实现,是这个功能比较鸡肋还是比较难实现??

在我们公司自研的低代码平台中,有个需求是在内置组件无法满足业务需求的情况下,需要快速进行组件的设计开发,目前有三种方案:

  • 插件组件:使用自定义脚手架搭建一个模板项目用于开发自定义组件,然后打包为umd.js格式,上传到代码库,基座项目通过远程组件的方式进行组件的加载渲染。
  • 微前端组件 :可以使用qiankunmicro 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中将htmlcssjavascript的样式导入到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 { }

渲染器页面布局

组件操作有:组件设置,组件编辑,组件预览

页面布局完了,下一步进行编辑器组件封装🙅

编辑器组件封装

编辑器组件分为htmlcssjavascriptjson组件,其实这四个组件大同小异,基本都差不多:

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. 组件JavaScripthtml解析

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样式:

  1. 正则表达式:定义了多个正则表达式,用于匹配CSS中的不同元素,如媒体查询、关键帧、CSS声明等。
  2. CSS解析fi 构造器包括多个方法用于解析CSS字符串,将其转换为JavaScript对象,分离出CSS的各种声明(如@media@keyframes等)。
  3. CSS操作:提供了对CSS对象进行操作的方法,如删除、压缩、合并等。
  4. 编辑器支持:包括将CSS对象转换为适合编辑器显示的字符串格式的方法。
  5. 命名空间处理:提供了添加和清除CSS选择器的命名空间的方法,用于避免样式冲突。
  6. 样式注入 :提供了将处理后的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

如果觉得有帮助可以加个关注

相关推荐
bysking24 分钟前
【前端-组件】定义行分组的表格表单实现-bysking
前端·react.js
王哲晓40 分钟前
第三十章 章节练习商品列表组件封装
前端·javascript·vue.js
fg_41143 分钟前
无网络安装ionic和运行
前端·npm
理想不理想v44 分钟前
‌Vue 3相比Vue 2的主要改进‌?
前端·javascript·vue.js·面试
酷酷的阿云1 小时前
不用ECharts!从0到1徒手撸一个Vue3柱状图
前端·javascript·vue.js
微信:137971205871 小时前
web端手机录音
前端
齐 飞1 小时前
MongoDB笔记01-概念与安装
前端·数据库·笔记·后端·mongodb
神仙别闹1 小时前
基于tensorflow和flask的本地图片库web图片搜索引擎
前端·flask·tensorflow
GIS程序媛—椰子2 小时前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_0012 小时前
前端八股文(一)HTML 持续更新中。。。
前端·html