如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

RxEditor是一款開(kāi)源企業(yè)級(jí)可視化低代碼前端,目標(biāo)是可以編輯所有 HTML 基礎(chǔ)的組件。比如支持 React、VUE、小程序等,目前僅實(shí)現(xiàn)了 React 版。

RxEditor運(yùn)行快照:

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

項(xiàng)目地址:https://github.com/rxdrag/rxeditor

演示地址( Vercel 部署,需要科學(xué)的方法才能訪問(wèn)):https://rxeditor.vercel.app/

本文介紹RxEditor 設(shè)計(jì)實(shí)現(xiàn)方法,盡可能包括技術(shù)選型、軟件架構(gòu)、具體實(shí)現(xiàn)中碰到的各種小坑、預(yù)覽渲染、物料熱加載、前端邏輯編排等內(nèi)容。

注:為了方便理解,文中引用的代碼濾除了細(xì)節(jié),是實(shí)際實(shí)現(xiàn)代碼的簡(jiǎn)化版

設(shè)計(jì)原則

  • 盡量減少對(duì)組件的入侵,最大程度使用已有組件資源。
  • 配置優(yōu)先,腳本輔助。
  • 基礎(chǔ)功能原子化,組合式設(shè)計(jì)。
  • 物料插件化、邏輯組件化,盡可能動(dòng)態(tài)插入系統(tǒng)。

基礎(chǔ)原理

項(xiàng)目的設(shè)計(jì)目標(biāo),是能夠通過(guò)拖拽的方式操作基于 HTML 制作的組件,如:調(diào)整這些組件的包含關(guān)系,并設(shè)置組件屬性。

不管是 React、Vue、Angluar、小程序,還是別的類似前端框架,最終都是要把 JS 組件,以DOM節(jié)點(diǎn)的形式渲染出來(lái)。

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

編輯器(RxEditor)要維護(hù)一個(gè)樹(shù)形模型,這個(gè)模型描述的是組件的隸屬關(guān)系,以及 props。同時(shí)還能跟 dom 樹(shù)交互,通過(guò)各種 dom 事件,操作組件模型樹(shù)。

這里關(guān)鍵的一個(gè)點(diǎn)是,編輯器需要知道 dom 節(jié)點(diǎn)跟組件節(jié)點(diǎn)之間的對(duì)應(yīng)關(guān)系。在不侵入組件的前提下,并且還要忽略前端庫(kù)的差異,比較理想的方法是給 dom 節(jié)點(diǎn)賦一個(gè)特殊屬性,并跟模型中組件的 id 對(duì)應(yīng),在 RxEditor 中,這個(gè)屬性是rx-id,比如在dom節(jié)點(diǎn)中這樣表示:

<div rx-id="one-uuid"> </div>

編輯器監(jiān)聽(tīng) dom 事件,通過(guò)事件的 target 的 rx-id 屬性,就可以識(shí)別其在模型中對(duì)應(yīng)組件節(jié)點(diǎn)。也可以通過(guò) document.querySelector([rx-id="${id}"])方法,查找組件對(duì)應(yīng)的 dom 節(jié)點(diǎn)。

除此之外,還加了 rx-node-type 跟 rx-status 這兩個(gè)輔助屬性。rx-node-type 屬性主要用來(lái)識(shí)別是工具箱的Resource、畫(huà)布內(nèi)的普通節(jié)點(diǎn)還是編輯器輔助組件,rx-status 計(jì)劃是多模塊編輯使用,不過(guò)目前該功能尚未實(shí)現(xiàn)。

rx-id 算是設(shè)計(jì)器的基礎(chǔ)性原理,它給設(shè)計(jì)器內(nèi)核抹平了前端框架的差異,幾乎貫穿設(shè)計(jì)器的所有部分。

Schema 定義

編輯器操作的是JSON格式的組件樹(shù),設(shè)計(jì)時(shí),設(shè)計(jì)引擎根據(jù)這個(gè)組件樹(shù)渲染畫(huà)布;預(yù)覽時(shí),執(zhí)行引擎根據(jù)這個(gè)組件樹(shù)渲染實(shí)際頁(yè)面;代碼生成時(shí),可以把這個(gè)組件樹(shù)生成代碼;保存時(shí),直接把它序列化存儲(chǔ)到數(shù)據(jù)庫(kù)或者文件。這個(gè)組件樹(shù)是設(shè)計(jì)器的數(shù)據(jù)模型,通常會(huì)被叫做 Schema。

像阿里的 formily,它的Schema 依據(jù)的是JSON Schema 規(guī)范,并在上面做了一些擴(kuò)展,他在描述父子關(guān)系的時(shí)候,用的是properties鍵值對(duì):

{ <---- RecursionField(條件:object;渲染權(quán):RecursionField) "type":"object", "properties":{ "username":{ <---- RecursionField(條件:string;渲染權(quán):RecursionField) "type":"string", "x-component":"Input" }, "phone":{ <---- RecursionField(條件:string;渲染權(quán):RecursionField) "type":"string", "x-component":"Input", "x-validator":"phone" }, "email":{ <---- RecursionField(條件:string;渲染權(quán):RecursionField) "type":"string", "x-component":"Input", "x-validator":"email" }, ...... }}

用鍵值對(duì)的方式存子組件(children)有幾個(gè)明顯的問(wèn)題:

  • 用這樣的方式渲染預(yù)覽界面時(shí),一個(gè)字段只能綁定一個(gè)控件,無(wú)法綁定多個(gè),因?yàn)閗ey值唯一。
  • 鍵值對(duì)不攜帶順序信息,存儲(chǔ)到數(shù)據(jù)庫(kù)JSON類型的字段時(shí),具體的后端實(shí)現(xiàn)語(yǔ)言要進(jìn)行序列化與反序列化的操作,不能保證順序,為了避免出問(wèn)題,不得不加一個(gè)類似index的字段來(lái)記錄順序。
  • 設(shè)計(jì)器引擎內(nèi)部操作時(shí),用的是數(shù)組的方式記錄數(shù)據(jù),傳輸?shù)胶蠖舜鎯?chǔ)時(shí),不得不進(jìn)行轉(zhuǎn)換。
    鑒于上述問(wèn)題,RxEditor采用了數(shù)組的形式來(lái)記錄Children,與React跟Vue控件比較接近的方式:

export interface INodeMeta<IField = any, IReactions = any> { componentName: string, props?: { [key: string]: any, }, "x-field"?: IField, "x-reactions"?: IReactions,}export interface INodeSchema<IField = any, IReactions = any> extends INodeMeta<IField, IReactions> { children?: INodeSchema[] slots?: { [name: string]: INodeSchema | undefined }}

上面formily的例子,相應(yīng)轉(zhuǎn)換成:

{ "componentName":"Profile", "x-field":{ "type":"object", "name":"user" }, "chilren":[ { "componentName":"Input", "x-field":{ "type":"string", "name":"username" } }, { "componentName":"Input", "x-field":{ "type":"string", "name":"phone" } }, { "componentName":"Input", "x-field":{ "type":"string", "name":"email", "rule":"email" } } ]}

其中 x-field 是表單數(shù)據(jù)的定義,x-reactions 是組件控制邏輯,通過(guò)前端編排來(lái)實(shí)現(xiàn),這兩個(gè)后面會(huì)詳細(xì)介紹。

需要注意的是卡槽(slots),這個(gè)是 RxEditor 的原創(chuàng)設(shè)計(jì),原生 Schema 直接支持卡槽,可以很大程度上支持現(xiàn)有組件,比如很多 React antd 組件,不需要封裝就可以直接拉到設(shè)計(jì)器里來(lái)用,關(guān)于卡槽后面還會(huì)有更詳細(xì)的介紹。

組件形態(tài)

項(xiàng)目中的前端組件,要在兩個(gè)地方渲染,一是設(shè)計(jì)引擎的畫(huà)布,另一處是預(yù)覽頁(yè)面。這兩處使用的是不同渲染引擎,對(duì)組件的要求也不一樣,所以把組件分定義為兩個(gè)形態(tài):

  • 設(shè)計(jì)形態(tài),在設(shè)計(jì)器畫(huà)布內(nèi)渲染,需要提供ref或者轉(zhuǎn)發(fā)rx-id,有能力跟設(shè)計(jì)引擎交互。
  • 預(yù)覽形態(tài),預(yù)覽引擎使用,渲染機(jī)制跟運(yùn)行時(shí)渲染一樣。相當(dāng)于普通的前端組件。

設(shè)計(jì)形態(tài)的組件跟預(yù)覽形態(tài)的組件,對(duì)應(yīng)的是同一份schema,只是在渲染時(shí),使用不同的組件實(shí)現(xiàn)。

接下來(lái),以React為例,詳細(xì)介紹組件設(shè)計(jì)形態(tài)與預(yù)覽形態(tài)之間的區(qū)別與聯(lián)系,同時(shí)也介紹了如何制作設(shè)計(jì)形態(tài)的組件。

有 React ref 的組件

這部分組件是最簡(jiǎn)單的,直接拿過(guò)來(lái)使用就好,這些組件的設(shè)計(jì)形態(tài)跟預(yù)覽形態(tài)是一樣的,在設(shè)計(jì)引擎這樣渲染:

export const ComponentDesignerView = memo((props: { nodeId: string }) => { const { nodeId } = props; //獲取數(shù)據(jù)模型樹(shù)中對(duì)應(yīng)的節(jié)點(diǎn) const node = useTreeNode(nodeId); //通過(guò)ref,給 dom 賦值rx-id const handleRef = usecallback((element: HTMLElement | undefined) => { element?.setAttribute("rx-id", node.id) }, [node.id]) //拿到設(shè)計(jì)形態(tài)的組件 const Component = useDesignComponent(node?.meta?.componentName); return (<Component ref={handleRef} {...realProps} > </Component>)}))

只要 rx-id 被添加到 dom 節(jié)點(diǎn)上,就建立了 dom 與設(shè)計(jì)器內(nèi)部數(shù)據(jù)模型的聯(lián)系。

預(yù)覽引擎的渲染相對(duì)更簡(jiǎn)單直接:

export type ComponentViewProps = { node: IComponentRenderSchema,}export const ComponentView = memo(( props: ComponentViewProps) => { const { node, ...other } = props //拿到預(yù)覽形態(tài)的組件 const Component = usePreviewComponent(node.componentName) return ( <Component {...node.props} {...other}> { node.children?.map(child => { return (<ComponentView key={child.id} node={child} />) }) } </Component> )})

無(wú)ref,但可以把未知屬性轉(zhuǎn)發(fā)到合適的dom節(jié)點(diǎn)上

比如一個(gè)React組件,實(shí)現(xiàn)方式是這樣的:

export const ComponentA = (props)=>{ const {propA, propB, ...rest} = props ... return( <div {...rest}> ... </div> )}

除了 propA 跟 propB,其它的屬性被原封不動(dòng)的轉(zhuǎn)發(fā)到了根div上,這樣的組件在設(shè)計(jì)引擎里面可這樣渲染:

export const ComponentDesignerView = memo((props: { nodeId: string }) => { const { nodeId } = props; //獲取數(shù)據(jù)模型樹(shù)中對(duì)應(yīng)的節(jié)點(diǎn) const node = useTreeNode(nodeId); //拿到設(shè)計(jì)形態(tài)的組件 const Component = useDesignComponent(node?.meta?.componentName); return (<Component rx-id={node.id} {...node?.meta?.props} > </Component>)}))

通過(guò)這樣的方式,rx-id 被同樣添加到 dom 節(jié)點(diǎn)上,從而建立了數(shù)據(jù)模型與 dom之間的關(guān)聯(lián)。

通過(guò)組件 id 拿到 ref

有的組件,既不能提供合適的ref,也不能轉(zhuǎn)發(fā)rx-id,但是這個(gè)組件有id屬性,可以通過(guò)唯一的id,來(lái)獲得對(duì)應(yīng) dom 的 ref:

export const WrappedComponentA = forwardRef((props, ref)=>{ const node = useNode() useLayoutEffect(() => { const element = node?.id ? document.getElementById(node?.id) : null if (isFunction(ref)) { ref(element) } }, [node?.id, ref]) return( <ComponentA id={node?.id} {...props}/> )})

提取成高階組件:

export function forwardRefById(WrappedComponent: ReactComponent): ReactComponent { return memo(forwardRef<HTMLInputElement>((props: any, ref) => { const node = useNode() useLayoutEffect(() => { const element = node?.id ? document.getElementById(node?.id) : null if (isFunction(ref)) { ref(element) } }, [node?.id, ref]) return <WrappedComponent id={node?.id} {...props} /> }))}export const WrappedComponentA = forwardRefById(ComponentA)

使用這種方式時(shí),要確保組件的id沒(méi)有其它用途。

嵌入隱藏元素

如果一個(gè)組件,通過(guò)上述方式安插 rx-id 都不合適,這個(gè)組件恰好有 children 的話,可以在 children 里面插入一個(gè)隱藏元素,通過(guò)隱藏元素 dom 的parentElement 獲取 ref,直接上高階組件:

const HiddenElement = styled.div` display: none;`export function forwardRefByChildren(WrappedComponent: ReactComponent): ReactComponent { return memo(forwardRef<HTMElement>((props: any, ref) => { const { children, ...rest } = props const handleRefChange = useCallback((element: HTMLElement | null) => { if (isFunction(ref)) { ref(element?.parentElement) } }, [ref]) return <WrappedComponent {...rest}> {children} <HiddenElement ref={handleRefChange} /> </WrappedComponent> }))}export const WrappedComponentA = forwardRefByChildren(ComponentA)

調(diào)整 ref 位置

有的組件,提供了 ref,但是 ref 位置并不合適,基于 ref 指示的 dom 節(jié)點(diǎn)畫(huà)編輯時(shí)的輪廓線的話,會(huì)顯的別扭,有個(gè)這樣實(shí)現(xiàn)的組件:

export const ComponentA = forwardRef<HTMElement>((props: any, ref) => { return (<div style={padding:16}> <div ref={ref}> ... </div> </div>)})

編輯時(shí)這個(gè)組件的輪廓線,會(huì)顯示在內(nèi)層 div,距離外層 div 差了16個(gè)像素。為了把rx-id插入到外層 div, 加入一個(gè)轉(zhuǎn)換 ref 的高階組件:

// 傳出真實(shí)ref用的回調(diào)export type Callback = (element?: HTMLElement | null) => HTMLElement | undefined | null;export const defaultCallback = (element?: HTMLElement | null) => element;export function switchRef(WrappedComponent: ReactComponent, callback: Callback = defaultCallback): ReactComponent { return memo(forwardRef<HTMLInputElement>((props: any, ref) => { const handleRefChange = useCallback((element: HTMLElement | null) => { if (isFunction(ref)) { ref(callback(element)) } }, [ref]) return <WrappedComponent ref={handleRefChange} {...props} /> }))}export const WrappedComponentA = forwardRefByChildren(ComponentA, element=>element?.parentElement)

組件外層包一個(gè) div

如果一個(gè)組件,既不能提供合適的ref,不能轉(zhuǎn)發(fā)rx-id,沒(méi)有id屬性,也沒(méi)有children,可以在組件外層直接包一個(gè) div,使用div 的 ref :

export const WrappedComponentA = forwardRef((props, ref)=>{ return( <div ref={ref}> <ComponentA {...props}/> </div> )})

提取成高階組件:

export type ReactComponent = React.FC<any> | React.ComponentClass<any> | stringexport function wrapWithRef(WrappedComponent: ReactComponent):ReactComponent{ return memo(forwardRef<HTMLDivElement>((props: any, ref) => { return <div ref = {ref}> <WrappedComponent {...props} /> </div }))}export const WrappedComponentA = wrapWithRef(ComponentA)

這個(gè)實(shí)現(xiàn)方式有個(gè)明顯的問(wèn)題,憑空添加了一個(gè)div,隔離了 css 上下文,為了保證設(shè)計(jì)器的顯示效果跟預(yù)覽時(shí)一樣,所見(jiàn)即所得,需要在組件的預(yù)覽形態(tài)上也加一個(gè)div,就是說(shuō)直接修改原生組件,設(shè)計(jì)形態(tài)跟預(yù)覽形態(tài)都使用轉(zhuǎn)換后的組件。即便是這樣,也像做不可描述的事情時(shí)帶T一樣,有些許不爽。

帶卡槽(slots)的組件

Vue 中有卡槽,分為具名卡槽跟不具名卡槽,不具名卡槽就是 children。React 中沒(méi)有明確的卡槽概念,但是React.ReactNode 類型的 props 就相當(dāng)于具名卡槽了。

在可視化設(shè)計(jì)器中,是需要卡槽的。

卡槽可以非常清晰的區(qū)分組建的各個(gè)區(qū)域,并且能很好地復(fù)用邏輯。

可視化編輯器中的拖拽,是把組件拖入(拖出)children(非具名卡槽),對(duì)于具名卡槽,這種普通拖放是無(wú)能無(wú)力的。

如果schema不支持卡槽,通常會(huì)特殊處理一下組件,就是在組件外封裝一層,并且還用不了高階組件。比如 antd 的 List 組件,它有 header 跟 footer 兩個(gè) React.ReactNode 類型的屬性,這就是兩個(gè)卡槽。要想在設(shè)計(jì)器中使用這兩個(gè)卡槽,設(shè)計(jì)形態(tài)的組件一般會(huì)這么寫:

import { List as AntdList, ListProps } from "antd"export type ListAddonProps = { hasHeader?: boolean, hasFooter?: boolean,}export const List = memo(forwardRef<HTMLDivElement>(( props: ListProps<any> & ListAddonProps, ref) => { const {hasHeader, hasFooter, children, ...rest} = props const footer = useMemo(()=>{ //這里根據(jù)Schema樹(shù)和children構(gòu)造footer卡槽 ... }, [children, hasFooter]) const header = useMemo(()=>{ //這里根據(jù)Schema樹(shù)和children構(gòu)造header卡槽 ... }, [children, hasHeader]) return(<AntdList header = {header} header={footer} {...rest}}/>)}

組件的設(shè)計(jì)形態(tài)也需要類似的封裝,這里就不詳細(xì)展開(kāi)了。

這個(gè)方式,相當(dāng)于把所有的具名卡槽轉(zhuǎn)換成非具名卡槽,然后在渲染的時(shí)候,再根據(jù)配置把非具名卡槽解析成具名卡槽。hasHeader這類屬性不設(shè)置,也能解析,只是換了種實(shí)現(xiàn)方式,并無(wú)本質(zhì)區(qū)別。

擁有具名卡槽的前端庫(kù)太多了,每一種組件都這樣處理,復(fù)雜而繁瑣,并且違背了設(shè)計(jì)原則:“盡量減少對(duì)組件的入侵,最大程度使用已有組件資源”。

基于這個(gè)因素,把卡槽(slots)放入了 schema,只需要在渲染的時(shí)候跟非具名卡槽稍微做一下區(qū)別,就可以插入插槽:

export type ComponentViewProps = { node: IComponentRenderSchema,}export const ComponentView = memo(( props: ComponentViewProps) => { const { node, ...other } = props //拿到預(yù)覽形態(tài)的組件 const Component = usePreviewComponent(node.componentName) //渲染卡槽 const slots = useMemo(() => { const slts: { [key: string]: React.ReactElement } = {} for (const name of Object.keys(node?.slots || {})) { const slot = node?.slots?.[name] if (slot) { slts[name] = <ComponentView node={slot} /> } } return slts }, [node?.slots]) return ( <Component {...node.props} {...slots} {...other}> { node.children?.map(child => { return (<ComponentView key={child.id} node={child} />) }) } </Component> )})

這是預(yù)覽形態(tài)的渲染代碼,設(shè)計(jì)形態(tài)類似,此處不詳細(xì)展開(kāi)了。

用這樣的方式處理卡槽,卡槽是不能被拖入的,只能通過(guò)屬性面板的配置打開(kāi)或者關(guān)閉卡槽:

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

并且,卡槽只能是一個(gè)獨(dú)立節(jié)點(diǎn),不能是節(jié)點(diǎn)數(shù)組,相當(dāng)于把React.ReactNode轉(zhuǎn)換成了React.ReactElement,不過(guò)這個(gè)轉(zhuǎn)換對(duì)用戶體驗(yàn)的影響并不大。

需要獨(dú)立制作設(shè)計(jì)形態(tài)的組件

通過(guò)上述各種高階組件、schema原生支持的slots,已有的組件,基本上不需要修改就可以納入可視化設(shè)計(jì)。

但是,也有例外。有些組件,還是需要獨(dú)立制作設(shè)計(jì)形態(tài)。需要獨(dú)立制作設(shè)計(jì)形態(tài)的組件,一般基于兩個(gè)方面的考慮:

  • 用戶體驗(yàn);
  • 業(yè)務(wù)邏輯復(fù)雜。
    在用戶體驗(yàn)方面,看一個(gè)例子,antd 的 Button 組件。Button的使用代碼:

<Button type="primary"> Primary Button</Button>

組件的children可以是 text 文本,text 文本不是一個(gè)組件,在編輯器中式很難被拖入的,要想拖入的話,可以加一個(gè)文本類型的組件 Text:

<Button type="primary"> <Text>Primary Button</Text></Button>

這樣就解決了拖放問(wèn)題,并且Text組件可以在很多地方被使用,也不算增加實(shí)體。但是這樣每個(gè)Button 嵌套一個(gè) Text方式,會(huì)大量增加設(shè)計(jì)器畫(huà)布中控件的數(shù)量,用戶體驗(yàn)并不好。這種情況,最好重寫B(tài)uton組件:

import {Button as AntdButton, ButtonProps} from "antd"export Button = memo(forwardRef<HTMLElement>( (props: ButtonProps&{title?:string}}, ref) => { const {title, ...rest} = props return (<AntdButton {...rest}> {title} </AntdButton>)}

進(jìn)一步提取為高階組件:

export function mapComponent(WrappedComponent: ReactComponent, maps: { [key: string]: string }): ReactComponent { return memo(forwardRef<HTMLElement>((props: any, ref) => { const mapedProps = useMemo(() => { const newProps = {} as any; for (const key of Object.keys(props || {})) { if (maps[key]) { newProps[maps[key]] = props?.[key] } else { newProps[key] = props?.[key] } } return newProps }, [props]) return ( <WrappedComponent ref={ref} {...mapedProps} /> ) }))}export const Button = mapComponent(AntdButton, { title: 'children' })

業(yè)務(wù)邏輯復(fù)雜的例子,典型的是table,設(shè)計(jì)形態(tài)跟預(yù)覽形態(tài)的區(qū)別:

設(shè)計(jì)形態(tài)

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

預(yù)覽形態(tài)

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

這種組件,是需要特殊制作的,沒(méi)有什么簡(jiǎn)單的辦法,具體實(shí)現(xiàn)請(qǐng)參考源碼。

Material,物料的定義

一個(gè)Schema,只是用來(lái)描述一個(gè)組件,這個(gè)組件相關(guān)的配置,比如多語(yǔ)言信息、在工具箱中的圖標(biāo)、編輯規(guī)則(比如:它可以被放置在哪些組件下,不能被放在什么組件下)等等這些信息,需要一個(gè)配置來(lái)描述,這個(gè)就是物料的定義。具體定義:

export interface IBehaviorRule { disabled?: boolean | AbleCheckFunction //默認(rèn)false selectable?: boolean | AbleCheckFunction //是否可選中,默認(rèn)為true droppable?: boolean | AbleCheckFunction//是否可作為拖拽容器,默認(rèn)為false draggable?: boolean | AbleCheckFunction //是否可拖拽,默認(rèn)為true deletable?: boolean | AbleCheckFunction //是否可刪除,默認(rèn)為true cloneable?: boolean | AbleCheckFunction //是否可拷貝,默認(rèn)為true resizable?: IResizable | ((engine?: IDesignerEngine) => IResizable) moveable?: IMoveable | ((engine?: IDesignerEngine) => IMoveable) // 可用于自由布局 allowChild?: (target: ITreeNode, engine?: IDesignerEngine,) => boolean allowAppendTo?: (target: ITreeNode, engine?: IDesignerEngine,) => boolean allowSiblingsTo?: (target: ITreeNode, engine?: IDesignerEngine,) => boolean noPlaceholder?: boolean, noRef?: boolean, lockable?: boolean,}export interface IComponentConfig<ComponentType = any> { //npm包名 生成代碼用 packageName?: string, //組件名稱,要唯一,可以加點(diǎn)號(hào):. componentName: string, //組件的預(yù)覽形態(tài) component: ComponentType, //組件的設(shè)計(jì)形態(tài) designer: ComponentType, //組件編輯規(guī)則,比如是否能作為另外組件的children behaviorRule?: IBehaviorRule //右側(cè)屬性面板的配置Schema designerSchema?: INodeSchema //組件的多語(yǔ)言資源 designerLocales?: ILocales //組件設(shè)計(jì)時(shí)的特殊props配置,比如Input組件的readOnly屬性 designerProps?: IDesignerProps //組件在工具箱中的配置 resource?: IResource //卡槽slots用到的組件,值為true時(shí),用缺省組件DefaultSlot, // string時(shí),存的是已經(jīng)注冊(cè)過(guò)的component resource名字 slots?: { [name: string]: IComponentConfig | true | string | undefined }, //右側(cè)屬性面板用的多語(yǔ)言資源 toolsLocales?: ILocales, //右側(cè)屬性面板用到的擴(kuò)展組件。是的,組合式設(shè)計(jì),都可以配置 tools?: { [name: string]: ComponentType | undefined },}

IBehaviorRule接口定義組建的編輯規(guī)則,隨著項(xiàng)目的逐步完善,這個(gè)接口大概率會(huì)變化,這里也沒(méi)必要在意這么細(xì)節(jié)的東西,要重點(diǎn)關(guān)注的是IComponentConfig接口,這就是一個(gè)物料的定義,泛型使用的ComponetType是為了區(qū)別前端差異,比如React的物料定義是這樣:

export type ReactComponent = React.FC<any> | React.ComponentClass<any> | stringexport interface IComponentMaterial extends IComponentConfig<ReactComponent> {}

物料如何使用

物料定義,包含了一個(gè)組件的所有內(nèi)容,直接注冊(cè)進(jìn)設(shè)計(jì)器,就可以使用。后面會(huì)有相關(guān)講述。

物料的熱加載

一個(gè)不想熱加載的低代碼平臺(tái),不是一個(gè)有出息的平臺(tái)。但是,這個(gè)版本并沒(méi)有來(lái)得及做熱加載,后續(xù)版本會(huì)補(bǔ)上。這里簡(jiǎn)單分享前幾個(gè)版本的熱加載經(jīng)驗(yàn)。

一個(gè)物料的定義是一個(gè)js對(duì)象,只要能拿到這個(gè)隊(duì)形,就可以直接使用。熱加載要解決的問(wèn)題式拿到,具體拿到的方式可能有這么幾種:

import

js 原生import可以引入遠(yuǎn)程定義的物料,但是這個(gè)方式有個(gè)明顯的缺點(diǎn),就是不能跨域。如果沒(méi)有跨域需求,可以用這種方式。

webpack組件聯(lián)邦

看網(wǎng)上介紹,這種方式似乎可行,但并沒(méi)有嘗試過(guò),有類似嘗試的朋友,歡迎留言。

src引入

這種方式可行的,并且以前的版本中已經(jīng)成功實(shí)現(xiàn),具體做法是在編譯的物料庫(kù)里,把物料的定義掛載到全局window對(duì)象上,在編輯器里動(dòng)態(tài)創(chuàng)建一個(gè) script 元素,在load事件中,從全局window對(duì)象上拿到定義,具體實(shí)現(xiàn):

function loadJS(src: string, clearCache = false): Promise<HTMLScriptElement> { const p = new Promise<HTMLScriptElement>((resolve, reject) => { const script = document.createElement("script", {}); script.type = "text/JavaScript"; if (clearCache) { script.src = src "?t=" new Date().getTime(); } else { script.src = src; } if (script.addEventListener) { script.addEventListener("load", () => { resolve(script) }); script.addEventListener("error", (e) => { console.log("Script錯(cuò)誤", e) reject(e) }); } document.head.appendChild(script); }) return p;}export function loadPlugin(url: string): Promise<IPlugin> { const path = trimUrl(url); const indexJs = path "index.js"; const p = new Promise<IPlugin>((resolve, reject) => { loadJS(indexJs, true) .then((script) => { //從全局window上拿到物料的定義 const rxPlugin = window.rxPlugin console.log("加載結(jié)果", window.rxPlugin) window.rxPlugin = undefined rxPlugin && resolve(rxPlugin); script?.remove(); }) .catch(err => { reject(err); }) }) return p;}

物料的單獨(dú)打包使用webpack,這個(gè)工具不是很熟練,勉強(qiáng)能用。有熟悉的大佬歡迎留言指導(dǎo)一下,不勝感激。

設(shè)計(jì)器的畫(huà)布目前使用的iframe,選擇iframe的原因,后面會(huì)有詳細(xì)介紹。使用iframe時(shí),相當(dāng)于一個(gè)應(yīng)用啟動(dòng)了兩套R(shí)eact,如果從設(shè)計(jì)器通過(guò)window對(duì)象,把物料傳給iframe畫(huà)布,react會(huì)報(bào)錯(cuò)。所以需要在iframe內(nèi)部單獨(dú)熱加載物料,切記!

狀態(tài)管理

如果不考慮其它前端庫(kù),只考慮React的話,狀態(tài)管理肯定會(huì)選擇recoil。如果要考慮vue、angular等其它前端,就只能放棄recoil,從知道的其它庫(kù)里選:redux、mobx、rxjs。

rxjs雖然看起來(lái)不錯(cuò),但是沒(méi)有使用經(jīng)驗(yàn),暫時(shí)放棄了。mobx,個(gè)人不喜歡,與上面的設(shè)計(jì)原則“盡量減少對(duì)組件的入侵,最大程度使用已有組件資源”相悖,也只能放棄。最后,選擇了Redux。

雖然Redux的代碼看起來(lái)會(huì)繁瑣一些,好在這種可視化項(xiàng)目本身的狀態(tài)并不多,這種繁瑣度是可以接受的。

在使用過(guò)程中發(fā)現(xiàn),Redux做低代碼狀態(tài)管理,有很多不錯(cuò)的優(yōu)勢(shì)。足夠輕量,數(shù)據(jù)的流向清晰明了,可以精確控制訂閱。并且,Redux對(duì)配置是友好的,在可視化業(yè)務(wù)編排里,配置訂閱其狀態(tài)數(shù)據(jù)非常方便。

年少無(wú)知的的時(shí)候,曾經(jīng)詆毀過(guò)Reudx。不管以前說(shuō)過(guò)多少Redux壞話,它還是優(yōu)雅地在那里,任你隨時(shí)取用,不介曾經(jīng)意被你誤解過(guò),不在意是否被你咒罵過(guò)?;蛟S,這就是開(kāi)源世界的包容。

目前項(xiàng)目里,有三個(gè)地方用到了Redux,這三處位置以后會(huì)獨(dú)立成三個(gè)npm包,所以各自維護(hù)自己的狀態(tài)樹(shù)的Root 節(jié)點(diǎn),也就是分別維護(hù)自己的狀態(tài)樹(shù)。這三個(gè)狀態(tài)樹(shù)分別是:

設(shè)計(jì)器狀態(tài)樹(shù)
設(shè)計(jì)器引擎邏輯上維護(hù)一棵節(jié)點(diǎn)樹(shù),節(jié)點(diǎn)樹(shù)跟帶 rx-id 的 dom 節(jié)點(diǎn)一一對(duì)應(yīng)。前面定義的schema,是協(xié)議性質(zhì),用于傳輸、存儲(chǔ)。設(shè)設(shè)計(jì)引擎會(huì)把schema轉(zhuǎn)換成節(jié)點(diǎn)樹(shù),然后展平存儲(chǔ)在Redux里面。節(jié)點(diǎn)樹(shù)的定義:

//這個(gè)INodeMeta跟上面Schema定義部分提到的,是一個(gè)export interface INodeMeta<IField = any, IReactions = any> { componentName: string, props?: { [key: string]: any, }, "x-field"?: IField, "x-reactions"?: IReactions,}//節(jié)點(diǎn)經(jīng)由Schema轉(zhuǎn)換而成export interface ITreeNode { //節(jié)點(diǎn)唯一ID,對(duì)應(yīng)dom節(jié)點(diǎn)上的rx-id id: ID //組件標(biāo)題 title?: string //組件描述 description?: string //組件Schema meta: INodeMeta //父節(jié)點(diǎn)Id parentId?: ID //子節(jié)點(diǎn)Id children: ID[] 是否是卡槽節(jié)點(diǎn) isSlot: boolean, //卡槽節(jié)點(diǎn)id鍵值對(duì) slots?: { [name: string]: ID } //文檔id,設(shè)計(jì)器底層模型支持多文檔 documentId: ID //標(biāo)識(shí)專用屬性,不通過(guò)外部傳入,系統(tǒng)自動(dòng)構(gòu)建 //包含rx-id,rx-node-type,rx-status三個(gè)屬性 rxProps?: RxProps //設(shè)計(jì)時(shí)的屬性,比如readOnly, open等 designerProps?: IDesignerProps //用來(lái)編輯屬性的schema designerSchema?: INodeSchema //設(shè)計(jì)器專用屬性,比如是否鎖定 //designerParams?: IDesignerParams}

展平到Redux里面:

//多文檔模型,一個(gè)文檔的狀態(tài)export type DocumentState = { //知否被修改過(guò) changed: boolean, //被選中的節(jié)點(diǎn) selectedIds: ID[] | null //操作快照 history: ISnapshot[] //根節(jié)點(diǎn)Id rootId?: ID}export type DocumentByIdState = { [key: string]: DocumentState | undefined}export type NodesById = { [id: ID]: ITreeNode}export type State = { //狀態(tài)id stateId: StateIdState //所有的文檔模型 documentsById: DocumentByIdState //當(dāng)前激活文檔的id activedDocumentId: ID | null //所有文檔的節(jié)點(diǎn),為了以后支持跨文檔拖放,全部節(jié)點(diǎn)放在根下 nodesById: NodesById}

數(shù)據(jù)模型狀態(tài)樹(shù)
fieldy模塊的數(shù)據(jù)模型主要用來(lái)管理頁(yè)面的數(shù)據(jù)模型,樹(shù)狀結(jié)構(gòu),Immutble的。數(shù)據(jù)模型中的數(shù)據(jù),通過(guò) schema 的 x-field 屬性綁定到具體組件。

預(yù)覽頁(yè)面、右側(cè)屬性面板都是用這個(gè)模型(右側(cè)屬性面板就是一個(gè)運(yùn)行時(shí)模塊,根頁(yè)面預(yù)覽使用相同的渲染引擎,就是說(shuō)右側(cè)屬性面板是基于低代碼配置來(lái)實(shí)現(xiàn)的)。

狀態(tài)定義:

//字段狀態(tài)export type FieldState = { //自動(dòng)生成id,用于組件key值 id: string; //字段名 name?: string; //基礎(chǔ)路徑 basePath?: string; //路徑,path=basePath "." name path: string; //字段是否已被初始化 initialized?: boolean; //字段是否已掛載 mounted?: boolean; //字段是否已卸載 unmounted?: boolean; //觸發(fā) onFocus 為 true,觸發(fā) onBlur 為 false active?: boolean; //觸發(fā)過(guò) onFocus 則永遠(yuǎn)為 true visited?: boolean; display?: FieldDisplayTypes; pattern?: FieldPatternTypes; loading?: boolean; validating?: boolean; modified?: boolean; required?: boolean; value?: any; defaultValue?: any; initialValue?: any; errors?: IFieldFeedback[]; validateStatus?: FieldValidateStatus; meta: IFieldMeta}export type FieldsState = { [path: string]: FieldState | undefined}export type FormState = { //字段是否已掛載 mounted?: boolean; //字段是否已卸載 unmounted?: boolean; initialized?: boolean; pattern?: FieldPatternTypes; loading?: boolean; validating?: boolean; modified?: boolean; fields: FieldsState; fieldSchemas: IFieldSchema[]; initialValue?: any; value?: any;}export type FormsState = { [name: string]: FormState | undefined}export type State = { forms: FormsState}

熟悉formily的朋友,會(huì)發(fā)現(xiàn)這個(gè)結(jié)構(gòu)定義跟fomily很像。沒(méi)錯(cuò),就是這個(gè)接口的定義就是借鑒(抄)了formily。

邏輯編排設(shè)計(jì)器狀態(tài)樹(shù)
這個(gè)有機(jī)會(huì)再單獨(dú)成文介紹吧。

軟件架構(gòu)

軟件被劃分為兩個(gè)比較獨(dú)立的部分:

  • 設(shè)計(jì)器,用于設(shè)計(jì)頁(yè)面,消費(fèi)的是設(shè)計(jì)形態(tài)的組件。生成頁(yè)面Schema。
  • 運(yùn)行時(shí),把設(shè)計(jì)器生成的頁(yè)面Schema,渲染為正常運(yùn)行的頁(yè)面,消費(fèi)的是預(yù)覽形態(tài)的組件。
    采用分層設(shè)計(jì)架構(gòu),上層依賴下層。

設(shè)計(jì)器架構(gòu)

設(shè)計(jì)器的最底層是core包,在它之上是react-core、vue-core,再往上就是shell層,比如Antd shell、Mui shell等。下圖是架構(gòu)圖,圖中虛線表示只是規(guī)劃尚未實(shí)現(xiàn)的部分,實(shí)線是已經(jīng)實(shí)現(xiàn)的部分。后面的介紹,也是以已經(jīng)實(shí)現(xiàn)的 React 為主。

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

core包是整個(gè)設(shè)計(jì)器的基礎(chǔ),包含了 Redux 狀態(tài)樹(shù)、頁(yè)面互動(dòng)邏輯,編輯器的各種狀態(tài)等。

react-core 包定義了 react 相關(guān)的基礎(chǔ)組件,把 core 包功能封裝為hooks。

react-shells 包,針對(duì)不同組件庫(kù)的具體實(shí)現(xiàn),比如 antd 或者 mui 等。

運(yùn)行時(shí)架構(gòu)

運(yùn)行時(shí)包含三個(gè)包:ComponentRender、fieldy跟minions,前者依賴后兩者。

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

fieldy 是數(shù)據(jù)模型,用于組織頁(yè)面數(shù)據(jù),比如表單、字段等。

minions(小黃人)是控制器部分,用于控制頁(yè)面的業(yè)務(wù)邏輯以及組件間的聯(lián)動(dòng)關(guān)系。

ComponertRender 負(fù)責(zé)把Schema 渲染為正常運(yùn)行的頁(yè)面。

core包的設(shè)計(jì)

Core包是基于接口的設(shè)計(jì),這樣的設(shè)計(jì)方式有個(gè)明顯的優(yōu)點(diǎn),就是清晰模塊間的依賴關(guān)系,封裝了具體的實(shí)現(xiàn)細(xì)節(jié),能方便的單獨(dú)替換某個(gè)模塊。Core 包含的模塊:

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

設(shè)計(jì)器引擎是 IDesignerEngine 接口的具體實(shí)現(xiàn),也是 Core 包入口,通過(guò) IDesignerEngine 可以訪問(wèn)包內(nèi)的其它模塊。接口定義:

export interface IDesignerEngine { //獲取設(shè)計(jì)器當(dāng)前語(yǔ)言代碼,比如:zh-CN, en-US... getLanguage(): string //設(shè)置設(shè)計(jì)設(shè)計(jì)語(yǔ)言代碼 setLanguage(lang: string): void //中創(chuàng)建一個(gè)文檔模型,注:設(shè)計(jì)器是多文檔模型,core支持同時(shí)編輯多個(gè)文檔 createDocument(schema: INodeSchema): IDocument //通過(guò) id 獲取文檔模型 getDocument(id: ID): IDocument | null //通過(guò)節(jié)點(diǎn) id 獲取節(jié)點(diǎn)所屬文檔模型 getNodeDocument(nodeId: ID): IDocument | null //獲取所有文檔模型 getAllDocuments(): IDocument[] | null //獲取監(jiān)視器 monitor,監(jiān)視器用于傳遞Redux store的狀態(tài)數(shù)據(jù) getMonitor(): IMonitor //獲取Shell模塊,shell用與獲取設(shè)計(jì)器的事件,比如鼠標(biāo)移動(dòng)等 getShell(): IDesignerShell //獲取組件管理器,組件管理器管理組件物料 getComponentManager(): IComponentManager //獲取資源管理器,資源是指左側(cè)工具箱上的資源,一個(gè)資源對(duì)應(yīng)一個(gè)組件或者一段組件模板 getResourceManager(): IResourceManager //獲取國(guó)語(yǔ)言資源管理器 getLoacalesManager(): ILocalesManager //獲取裝飾器管理器,裝飾器是設(shè)計(jì)器的輔助工具,主要用于給畫(huà)布內(nèi)的節(jié)點(diǎn)添加附加dom屬性,比如outline,輔助邊距,數(shù)據(jù)綁定提示等 getDecoratorManager(): IDecoratorManager //獲取設(shè)計(jì)動(dòng)作,動(dòng)作的實(shí)現(xiàn)方法,大部分會(huì)轉(zhuǎn)換成redux的action getActions(): IActions //注冊(cè)插件,rxeditor是組合式設(shè)計(jì),插件沒(méi)有功能性接口,只是為了統(tǒng)一銷毀被組合的對(duì)象,提供了簡(jiǎn)單的銷毀接口 registerPlugin(pluginFactory: IPluginFactory): void //獲取插件 getPlugin(name: string): IPlugin | null //發(fā)送 redux action dispatch(action: IAction<any>): void //銷毀設(shè)計(jì)器 destory(): void //獲取一個(gè)節(jié)點(diǎn)的行為規(guī)則,比如是否可拖放等 getNodeBehavior(nodeId: ID): NodeBehavior}

Redux store 是設(shè)計(jì)其引擎的狀態(tài)管理模塊,通過(guò)Monitor模塊跟文檔模型,把最新的狀態(tài)傳遞出去。

監(jiān)視器(IMonitor)模塊,提供訂閱接口,發(fā)布設(shè)計(jì)器狀態(tài)。

動(dòng)作管理(IActions)模塊,把部分常用的Redux actions 封裝成通用接口。

文檔模型(IDocument),Redux store存儲(chǔ)了文檔的狀態(tài)數(shù)據(jù),文檔模型直接使用Redux store,并將其分裝為更直觀的接口:

export interface IDocument { //唯一標(biāo)識(shí) id: ID //銷毀文檔 destory(): void //初始化 initialize(rootSchema: INodeSchema, documentId: ID): void //把一個(gè)節(jié)點(diǎn)移動(dòng)到樹(shù)形結(jié)構(gòu)的指定位置 moveTo(sourceId: ID, targetId: ID, pos: NodeRelativePosition): void //把多個(gè)節(jié)點(diǎn)移動(dòng)到樹(shù)形結(jié)構(gòu)的指定位置 multiMoveTo(sourceIds: ID[], targetId: ID, pos: NodeRelativePosition): void //添加新節(jié)點(diǎn),把組件從工具箱拖入畫(huà)布,會(huì)調(diào)用這個(gè)方法 addNewNodes(elements: INodeSchema | INodeSchema[], targetId: ID, pos: NodeRelativePosition): NodeChunk //刪除一個(gè)節(jié)點(diǎn) remove(sourceId: ID): void //克隆一個(gè)節(jié)點(diǎn) clone(sourceId: ID): void //修改節(jié)點(diǎn)meta數(shù)據(jù),右側(cè)屬性面板調(diào)用這個(gè)方法修改數(shù)據(jù) changeNodeMeta(id: ID, newMeta: INodeMeta): void //刪除組件卡槽位的組件 removeSlot(id: ID, name: string): void //給一個(gè)組件卡槽插入默認(rèn)組件 addSlot(id: ID, name: string): void //發(fā)送一個(gè)redux action dispatch(action: IDocumentAction<any>): void //把當(dāng)前文檔狀態(tài)備份為一個(gè)快照 backup(actionType: HistoryableActionType): void //撤銷時(shí)調(diào)用 undo(): void //重做是調(diào)用 redo(): void //定位到某個(gè)操作快照,撤銷、重做的補(bǔ)充 goto(index: number): void //獲取文檔根節(jié)點(diǎn) getRootNode(): ITreeNode | null //通過(guò)id獲取文檔節(jié)點(diǎn) getNode(id: ID): ITreeNode | null //獲取節(jié)點(diǎn)schema,相當(dāng)于把ItreeNode樹(shù)轉(zhuǎn)換成 schema 樹(shù) getSchemaTree(): INodeSchema | null}

組件管理器(IComponentManager),管理組件信息(組件注冊(cè)、獲取等)。

資源管理器(IResourceManager),管理工具箱的組件、模板資源(資源注冊(cè)、資源獲取等)。

多語(yǔ)言管理器(ILocalesManager),管理多語(yǔ)言資源。

Shell管理(IDesignerShell),與界面交互的通用邏輯,基于事件模型實(shí)現(xiàn),類圖:

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

DesignerShell類聚合了多個(gè)驅(qū)動(dòng)(IDriver),驅(qū)動(dòng)通過(guò)IDispatchable接口(DesignerShell就實(shí)現(xiàn)了這個(gè)接口,代碼中使用的就是DesignerShell)把事件發(fā)送給 DesignerShell,再由 DesignerShell 把事件分發(fā)給其它訂閱者。驅(qū)動(dòng)的種類有很多,比如鍵盤事件驅(qū)動(dòng)、鼠標(biāo)事件驅(qū)動(dòng)、dom事件驅(qū)動(dòng)等。不同的shell實(shí)現(xiàn),需要的驅(qū)動(dòng)也不一樣,比如畫(huà)布用div實(shí)現(xiàn)跟iframe實(shí)現(xiàn),需要的驅(qū)動(dòng)會(huì)略有差異。

隨著后續(xù)的進(jìn)展,可以有更多的驅(qū)動(dòng)被組合進(jìn)項(xiàng)目。

插件(IPlugin),RxEditor組合式的編輯器,只要拿到 IDesignerEngine 實(shí)例,就可以擴(kuò)展編輯器的功能。只是有的時(shí)候需要在編輯器退出的時(shí)候,需要統(tǒng)一銷毀某些資源,故而加入了一個(gè)簡(jiǎn)單的IPlugin接口:

export interface IPlugin { //唯一名稱,可用于覆蓋默認(rèn)值 name: string, destory(): void,}

代碼中的 core/auxwidgets 跟 core/controllers 都是 IPlugin 的實(shí)現(xiàn),查看這些代碼,就可以明白具體功能是怎么被組合進(jìn)設(shè)計(jì)器的。實(shí)際代碼中,為了更好的組合,還定義了一個(gè)工廠接口:

export type IPluginFactory = ( engine: IDesignerEngine,) => IPlugin

創(chuàng)建 IDesignerEngine 的時(shí)候直接傳入不同的 Plugin 工廠就可以:

export function createEngine( plugins: IPluginFactory[], options: { languange?: string, debugMode: boolean, }): IDesignerEngine { //構(gòu)建IDesignerEngine .... } const eng = createEngine( [ StartDragController, SelectionController, DragStopController, DragOverController, ActiveController, ActivedOutline, SelectedOutline, GhostWidget, DraggedAttenuator, InsertionCursor, Toolbar, ], { debugMode: false } )

裝飾器管理(IDecoratorManager),裝飾器用于給畫(huà)布內(nèi)的節(jié)點(diǎn),插入html標(biāo)簽或者屬性。這些插入的元素不依賴于節(jié)點(diǎn)的編輯狀態(tài)(依賴于編輯狀態(tài)的,通過(guò)插件插入,比如輪廓線),比如給所有的節(jié)點(diǎn)加入輔助的outline,或者標(biāo)識(shí)出已經(jīng)綁定了后端數(shù)據(jù)的節(jié)點(diǎn)??梢宰远x多種類型的裝飾器,動(dòng)態(tài)插入編輯器。

裝飾器的接口定義:

export interface IDecorator { //唯一名稱 name: string //附加裝飾器到dom節(jié)點(diǎn) decorate(el: HTMLElement, node: ITreeNode): void; //從dom節(jié)點(diǎn),卸載裝飾器 unDecorate(el: HTMLElement): void;}export interface IDecoratorManager { addDecorator(decorator: IDecorator, documentId: string): void removeDecorator(name: string, documentId: string): void getDecorator(name: string, documentId: string): IDecorator | undefined}

一個(gè)輔助輪廓線的示例:

export const LINE_DECORTOR_NAME = "lineDecorator"export class LineDecorator implements IDecorator { name: string = LINE_DECORTOR_NAME; decorate(el: HTMLElement, node: ITreeNode): void { el.classList.add("rx-node-outlined") } unDecorate(el: HTMLElement): void { el.classList.remove("rx-node-outlined") }}//css.rx-node-outlined{ outline: dashed grey 1px;}

react-core 包

這個(gè)包是使用 React 對(duì) core 進(jìn)行的封裝,并且提供一些通用 React 組件,不依賴具體的組件庫(kù)(類似antd,mui等)。

上下文(Contexts)

DesignerEngineContext 設(shè)計(jì)引擎上下文,用于下發(fā) IDesignerEngine 實(shí)例,包裹在設(shè)計(jì)器最頂層。

DesignComponentsContext 設(shè)計(jì)形態(tài)組件上下文,注冊(cè)進(jìn)設(shè)計(jì)器的組件,它們的設(shè)計(jì)形態(tài)通過(guò)這個(gè)上下文下發(fā)。

PreviewComponentsContext 預(yù)覽形態(tài)組件上下文,注冊(cè)進(jìn)設(shè)計(jì)器的組件,他們的預(yù)覽形態(tài)通過(guò)這個(gè)上下文下發(fā)。

DocumentContext 文檔上下文,下發(fā)一個(gè)文檔模型(IDocument),包裹在文檔視圖的頂層。

NodeContext 節(jié)點(diǎn)上下文,下發(fā) ITreeNode,每個(gè)節(jié)點(diǎn)包裹一個(gè)這樣的上下文。

通用組件

Designer 設(shè)計(jì)器根組件。

DocumentRoot 文檔視圖根組件。

ComponentTreeWidget 在畫(huà)布上渲染節(jié)點(diǎn)樹(shù),調(diào)用 ComponentDesignerView 遞歸實(shí)現(xiàn)。

畫(huà)布(Canvas)

實(shí)現(xiàn)不依賴具體畫(huà)布。使用 ComponentTreeWidget 組件實(shí)現(xiàn)。

core 包定義了畫(huà)布接口 IShellPane,和不同的畫(huà)布實(shí)現(xiàn)邏輯(headless的):IFrameCanvasImpl(把畫(huà)布包放入iframe的實(shí)現(xiàn)邏輯),ShadowCanvasImpl(把畫(huà)布放入Web component的實(shí)現(xiàn)邏輯)。如果需要,可以做一個(gè)div的畫(huà)布實(shí)現(xiàn)。

在react-core包,把畫(huà)布的實(shí)現(xiàn)邏輯跟具體界面組件掛接到一起,具體可以閱讀相關(guān)代碼,有問(wèn)題歡迎留言。

畫(huà)布的實(shí)現(xiàn)方式大概有三種方式,都有各自的優(yōu)缺點(diǎn),下面分別說(shuō)說(shuō)。

div實(shí)現(xiàn)方式,把設(shè)計(jì)器組件樹(shù)渲染在一個(gè)div內(nèi),跟設(shè)計(jì)器沒(méi)有隔離,這中實(shí)現(xiàn)方式比較簡(jiǎn)單,性能也好。缺點(diǎn)就是js上下文跟css樣式?jīng)]有隔離機(jī)制,被設(shè)計(jì)頁(yè)面的樣式不夠獨(dú)立。類似 position:fixed 的樣式需要在畫(huà)布最外層加一個(gè)隔離,比如:transform:scale(1) 。

響應(yīng)式布局,是指隨著瀏覽器的大小改變,會(huì)呈現(xiàn)不同的樣式,css中使用的是 @media 查詢,比如:

@media (min-width: 1200){ //>=1200的設(shè)備 }@media (min-width: 992px){ //>=992的設(shè)備 }@media (min-width: 768px){ //>=768的設(shè)備 }

一個(gè)設(shè)計(jì)器中,如果能通過(guò)調(diào)整畫(huà)布的大小來(lái)觸發(fā)@media的選擇,就可以直觀的看到被設(shè)計(jì)的內(nèi)容在不同設(shè)備上的外觀。div作為畫(huà)布,是模擬不了瀏覽器大小的,無(wú)法觸發(fā)@media 查詢,對(duì)響應(yīng)式頁(yè)面的設(shè)計(jì)并不十分友好。

web component沙箱方式,用 shadow dom 作為畫(huà)布,把設(shè)計(jì)器組件樹(shù)渲染在 shadow dom 內(nèi)。這樣的實(shí)現(xiàn)方式,性能跟div方式差不多,還可以有效隔離js上下文跟css樣式,比div的實(shí)現(xiàn)方式稍微好一些,類似 position:fixed 的樣式還是需要在畫(huà)布最外層加一個(gè)隔離,比如:transform:scale(1) 。并且 shadow dom 不能模擬瀏覽器大小,它的大小改變也不能觸發(fā)無(wú)法觸發(fā)@media 查詢。

iframe實(shí)現(xiàn)方式,把設(shè)計(jì)器組件樹(shù)渲染在 iframe 內(nèi),iframe會(huì)隔離js跟css,并且iframe尺寸的變化也會(huì)觸發(fā) @media 查詢,是非常理想的實(shí)現(xiàn)方式,RxEditor 最終也鎖定在了這種實(shí)現(xiàn)方式上。

往iframe內(nèi)部渲染組件,也有不同的渲染方式。在 RxEditor 項(xiàng)目中,嘗試過(guò)兩種方式:

ReactDOM.Root.render渲染,這種方式需要拿到iframe里面第一個(gè)div的dom,然后傳入ReactDOM.createRoot。相當(dāng)于在主程序渲染畫(huà)布組件,這種實(shí)現(xiàn)方式性能還是不錯(cuò)的,畫(huà)面沒(méi)有閃爍感。但是,組件用的css樣式跟js鏈接,需要從外部傳入iframe內(nèi)部。很多組件庫(kù)的不兼容這樣實(shí)現(xiàn)方式,比如 antd 的 popup 系列組件,在這種方式下很難正常工作,要實(shí)現(xiàn)類似功能,不得不重寫組件,與設(shè)計(jì)原則 “盡量減少對(duì)組件的入侵,最大程度使用已有組件資源” 相悖。
iframe.src方式渲染,定義一個(gè)畫(huà)布渲染組件,并配置路由,把路由地址傳入iframe.src:

<Routes> ... <Route path={'/canvas-render'} element={<IFrameCanvasRender designers={designers} />} > </Route> ...</Routes>//iframe渲染<iframe ref={ref} src={'/canvas-render'} onLoad={handleLoaded}></iframe>

這樣的渲染方式,完美解決了上述各種問(wèn)題,就是渲染畫(huà)布的時(shí)候,需要一段時(shí)間初始化React,性能上比上述方式略差。另外,熱加載進(jìn)來(lái)的組件不能通過(guò)window全局對(duì)象的形式傳入iframe,熱加載需要在iframe內(nèi)部完成,否則React會(huì)報(bào)沖突警告。

react-shells 包

依賴于組件庫(kù)部分的實(shí)現(xiàn),目前只是先了 antd 版本。代碼就是普通react組件跟鉤子,直接翻閱一下源碼就好,有問(wèn)題歡迎留言。

runner 包

這個(gè)包是運(yùn)行時(shí),以正常運(yùn)行的方式渲染設(shè)計(jì)器生產(chǎn)的頁(yè)面,消費(fèi)的是預(yù)覽形態(tài)的組件。設(shè)計(jì)器右側(cè)的屬性面板也是基于低代碼實(shí)現(xiàn),使用的是這個(gè)包。

runner 包能渲染一個(gè)完整的前端應(yīng)用,包含表單數(shù)據(jù)綁定,組件的聯(lián)動(dòng)。采用模型數(shù)據(jù)、行為、UI界面三者分離的方式。

數(shù)據(jù)模型在 fieldy 模塊定義,基于Redux實(shí)現(xiàn),前面已經(jīng)介紹過(guò)其接口。這個(gè)模塊,在邏輯上管理一棵數(shù)據(jù)樹(shù),組件可以綁定樹(shù)的具體節(jié)點(diǎn),一個(gè)節(jié)點(diǎn)可以綁定多個(gè)組件。綁定方式,在 schema 的 x-field 字段定義。

本文的開(kāi)始的設(shè)計(jì)原則中說(shuō)過(guò),盡量減少對(duì)組件的入侵,最大程度使用已有組件資源。這就意味著,控制組件的時(shí)候,不要重寫組件或者侵入其內(nèi)部,而是通過(guò)組件對(duì)外的接口props來(lái)控制。在組件外層,包裝一個(gè)控制器,來(lái)實(shí)現(xiàn)對(duì)組件的控制。比如一個(gè)組件ComponentA,控制器代碼可以這樣:

export class ControllerA{ setProp(name: string, value: any): void subscribeToPropsChange(listener: PropsListener): UnListener destory(): void, ...}export const ComponentAController = memo((props)=>{ const [changedProps, setChangeProps] = useState<any>() const handlePropsChange = useCallback((name: string, value: any) => { setChangeProps((changedProps: any) => { return ({ ...changedProps, [name]: value }) }) }, []) useEffect(() => { const ctrl = new ControllerA() const unlistener = ctrl?.subscribeToPropsChange(handlePropsChange) return () => { ctrl.destory() unlistener?.() } }, []) const newProps = useMemo(() => { return { ...props, ...controller?.events, ...changedProps } }, [changedProps, controller?.events, props]) return( <Component {...newProps}> )})

這段代碼,相當(dāng)于把組件的控制邏輯抽象到ControllerA內(nèi)部,通過(guò) props 更改 ComponentA 的狀態(tài)。ControllerA 的實(shí)例可以注冊(cè)到全局或者通過(guò)Context下發(fā)到子組件(上面算是偽代碼,未展示這部分),其它組件可以通過(guò)ControllerA 的實(shí)例,傳遞聯(lián)動(dòng)控制。

在RxEditor中,控制器實(shí)例是通過(guò)Context逐級(jí)下發(fā)的,子組件可以調(diào)用所有父組件的控制器,因?yàn)榭刂破鞅旧硎莻€(gè)類,所以可以通過(guò)屬性變量傳遞數(shù)據(jù),實(shí)際的控制器定義如下:

//變量控制器,用于組件間共享數(shù)據(jù)export interface IVariableController { setVariable(name: string, value: any): void, getVariable(name: string): any, subscribeToVariableChange(name: string, listener: VariableListener): void}//屬性控制器,用于設(shè)置組件屬性export interface IPropController { setProp(name: string, value: any): void}//組件控制器接口export interface IComponentController extends IVariableController, IPropController { //唯一Id id: string, //并稱,編排時(shí)作為標(biāo)識(shí) name?: string, //邏輯編排的meta數(shù)據(jù) meta: IControllerMeta, subscribeToPropsChange(listener: PropsListener): UnListener destory(): void, //其它 ...}

runner 渲染跟設(shè)計(jì)器一樣,是通過(guò) ComponentView 組件遞歸完成的。所以 ComponentAController 可以提取為一個(gè)高階組件 withController(具體實(shí)現(xiàn)請(qǐng)閱讀代碼),ComponentView 渲染組件時(shí),根據(jù)schema配置,如果配置了 x-reactions,就給組件包裹高階組件withController,實(shí)現(xiàn)組件控制器的綁定。如果配置了x-field,就給組件包裹一個(gè)數(shù)據(jù)綁定的高階組件 withBind。

ComponentRender 調(diào)用 ComponentView, 通過(guò)遞歸機(jī)制把schema樹(shù)渲染為真實(shí)頁(yè)面。渲染時(shí),會(huì)根據(jù)x-field的配置渲染fieldy模塊的一些組件,完成數(shù)據(jù)模型的建立。

另外,IComponentController 的具體實(shí)現(xiàn),依賴邏輯編排,邏輯編排的實(shí)現(xiàn)原理在下一節(jié)介紹。

邏輯編排

一直對(duì)邏輯編排不是很感興趣,覺(jué)得用圖形化的形式實(shí)現(xiàn)代碼邏輯,不會(huì)有什么優(yōu)勢(shì)。直到看到 mybricks 的邏輯編排,才發(fā)現(xiàn)換個(gè)思路,可以把業(yè)務(wù)邏輯組件化,邏輯編排其實(shí)大有可為。

接下來(lái),以打地鼠邏輯為例,說(shuō)一下邏輯編排的實(shí)現(xiàn)思路。

打地鼠的界面:

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

左側(cè)9個(gè)按鈕是地鼠,每隔1秒會(huì)隨機(jī)活動(dòng)一只(變?yōu)樗{(lán)色),鼠標(biāo)點(diǎn)擊活動(dòng)地鼠為擊中(變?yōu)榧t色,并且積分器上記1分),右側(cè)上方的輸入框?yàn)橛?jì)分器,下面是兩個(gè)按鈕用來(lái)開(kāi)始或者結(jié)束游戲。

前面講過(guò),RxEditor 組件控制器是通過(guò)Context下發(fā)到子組件的,就是是說(shuō)只有子組件能訪問(wèn)父組件的控制器,父組件訪問(wèn)不了子組件的控制器,兄弟組件之間也不能相互訪問(wèn)控制器。如果通過(guò)全局注冊(cè)控制器的方式,組件之間就可以隨意訪問(wèn)控制器,實(shí)現(xiàn)這種地鼠邏輯會(huì)簡(jiǎn)單些。但是,如果全局的方式注冊(cè)控制器,會(huì)帶來(lái)一個(gè)新的問(wèn)題,就是動(dòng)態(tài)表格的控制器不好注冊(cè),表格內(nèi)的控件是動(dòng)態(tài)生成的,他的控制器不好在設(shè)計(jì)時(shí)綁定,所以目前只考慮Context的實(shí)現(xiàn)方式。

游戲主控制器
在最頂層的組件 antd Row 上加一個(gè)一個(gè)游戲控制,控制器取名“游戲容器”:

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

這個(gè)控制器的可視化配置:

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

這個(gè)可視化配置的實(shí)現(xiàn)原理,改天再寫吧,這里只介紹如何用它實(shí)現(xiàn)邏輯編排。

這是一個(gè)基于數(shù)據(jù)流的邏輯編排引擎,數(shù)據(jù)從節(jié)點(diǎn)的輸入端口(左側(cè)端口)流入,經(jīng)過(guò)處理以后,再?gòu)妮敵龆丝冢ㄓ覀?cè)端口)流出。流入與流出是基于回調(diào)的方式實(shí)現(xiàn)(類似Promise),并且每個(gè)節(jié)點(diǎn)可以有自己的狀態(tài),所以上圖跟流程圖有個(gè)本質(zhì)的不同,流程圖是單線腳本,而上圖每一個(gè)節(jié)點(diǎn)是一個(gè)對(duì)象,有點(diǎn)像電影《超級(jí)奶爸》里面的小黃人,所以我給這個(gè)邏輯編排功能起名叫minions(小黃人),不同的是,這里的小黃人可以組合成另外一個(gè)小黃人,可以任意嵌套、任意組合。

這樣的實(shí)現(xiàn)機(jī)制相當(dāng)于把業(yè)務(wù)邏輯組件化了,然后再把業(yè)務(wù)邏輯組件可視化。

控制器的事件組件內(nèi)置的,antd 的 Row 內(nèi)置了三個(gè)事件:初始化、銷毀、點(diǎn)擊??梢栽谶@些事件里實(shí)現(xiàn)具體的業(yè)務(wù)邏輯。本例中的初始化事件中,實(shí)現(xiàn)了打地鼠的主邏輯:

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

監(jiān)聽(tīng)“運(yùn)行”變量,如果為true,啟動(dòng)一個(gè)信號(hào)發(fā)生器,信號(hào)發(fā)生器每1000毫秒產(chǎn)生一個(gè)信號(hào),游戲開(kāi)始;如果為false,則停止信號(hào)發(fā)生器,游戲結(jié)束。信號(hào)發(fā)生器產(chǎn)生信號(hào)以后,傳遞給一個(gè)隨機(jī)數(shù)生成器,用于生成一個(gè)代表地鼠編號(hào)的隨機(jī)數(shù),這個(gè)隨機(jī)數(shù)賦值給變量”活躍地鼠“,地鼠組件會(huì)訂閱變量”活躍地鼠“,如果變量值跟自己的編號(hào)一致,就把自己變?yōu)榧せ顮顟B(tài)

交互相當(dāng)于類的方法(實(shí)際上用一個(gè)類來(lái)實(shí)現(xiàn)),是自定義的。這里定義了三個(gè)交互:開(kāi)始、結(jié)束、計(jì)分,一個(gè)交互就是一個(gè)類,可以通過(guò)Context下發(fā)到子組件,子組件可以實(shí)例化并用它們來(lái)組合自己的邏輯。

開(kāi)始,就是把變量”運(yùn)行“賦值為true,用于啟動(dòng)游戲。

結(jié)束,就是把變量”運(yùn)行“賦值為false,用于結(jié)束游戲。

計(jì)分,就是把成績(jī) 1

變量相當(dāng)于組件控制器類的屬性,外部可以通過(guò) subscribeToVariableChange 方法訂閱變量的變化。

地鼠控制器

在初始化事件中,地鼠訂閱父組件”游戲容器“的活躍地鼠變量,通過(guò)條件判斷節(jié)點(diǎn)判斷是否跟自己編號(hào)一致,如果一致,把按鈕的disabled屬性設(shè)置為常量false,并啟動(dòng)延時(shí)器,延時(shí)2000毫秒以后,設(shè)置disabled為常量true,并重置按鈕顏色(danger屬性設(shè)置為false)。

點(diǎn)擊事件的編排邏輯:

如何制作一個(gè)高擴(kuò)展、可視化低代碼前端?(如何制作一個(gè)高擴(kuò)展,可視化低代碼前端的軟件)

給danger屬性賦值常量true(按鈕變紅),調(diào)用游戲容器的計(jì)分方法,增加積分。

其它組件也是類似的實(shí)現(xiàn)方式,這里就不展開(kāi)了。具體的實(shí)現(xiàn)例子,請(qǐng)參考在線演示。

這里只是初步介紹了邏輯編排的大概原理,詳細(xì)實(shí)現(xiàn)有機(jī)會(huì)再起一篇專門文章來(lái)寫吧。

總結(jié)

本文介紹了一個(gè)可視化前端的實(shí)現(xiàn)原理,包括可視化編輯、運(yùn)行時(shí)渲染等方面內(nèi)容,所涵蓋內(nèi)容,可以構(gòu)建一個(gè)完整低代碼前端,只是限于精力有限、篇幅有限,很多東西沒(méi)有展開(kāi),詳細(xì)的可以翻閱一下實(shí)現(xiàn)代碼。有問(wèn)題,歡迎留言

相關(guān)新聞

聯(lián)系我們
聯(lián)系我們
公眾號(hào)
公眾號(hào)
在線咨詢
分享本頁(yè)
返回頂部