這兩年低程式碼平臺的話題愈來愈火,一眼望去全是關於低程式碼開發的概念,鮮有關於低程式碼平臺的設計實現。本文將以實際的程式碼入手,逐步介紹如何打造一款低開的平臺。
低開概念我們不再贅述,但對於低開的前端來說,至少要有以下3個要素:
本文我們首先著眼於如何進行渲染,後面的文章我們再詳細介紹設計器的實現思路。
對於頁面UI來說,我們總是可以將介面通過樹狀結構進行描述:
1. 頁面
1-1. 標題
1-1-1. 文字
1-2. 內容面板
1-2-1. 一個輸入框
如果採用xml來描述,可以是如下的形式:
<page>
<title>標題文字</title>
<content>
<input></input>
</content>
</page>
當然,xml作為DSL有以下的兩個問題:
自然,我們很容易想到另一個資料描述方案:JSON。使用JSON來描述上述的頁面,我們可以如下設計:
{
"type": "page",
"children": [
{
"type": "title",
"value": "標題文字"
},
{
"type": "content",
"children": [
{
"type": "input"
}
]
}
]
}
初看JSON可能覺得內容比起xml更多,但是在前端我們擁有原生處理JSON的能力,這一點就很體現優勢。
回顧一下JSON的方案,我們首先定義一個基本的資料結構:元素節點(ElementNode
),它至少有如下的內容:
例如,對於一個頁面(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
。其作用是和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範例。
這裡我們先簡單實現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
,叫引擎高大上)。
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;
}
}
目前的程式碼並不複雜,流程如下:
需要注意,這個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>
);
}
目前為止,我們已經設計了一個簡單的渲染引擎。但是還有兩個需要解決的問題:
我們先討論問題2。對於該問題具體是指:TypeRenderer.render方法接受的入參可以知道當前ElementNode節點自身的資訊,但是卻無法知道ElementNode所在的位置具體處於整體ElementNode的哪個位置。
{
"type": "page",
"children": [
{
"type": "panel",
"children": [
{
"type": "input"
},
{
"type": "button",
}
]
},
{
"type": "input"
}
]
}
對於上述的每一個type,都應當有其標誌其唯一的一個key。可以知道,每一個元素的路徑是唯一的:
也就是說,路徑由'/'
拼接,每一級路徑由'@'
分割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