低程式碼平台前端的設計與實現(一)渲染模組的基本實現

這兩年低程式碼平台的話題愈來愈火,一眼望去全是關於低程式碼開發的概念,鮮有關於低程式碼平台的設計實現。本文將以實際的程式碼入手,逐步介紹如何打造一款低開的平台。

低開概念我們不再贅述,但對於低開的前端來說,至少要有以下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