这两年低代码平台的话题愈来愈火,一眼望去全是关于低代码开发的概念,鲜有关于低代码平台的设计实现。本文将以实际的代码入手,逐步介绍如何打造一款低开的平台。
低开概念我们不再赘述,但对于低开的前端来说,至少要有以下3个要素:
1.使用能被更多用户(甚至不是开发人员)容易接受的DSL(领域特定语言),用以描述页面结构以及相关UI上下文。
2.内部具有构建引擎,能够将DSL JSON构建为React组件,交给React进行渲染。
3.提供设计器(Designer)支持以拖拉拽方式来快速处理DSL,方便用户快速完成页面设计。
本文我们首先着眼于如何进行构建,后面的文章我们再详细介绍设计器的实现思路。
对于页面UI来说,我们总是可以将界面通过树状结构进行描述:
1. 页面
1-1. 标题 1-1-1. 文字1-2. 内容面板 1-2-1. 一个输入框
如果采用xml来描述,可以是如下的形式:
<page><title>标题文字</title><content><input></input></content>
</page>
当然,xml作为DSL有以下的两个问题:
1.内容存在较大的信息冗余(page标签、title标签,都有重复的字符)。
2.前端需要引入单独处理xml的库。
自然,我们很容易想到另一个数据描述方案:JSON。使用JSON来描述上述的页面,我们可以如下设计:
{"type": "page","children": [{"type": "title","value": "标题文字"},{"type": "content","children": [{"type": "input"}]}]
}
初看JSON可能觉得内容比起xml更多,但是在前端我们拥有原生处理JSON的能力,这一点就很体现优势。
回顾一下JSON的方案,我们首先定义一个基本的数据结构:元素节点(ElementNode),它至少有如下的内容:
1.type属性:表明当前节点所属的类型。
2.children属性:一个数组,存放所有的子节点。
3.额外属性:额外剩余的属性,可以应用到当前type,产生作用。
例如,对于一个页面(page),该页面有一个属性配置背景色(backgroundColor),该页面中有一个按钮(button),并且该按钮有一个属性配置按钮的尺寸(size),此外还有一个输入框(input)。
{"type": "page","backgroundColor": "pink", // page的 backgroundColor 配置"children": [{"type": "button","size": "blue" // button的size配置},{"type": "input"}]
}
在我们的平台中,我们定义如下的结构:
export interface ElementNode {/** * Element 唯一类型type */type: string;/** * 组件的各种属性: * 扩展的、UI的 */[props: string]: string | number | any/** * Element 的所有子元素 */children?: ElementNode[]
}
上文定义了我们低开平台的DSL,但是DSL数据如果没有转换构建为UI组件并渲染在界面上,是没有任何意义的。我们必须要有构建引擎支持将JSON转换为web页面的内容。
首先我们需要定义基本的构建器:TypeBuilder。其作用是和ElementNode.type相绑定,一个type对应一个builder。
import {ReactNode} from "react";
import {ElementNode} from "../meta/ElementNode";
/**
* 构建器构建上下文,至少包含ElementNode的相关数据
*/
export interface TypeBuilderContext {elementNode: Omit<ElementNode, ''>;
}
/**
* 绑定Type的构建器
*/
export interface TypeBuilder {/** * 根据ElementNode上下文信息,得到ReactNode供React渲染 * @param builderContext 构建器接受的数据上下文 * @param childrenReactNode 已经完成构建的子节点的 ReactNode */build(builderContext: TypeBuilderContext,childrenReactNode?: ReactNode[],): ReactNode;
}
/**
* TypeBuilder构造函数类型
*/
export type TypeBuilderConstructor = new (args: any) => TypeBuilder;
这里的TypeBuilder只是接口抽象,具体的实现需要根据不同的type来编写不同的builder,每个builder中的build会根据有所差异。
这里我们先简单实现page、button和input:
export class PageTypeBuilder implements TypeBuilder {build(builderContext: TypeBuilderContext,childrenReactNode?: ReactNode[]): ReactNode {const style: CSSProperties = {width: '100%',height: '100%',padding: '10px'}// 对于type = 'page',就是用一个div作为UI组件// 注意,对于容器类组件,始终需要将传入的子元素放到对应的位置,控制子元素的展示return (<div style={style}>{childrenReactNode}</div>)}
}
export class ButtonTypeBuilder implements TypeBuilder {build(builderContext: TypeBuilderContext,childrenReactNode?: ReactNode[]): ReactNode {const {elementNode = {}} = builderContext;const {text = 'button'} = elementNode;// 直接使用antd的Buttonreturn (<Buttontype='primary'>{text}</Button>)}
}
export class InputTypeBuilder implements TypeBuilder {build(builderContext: TypeBuilderContext,childrenReactNode?: ReactNode[]): ReactNode {// 使用antd的Inputreturn (<Input/>)}
}
实际上,每个builder具体返回的组件,都可以根据要求进行任意定制开发,后续我们会深入介绍这一块的内容。但需要再次强调,正如上面PageTypeBuilder中的注释一样,对于容器类组件,需要将childrenReactNode放到对应的节点位置,React才能正常渲染所有的子元素。
实现了builder以后,为了方便管理,我们使用一个TypeBuilderManager(构建器管理器)来管理我们定义的所有的TypeBuilder:
import {TypeBuilder, TypeBuilderConstructor} from "./TypeBuilder";
import {PageTypeBuilder} from "./impl/PageTypeBuilder";
import {ButtonTypeBuilder} from "./impl/ButtonTypeBuilder";
import {InputTypeBuilder} from "./impl/InputTypeBuilder";
/**
* TypeBuilder管理器
* 统一管理应用中所有已知的构建器
* todo 后续可以支持多种方式加载
*/
class TypeBuilderManager {/** * 单实例 * @private */private static instance: TypeBuilderManager;/** * 内存单例获取 */static getInstance(): TypeBuilderManager {if (!TypeBuilderManager.instance) {TypeBuilderManager.instance = new TypeBuilderManager();}return TypeBuilderManager.instance;}/** * 单例,构造函数private控制 * @private */private constructor() {}/** * 这里记录了目前所有的TypeBuilder映射, * 后续可以优化为程序进行扫描实现,不过是后话了 * @private */private typeBuilderConstructors: Record<string, TypeBuilderConstructor> = {'page': PageTypeBuilder,'button': ButtonTypeBuilder,'input': InputTypeBuilder};/** * 根据元素类型得到对应构建器 * @param elementType */getTypeBuilder(elementType: string): TypeBuilder {if (!this.typeBuilderConstructors.hasOwnProperty(elementType)) {throw new Error('找不到处理')}// 采用ES6的Reflect反射来处理对象创建,供后续扩展优化return Reflect.construct(this.typeBuilderConstructors[elementType], [])}/** * 添加专门处理某种elementType的TypeBuilder * @param elementType * @param typeBuilderConstructor */addTypeBuilder(elementType: string, typeBuilderConstructor: TypeBuilderConstructor): void {if (this.typeBuilderConstructors.hasOwnProperty(elementType)) {console.warn(`当前TypeBuilderManager已经存在处理 elementType = ${elementType} 的Builder,本次添加对其覆盖。`);}this.typeBuilderConstructors[elementType] = typeBuilderConstructor;}/** * 移除处理指定elementType的Builder * @param elementType */removeTypeBuilder(elementType: string): void {delete this.typeBuilderConstructors[elementType];}/** * 获取当前能够处理的ElementType */getHandledElementTypes(): string[] {return Object.keys(this.typeBuilderConstructors);}
}
export {TypeBuilderManager
}
该构建器管理器维持了一个映射表,由ElementType映射到对应TypeBuilder的构造函数(注意,不是TypeBuilder实例,目的是为了可控懒创建TypeBuilder实例)。同时,该管理器还提供了对该映射表的增上查等API。
接下来是实现我们的构建引擎(BuildEngine,叫引擎高大上)。构建引擎的核心功能是读取由Schema转为的ElementNode,然后以递归深度遍历的方式不断读取ElementNode和ElementNode的子节点,根据ElementNode的类型type,从前面我们编写的TypeBuilderManager中获取对应Builder来将ElementNode构建为一个又一个ReactNode。
代码如下:
import {ElementNode} from "../meta/ElementNode";
import {TypeBuilderManager} from "../builder/TypeBuilderManager";
import {ReactNode} from "react"
/**
* 构建引擎
*/
export class BuildEngine {/** * 构建:通过传入ElementNode信息,得到该节点对应供React渲染的ReactNode * @param rootEleNode */build(rootEleNode: ElementNode): ReactNode | undefined {return this.innerBuild(rootEleNode);}/** * 构建:通过传入ElementNode信息,得到该节点对应供React渲染的ReactNode * @param rootEleNode */private innerBuild(rootEleNode: ElementNode): ReactNode | undefined {if (!rootEleNode) {return undefined;}const {type, children} = rootEleNode;// 如果有子元素,则递归调用自身,获取子元素处理后的ReactNodeconst childrenReactNode =(children || []).map((childEleNode) => {return this.innerBuild(childEleNode)});// 通过 TypeBuilderManager 来统一查找对应ElementType的Builderconst typeBuilder = TypeBuilderManager.getInstance().getTypeBuilder(type);if (!typeBuilder) {console.warn(`找不到type="${type}"的builder`)return undefined;}// 调用TypeBuilder的build,让其实例内部生成ReactNodeconst reactNode = typeBuilder.build({elementNode: rootEleNode},childrenReactNode)return reactNode;}
}
需要注意,这个Engine的公共API是build,由外部调用,仅需要传入根节点ElementNode即可得到整个节点数的UI组件树(ReactNode)。为了后续我们优化内部的API结构,我们内部使用innerBuild作为内部处理的实际方法。
建立一个样例项目,编写一个简单的样例:
import {BuildEngine} from "@lite-lc/core";
import {ChangeEvent, useState} from "react";
import {Input} from 'antd';
export function SimpleExample() {// 使用构建引擎const [buildEngine] = useState(new BuildEngine());// 使用state存储一个schema的字符串const [elementNodeJson, setElementNodeJson] = useState(JSON.stringify({"type": "page","backgroundColor": "pink", // page的 backgroundColor 配置"children": [{"type": "button","size": "blue" // button的size配置},{"type": "input"}]}, null, 2))let reactNode;try {const eleNode = JSON.parse(elementNodeJson);reactNode = buildEngine.build(eleNode);} catch (e) {// 序列化出异常,返回JSON格式出错reactNode = <div>JSON格式出错</div>}return (<div style={{width: '100%', height: '100%', padding: '10px'}}><div style={{width: '100%', height: 'calc(50%)'}}><Input.TextAreaautoSize={{minRows: 2, maxRows: 10}}value={elementNodeJson}onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {const value = e.target.value;// 编辑框发生修改,重新设置JSONsetElementNodeJson(value);}}/></div><div style={{width: '100%', height: 'calc(50%)', border: '1px solid gray'}}>{reactNode}</div></div>);
}
目前为止,我们已经设计了一个简单的构建引擎。但是还有两个需要解决的问题:
1.循环创建的ReactNode数组没有添加key,会导致React渲染性能问题。
2.构建的过程中,无法定位当前ElementNode的所在位置。
我们先讨论问题2。对于该问题具体是指:TypeBuilder.build方法接受的入参可以知道当前ElementNode节点自身的信息,但是却无法知道ElementNode所在的位置具体处于整体的哪个位置。
{"type": "page","children": [{"type": "panel","children": [{"type": "input"},{"type": "button",}]},{"type": "input"}]
}
对于上述的每一个type,都应当有其标志其唯一的一个key。可以知道,每一个元素的路径是唯一的:
也就是说,路径由'/'拼接,每一级路径由'@'分割type和index,type表明该节点类型,index表明该节点处于上一级节点(也就是父级节点)的children数组的位置(基于0起始)。
那么,如何生成这样一个路径信息呢?逐级遍历ElementNode即可。其实遍历的这个动作,我们已经在之前构建引擎的innerBuild地方进行过了(递归),现在只需要进行简单的修改方法:
// BuildEngine.ts代码
-private innerBuild(rootEleNode: ElementNode): ReactNode | undefined {
+private innerBuild(rootEleNode: ElementNode, rootPath: string): ReactNode | undefined { if (!rootEleNode) { return undefined; }
// ... ... // 递归调用自身,获取子元素处理后的ReactNode const childrenReactNode =
-(children || []).map((childEleNode) => {
-return this.innerBuild(childEleNode)
+(children || []).map((childEleNode, index) => {
+// 子元素路径:
+// 父级路径(也就是当前path)+ '/' + 子元素类型 + 子元素所在索引
+const childPath = `${rootPath}/${childEleNode.type}@${index}`;
+return this.innerBuild(childEleNode, childPath); });
// ... ...
首先,我们修改了innerBuild方法入参,增加了参数rootPath,用以表示当前节点所在的路径;其次,在生成子元素ReactNode的地方,将rootPath作为基准,根据上述规则"${elementType}@${index}",来生成子元素节点的路径,并传入到的递归调用的innerBuild中。
当然,build内部调用innerBuild的时候,需要构造一个起始节点的path,传入innerBuild。
// BuildEngine.ts代码 build(rootEleNode: ElementNode): JSX.Element | undefined {
-return this.innerBuild(rootEleNode);
+// 起始节点,需要构造一个起始path传入innerBuild
+// 注意,根节点由于不属于某一个父级的子元素,所以不存在'@${index}'
+return this.innerBuild(rootEleNode, '/' + rootEleNode.type); }
另外,为了让每一个builder能够获取到需要构建的ElementNode的路径信息这个上下文,我们在TypeBuilderContext中添加path属性:
/**
* 构建器构建上下文,至少包含ElementNode的相关数据
*/
export interface TypeBuilderContext {
+ /**
+* path:让每个TypeBuilder知道当前构建的节点所在的路径
+*/
+path: string;elementNode: Omit<ElementNode, ''>;
}
同时,innerBuild中也要进行一定的修改,需要在调用TypeBuilder.build方法的时候把path传入:
// innerBuild函数// ... const reactNode = typeBuilder.build( {
+path: rootPath, elementNode: rootEleNode },// ...
这样一来,每个builder的build方法里面,都可以从BuilderContext中获取到当前实际要构建转换的ElementNode唯一具体路径path。在后续的优化中,我们就可以利用该path做一些事情了。
现在,如何处理问题1:key值未填写的问题呢?其实,当我们解决了问题2以后,我们现在知道path是唯一的,那么我们可以将path作为每个元素的key,例如:
Button构建器:
export class ButtonTypeBuilder implements TypeBuilder {build(builderContext: TypeBuilderContext,childrenReactNode?: ReactNode[]): ReactNode {
-const {elementNode = {}} = builderContext;
+const {path, elementNode = {}} = builderContext; const {text = 'button'} = elementNode; // 直接使用antd的Button return ( <Button
+key={path} type='primary'> {text} </Button>) }
}
Input构建器:
export class InputTypeBuilder implements TypeBuilder {build(builderContext: TypeBuilderContext,childrenReactNode?: ReactNode[]): ReactNode {
+const {path} = builderContext; // 使用antd的Input return (
-<Input/>
+<Input key={path} /> ) }
}
page构建器可以不用,因为它只会生成一个组件,不会参与循环生成中。
我们只需要将所有的组件使用path作为key即可。
目前为止,我们设计了一套十分精简的根据Schema节点转换为ReactNode的构建引擎,以一套基于antd组件的组件构建引擎,通过接收JSON遍历节点构建出ReactNode,再交给React渲染出对应结构的页面。该构建引擎需要考虑,React渲染时候元素的上下文,所以在遍历元素节点的时候,需要把相关的上下文进行封装并交给对应的构建Builder作为key使用。当然,渲染部分还有很多很多的处理以及各种基本UI元素的创建还有很多的方法(譬如CDN挂载基础类型等),但是基于本系列,我们由浅入深逐步建立整个低代码平台。下篇文章,笔者将开始介绍设计器Designer的实现。
为大家准备了一个前端资料包。包含54本,2.57G的前端相关电子书,《前端面试宝典(附答案和解析)》,难点、重点知识视频教程(全套)。



有需要的小伙伴,可以点击下方卡片领取,无偿分享
假设我做了一个模块如下:m=Module.newdoclassCendend三个问题:除了对m的引用之外,还有什么方法可以访问C和m中的其他内容?我可以在创建匿名模块后为其命名吗(就像我输入“module...”一样)?如何在使用完匿名模块后将其删除,使其定义的常量不再存在? 最佳答案 三个答案:是的,使用ObjectSpace.此代码使c引用你的类(class)C不引用m:c=nilObjectSpace.each_object{|obj|c=objif(Class===objandobj.name=~/::C$/)}当然这取决于
作为我的Rails应用程序的一部分,我编写了一个小导入程序,它从我们的LDAP系统中吸取数据并将其塞入一个用户表中。不幸的是,与LDAP相关的代码在遍历我们的32K用户时泄漏了大量内存,我一直无法弄清楚如何解决这个问题。这个问题似乎在某种程度上与LDAP库有关,因为当我删除对LDAP内容的调用时,内存使用情况会很好地稳定下来。此外,不断增加的对象是Net::BER::BerIdentifiedString和Net::BER::BerIdentifiedArray,它们都是LDAP库的一部分。当我运行导入时,内存使用量最终达到超过1GB的峰值。如果问题存在,我需要找到一些方法来更正我的代
我有一个模型:classItem项目有一个属性“商店”基于存储的值,我希望Item对象对特定方法具有不同的行为。Rails中是否有针对此的通用设计模式?如果方法中没有大的if-else语句,这是如何干净利落地完成的? 最佳答案 通常通过Single-TableInheritance. 关于ruby-on-rails-Rails-子类化模型的设计模式是什么?,我们在StackOverflow上找到一个类似的问题: https://stackoverflow.co
如何在buildr项目中使用Ruby?我在很多不同的项目中使用过Ruby、JRuby、Java和Clojure。我目前正在使用我的标准Ruby开发一个模拟应用程序,我想尝试使用Clojure后端(我确实喜欢功能代码)以及JRubygui和测试套件。我还可以看到在未来的不同项目中使用Scala作为后端。我想我要为我的项目尝试一下buildr(http://buildr.apache.org/),但我注意到buildr似乎没有设置为在项目中使用JRuby代码本身!这看起来有点傻,因为该工具旨在统一通用的JVM语言并且是在ruby中构建的。除了将输出的jar包含在一个独特的、仅限ruby
在rails源中:https://github.com/rails/rails/blob/master/activesupport/lib/active_support/lazy_load_hooks.rb可以看到以下内容@load_hooks=Hash.new{|h,k|h[k]=[]}在IRB中,它只是初始化一个空哈希。和做有什么区别@load_hooks=Hash.new 最佳答案 查看rubydocumentationforHashnew→new_hashclicktotogglesourcenew(obj)→new_has
我有一个包含模块的模型。我想在模块中覆盖模型的访问器方法。例如:classBlah这显然行不通。有什么想法可以实现吗? 最佳答案 您的代码看起来是正确的。我们正在毫无困难地使用这个确切的模式。如果我没记错的话,Rails使用#method_missing作为属性setter,因此您的模块将优先,阻止ActiveRecord的setter。如果您正在使用ActiveSupport::Concern(参见thisblogpost),那么您的实例方法需要进入一个特殊的模块:classBlah
我将应用程序升级到Rails4,一切正常。我可以登录并转到我的编辑页面。也更新了观点。使用标准View时,用户会更新。但是当我添加例如字段:name时,它不会在表单中更新。使用devise3.1.1和gem'protected_attributes'我需要在设备或数据库上运行某种更新命令吗?我也搜索过这个地方,找到了许多不同的解决方案,但没有一个会更新我的用户字段。我没有添加任何自定义字段。 最佳答案 如果您想允许额外的参数,您可以在ApplicationController中使用beforefilter,因为Rails4将参数
我有一个用户工厂。我希望默认情况下确认用户。但是鉴于unconfirmed特征,我不希望它们被确认。虽然我有一个基于实现细节而不是抽象的工作实现,但我想知道如何正确地做到这一点。factory:userdoafter(:create)do|user,evaluator|#unwantedimplementationdetailshereunlessFactoryGirl.factories[:user].defined_traits.map(&:name).include?(:unconfirmed)user.confirm!endendtrait:unconfirmeddoenden
我刚刚被困在这个问题上一段时间了。以这个基地为例:moduleTopclassTestendmoduleFooendend稍后,我可以通过这样做在Foo中定义扩展Test的类:moduleTopmoduleFooclassSomeTest但是,如果我尝试通过使用::指定模块来最小化缩进:moduleTop::FooclassFailure这失败了:NameError:uninitializedconstantTop::Foo::Test这是一个错误,还是仅仅是Ruby解析变量名的方式的逻辑结果? 最佳答案 Isthisabug,or
我的主要目标是能够完全理解我正在使用的库/gem。我尝试在Github上从头到尾阅读源代码,但这真的很难。我认为更有趣、更温和的踏脚石就是在使用时阅读每个库/gem方法的源代码。例如,我想知道RubyonRails中的redirect_to方法是如何工作的:如何查找redirect_to方法的源代码?我知道在pry中我可以执行类似show-methodmethod的操作,但我如何才能对Rails框架中的方法执行此操作?您对我如何更好地理解Gem及其API有什么建议吗?仅仅阅读源代码似乎真的很难,尤其是对于框架。谢谢! 最佳答案 Ru