低代码平台前端的设计与实现(一)渲染模块的基本实现

这两年低代码平台的话题愈来愈火,一眼望去全是关于低代码开发的概念,鲜有关于低代码平台的设计实现。本文将以实际的代码入手,逐步介绍如何打造一款低开的平台。

低开概念我们不再赘述,但对于低开的前端来说,至少要有以下3个要素:

  1. 使用能被更多用户(甚至不是开发人员)容易接受的DSL(领域特定语言),用以描述页面结构以及相关UI上下文。
  2. 内部具有渲染引擎,用以渲染DSL JSON到实际页面元素。
  3. 提供设计器(Designer)支持以拖拉拽方式来快速处理DSL,方便用户快速完成页面设计。

本文我们首先着眼于如何进行渲染,后面的文章我们再详细介绍设计器的实现思路。

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,但是数据如果没有渲染在界面上,是没有任何意义的。我们必须要有渲染引擎支持将JSON转换为web页面的内容。

类型渲染器(TypeRenderer)

首先我们需要定义基本的渲染器:TypeRenderer。其作用是和ElementNode.type相绑定,一个type对应一个renderer。

import {ReactNode} from "react";
import {ElementNode} from "./ElementNode";

/**
 * 渲染器渲染上下文,至少包含ElementNode的相关数据
 */
export interface TypeRendererContext {
    elementNode: Omit<ElementNode, ''>;
}

/**
 * 绑定Type的渲染器
 */
export interface TypeRenderer {
    /**
     * 根据ElementNode上下文信息,得到JXS.Element,供React渲染
     * @param rendererContext 渲染器接受的数据上下文
     * @param childrenReactNode 已经完成渲染的子节点的ReactNode
     */
    render(
        rendererContext: TypeRendererContext,
        childrenReactNode?: ReactNode[],
    ): JSX.Element;
}

/**
 * TypeRenderer构造函数类型
 */
export type TypeRendererConstructor = new (args: any) => TypeRenderer;

这里的TypeRenderer只是接口抽象,具体的实现,是需要根据type来创建对应的renderer实例。

010-TypeRenderer-flow

这里我们先简单实现page、button和input:

// type = 'page'的renderer,使用div作为实际组件
export class PageRenderer implements TypeRenderer {

    render(rendererContext: TypeRendererContext,
           childrenReactNode?: ReactNode[]): JSX.Element {
        const style: CSSProperties = {
            width: '100%',
            height: '100%',
            padding: '10px'
        }
        // 对于type = 'page',就是用一个div进行渲染
        // 注意,对于容器类组件,始终需要将传入的子元素放到对应的位置,控制子元素的展示
        return (
            <div style={style}>
                {childrenReactNode}
            </div>
        )
    }
}
// type = 'button'的renderer,使用antd的Button作为实际组件
export class ButtonRenderer implements TypeRenderer {

    render(rendererContext: TypeRendererContext,
           childrenReactNode?: ReactNode[]): JSX.Element {
        const {elementNode = {}} = rendererContext;
        const {text = 'button'} = elementNode;
        return (
            <Button
                type='primary'>
                {text}
            </Button>
        )
    }
    
}
// type = 'input'的renderer,使用antd的Input作为实际组件
export class InputRenderer implements TypeRenderer {
    render(rendererContext: TypeRendererContext,
           childrenReactNode?: ReactNode[]): JSX.Element {
        return (
            <Input/>
        )
    }
}

实际上,每个renderer具体返回的组件,都可以任意根据要求进行定制开发,后续我们会深入介绍这一块的内容。但需要再次强调,正如上面PageRenderer中的注释一样,对于容器类组件,需要将childrenReactNode放到对应的节点位置,才能正常渲染所有的子元素。

实现了renderer以后,为了方便管理,我们使用一个所谓的TypeRendererManager(渲染器管理器)来管理我们定义的所有的TypeRenderer:

import {TypeRenderer, TypeRendererConstructor} from "./TypeRenderer";
import {PageRenderer} from "./impl/PageRenderer";
import {ButtonRenderer} from "./impl/ButtonRenderer";
import {InputRenderer} from "./impl/InputRenderer";


/**
 * TypeRenderer管理器
 */
class TypeRendererManager {

    /**
     * 单实例
     * @private
     */
    private static instance: TypeRendererManager;

    /**
     * 内存单例获取
     */
    static getInstance(): TypeRendererManager {
        if (!TypeRendererManager.instance) {
            TypeRendererManager.instance = new TypeRendererManager();
        }
        return TypeRendererManager.instance;
    }

    /**
     * 单例,构造函数private控制
     * @private
     */
    private constructor() {
    }

    /**
     * 这里记录了目前所有的TypeRenderer映射,
     * 后续可以优化为程序进行扫描实现,不过是后话了
     * @private
     */
    private typeRendererConstructors: Record<string, TypeRendererConstructor> = {
        page: PageRenderer,
        button: ButtonRenderer,
        input: InputRenderer
    };

    /**
     * 根据元素类型得到对应渲染器
     * @param elementType
     */
    getTypeRenderer(elementType: string): TypeRenderer {
        if (!this.typeRendererConstructors.hasOwnProperty(elementType)) {
            throw new Error('找不到处理')
        }
        // 采用ES6的Reflect反射来处理对象创建,供后续扩展优化
        return Reflect.construct(this.typeRendererConstructors[elementType], [])
    }
}

export {
    TypeRendererManager
}

渲染引擎(RenderEngine)

接下来是实现我们的渲染引擎(RenderEngine,叫引擎高大上)。

import {ElementNode} from "./ElementNode";
import {TypeRendererManager} from "./TypeRendererManager";

/**
 * 渲染引擎
 */
export class RenderEngine {

    /**
     * 构建:通过传入ElementNode信息,得到该节点对应供React渲染的ReactNode
     * @param rootEleNode
     */
    build(rootEleNode: ElementNode): JSX.Element | undefined {
        return this.innerBuild(rootEleNode);
    }

    /**
     * 构建:通过传入ElementNode信息,得到该节点对应供React渲染的ReactNode
     * @param rootEleNode
     */
    private innerBuild(rootEleNode: ElementNode): JSX.Element | undefined {
        if (!rootEleNode) {
            return undefined;
        }
        const {type, children} = rootEleNode;
        // 通过 typeRendererManager 来统一查找对应ElementType的Renderer
        const typeRenderer = TypeRendererManager.getInstance().getTypeRenderer(type);
        if (!typeRenderer) {
            console.warn(`找不到type="${type}"的renderer`)
            return undefined;
        }
        // 递归调用自身,获取子元素处理后的ReactNode
        const childrenReactNode =
            (children || []).map((childEleNode) => {
                return this.innerBuild(childEleNode)
            });
        const reactNode = typeRenderer.render(
            {elementNode: rootEleNode},
            childrenReactNode
        )
        return reactNode;
    }
}

目前的代码并不复杂,流程如下:

020-RenderEngine-handle-flow-v1

需要注意,这个Engine的公共API是build,由外部调用,仅需要传入根节点ElementNode即可得到整个节点数的UI组件树。但是为了后续我们优化内部的API结构,我们内部使用innerBuild作为内部处理的实际方法。

效果展示

建立一个样例项目,编写一个简单的样例:

const renderEngine = new RenderEngine();

export function SimpleExample() {
    const [elementNodeJson, setElementNodeJson] = useState(JSON.stringify({
        "type": "page",
        "backgroundColor": "pink", // page的 backgroundColor 配置
        "children": [
            {
                "type": "button",
                "size": "blue" // button的size配置
            },
            {
                "type": "input"
            }
        ]
    }, null, 2))

    const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
        const value = e.target.value;
        setElementNodeJson(value);
    }

    let reactNode;
    try {
        const eleNode = JSON.parse(elementNodeJson);
        reactNode = renderEngine.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.TextArea
                    autoSize={{ minRows: 2, maxRows: 10 }}
                    value={elementNodeJson} onChange={onChange}/>
            </div>
            <div style={{width: '100%', height: 'calc(50%)', border: '1px solid gray'}}>
                {reactNode}
            </div>
        </div>
    );
}

030-simple-example-show

设计优化

路径设计

目前为止,我们已经设计了一个简单的渲染引擎。但是还有两个需要解决的问题:

  1. 循环创建的ReactNode数组没有添加key,会导致渲染性能问题。
  2. 渲染的过程中,无法定位当前ElementNode的所在位置。

我们先讨论问题2。对于该问题具体是指:TypeRenderer.render方法接受的入参可以知道当前ElementNode节点自身的信息,但是却无法知道ElementNode所在的位置具体处于整体ElementNode的哪个位置。

{
    "type": "page",
    "children": [
        {
            "type": "panel",
            "children": [
                {    
                    "type": "input"
                },
                {
                    "type": "button",
                }
            ]
        },
        {    
            "type": "input"
        }
    ]
}

对于上述的每一个type,都应当有其标志其唯一的一个key。可以知道,每一个元素的路径是唯一的:

  • page:/page
  • panel:/page/panel@0
  • 第一个input:/page/panel@0/input@0。page下面有个panel(面板)元素,位于page的子节点第0号位置(基于0作为起始)。panel下面有个input元素,位于panel的子节点第0号位置。
  • button:/page/panel@0/button@1
  • 第二个input:/page/input@1

也就是说,路径由'/'拼接,每一级路径由'@'分割type和index,type表明该节点类型,index表明该节点处于上一级节点(也就是父级节点)的children数组的位置(基于0起始)。

那么,如何生成这样一个路径信息呢?逐级遍历ElementNode即可。其实遍历的这个动作,我们已经在之前渲染引擎的innerBuild地方进行过了(递归),现在只需要进行简单的修改方法:

// RenderEngine.ts代码
-    private innerBuild(rootEleNode: ElementNode): JSX.Element | undefined {
+    private innerBuild(rootEleNode: ElementNode, path: string): JSX.Element | undefined {
         if (!rootEleNode) {
             return undefined;
         }
// ... ...
         // 递归调用自身,获取子元素处理后的ReactNode
         const childrenReactNode =
-            (children || []).map((childEleNode) => {
-                return this.innerBuild(childEleNode)
+            (children || []).map((childEleNode, index) => {
+                // 子元素路径:
+                // 父级路径(也就是当前path)+ '/' + 子元素类型 + 子元素所在索引
+                const childPath = `${path}/${childEleNode.type}@${index}`;
+                return this.innerBuild(childEleNode, childPath)
             });
         const reactNode = typeRenderer.render(
             {elementNode: rootEleNode},
// ... ...

首先,我们修改了innerBuild方法入参,增加了参数path,用以表示当前节点所在的路径;其次,在生成子元素ReactNode的地方,将path作为基准,根据上述规则"${elementType}@${index}",来生成子元素节点的路径,并传入到的递归调用的innerBuild中。

当然,build内部调用innerBuild的时候,需要构造一个起始节点的path,传入innerBuild。

// RenderEngine.ts代码
     build(rootEleNode: ElementNode): JSX.Element | undefined {
-        return this.innerBuild(rootEleNode);
+        // 起始节点,需要构造一个起始path传入innerBuild
+        // 注意,根节点由于不属于某一个父级的子元素,所以不存在'@${index}'
+        return this.innerBuild(rootEleNode, '/' + rootEleNode.type);
     }

另外,为了让每一个renderer能够获取到需要渲染的ElementNode的路径信息这个上下文,我们在TypeRendererContext中添加path属性:

 export interface TypeRendererContext {
+    /**
+     * path:让每个TypeRenderer知道当前渲染的元素所在的路径
+     */
+    path: string;
     elementNode: Omit<ElementNode, ''>;
 }

同时,innerBuild中也要进行一定的修改,需要在调用TypeRender.render方法的时候把path传入:

        // innerBuild函数
        // ...
        const reactNode = typeRenderer.render(
-            {elementNode: rootEleNode},
+            {path: path, elementNode: rootEleNode},
            childrenReactNode
        )
        // ...

这样一来,每个renderer的render方法里面,都可以从RenderContext中获取到当前实际渲染的ElementNode唯一具体路径path。在后续的优化中,我们就可以利用该path做一些事情了。

现在,如何处理问题1:key值未填写的问题呢?其实,当我们解决了问题2以后,我们现在知道path是唯一的,那么我们可以将path作为每个元素的key,例如:

Button渲染器:

export class ButtonRenderer implements TypeRenderer {

     render(rendererContext: TypeRendererContext,
            childrenReactNode?: ReactNode[]): JSX.Element {
-        const {elementNode = {}} = rendererContext;
+        const {path, elementNode = {}} = rendererContext;
         const {text = 'button'} = elementNode;
         return (
             <Button
+                key={path}
                 type='primary'>
                 {text}
             </Button>)
     }
}

Input渲染器:

 export class InputRenderer implements TypeRenderer{
     render(rendererContext: TypeRendererContext,
            childrenReactNode?: ReactNode[]): JSX.Element {
+        const {path} = rendererContext;
         return (
-            <Input />
+            <Input key={path}/>
         )
     }
 }

我们只需要将所有的组件使用path作为key即可

关于构建与渲染的总结

目前为止,我们设计了一套十分精简的渲染引擎,以一套基于antd组件的组件渲染器,通过接受JSON,渲染出对应结构的页面。该渲染器需要考虑,渲染时候元素的上下文,所以在遍历元素节点的时候,需要把相关的上下文进行封装并交给对应的渲染用于自行处理。当然,渲染部分还有很多很多的处理以及各种基本UI元素的创建还有很多的方法(譬如CDN挂载基础类型等),但是基于本系列,我们由浅入深逐步建立整个低代码平台。下篇文章,笔者将开始介绍设计器Designer的实现。

附录

本章内容对应代码已经推送到github上

w4ngzhen/light-lc (github.com)

main分支与最新文章同步,chapterXX对应本系列的第几章,本文在分支chapter01上体现。

且按照文章里各段介绍顺序完成了提交:

modify: use 'path' as key for component.
0535765 modify: add path info for innerBuild.
9d1007b add: SimpleExample.
7658f83 add: root index.ts for exporting all types and instance.
74f9089 add: RenderEngine for build UI component.
3bc90cb add: TypeRendererManager for managing all TypeRenderer instance.
42083f4 add: TypeRenderer and implements.
be4d31f add: ElementNode 映射schema节点.
d62f830 init config for project
Tags: