實踐,製作一個高擴充套件、視覺化低程式碼前端,詳實、完整

語言: CN / TW / HK

RxEditor是一款開源企業級視覺化低程式碼前端,目標是可以編輯所有 HTML 基礎的元件。比如支援 React、VUE、小程式等,目前僅實現了 React 版。

RxEditor執行快照: image

專案地址:https://github.com/rxdrag/rxeditor

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

本文介紹RxEditor 設計實現方法,儘可能包括技術選型、軟體架構、具體實現中碰到的各種小坑、預覽渲染、物料熱載入、前端邏輯編排等內容。

注:為了方便理解,文中引用的程式碼濾除了細節,是實際實現程式碼的簡化版

設計原則

  • 儘量減少對元件的入侵,最大程度使用已有元件資源。
  • 配置優先,指令碼輔助。
  • 基礎功能原子化,組合式設計。
  • 物料外掛化、邏輯元件化,儘可能動態插入系統。

基礎原理

專案的設計目標,是能夠通過拖拽的方式操作基於 HTML 製作的元件,如:調整這些元件的包含關係,並設定元件屬性。

不管是 React、Vue、Angluar、小程式,還是別的類似前端框架,最終都是要把 JS 元件,以DOM節點的形式渲染出來。 image

編輯器(RxEditor)要維護一個樹形模型,這個模型描述的是元件的隸屬關係,以及 props。同時還能跟 dom 樹互動,通過各種 dom 事件,操作元件模型樹。

這裡關鍵的一個點是,編輯器需要知道 dom 節點跟元件節點之間的對應關係。在不侵入元件的前提下,並且還要忽略前端庫的差異,比較理想的方法是給 dom 節點賦一個特殊屬性,並跟模型中元件的 id 對應,在 RxEditor 中,這個屬性是rx-id,比如在dom節點中這樣表示: ```

`` 編輯器監聽 dom 事件,通過事件的 target 的 rx-id 屬性,就可以識別其在模型中對應元件節點。也可以通過document.querySelector([rx-id="${id}"])`方法,查詢元件對應的 dom 節點。

除此之外,還加了 rx-node-type 跟 rx-status 這兩個輔助屬性。rx-node-type 屬性主要用來識別是工具箱的Resource、畫布內的普通節點還是編輯器輔助元件,rx-status 計劃是多模組編輯使用,不過目前該功能尚未實現。

rx-id 算是設計器的基礎性原理,它給設計器核心抹平了前端框架的差異,幾乎貫穿設計器的所有部分。

Schema 定義

編輯器操作的是JSON格式的元件樹,設計時,設計引擎根據這個元件樹渲染畫布;預覽時,執行引擎根據這個元件樹渲染實際頁面;程式碼生成時,可以把這個元件樹生成程式碼;儲存時,直接把它序列化儲存到資料庫或者檔案。這個元件樹是設計器的資料模型,通常會被叫做 Schema。

像阿里的 formily,它的Schema 依據的是JSON Schema 規範,並在上面做了一些擴充套件,他在描述父子關係的時候,用的是properties鍵值對: { <---- RecursionField(條件:object;渲染權:RecursionField) "type":"object", "properties":{ "username":{ <---- RecursionField(條件:string;渲染權:RecursionField) "type":"string", "x-component":"Input" }, "phone":{ <---- RecursionField(條件:string;渲染權:RecursionField) "type":"string", "x-component":"Input", "x-validator":"phone" }, "email":{ <---- RecursionField(條件:string;渲染權:RecursionField) "type":"string", "x-component":"Input", "x-validator":"email" }, ...... } } 用鍵值對的方式存子元件(children)有幾個明顯的問題:

  • 用這樣的方式渲染預覽介面時,一個欄位只能繫結一個控制元件,無法繫結多個,因為key值唯一。
  • 鍵值對不攜帶順序資訊,儲存到資料庫JSON型別的欄位時,具體的後端實現語言要進行序列化與反序列化的操作,不能保證順序,為了避免出問題,不得不加一個類似index的欄位來記錄順序。
  • 設計器引擎內部操作時,用的是陣列的方式記錄資料,傳輸到後端儲存時,不得不進行轉換。 鑑於上述問題,RxEditor採用了陣列的形式來記錄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的例子,相應轉換成: { "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 是表單資料的定義,x-reactions 是元件控制邏輯,通過前端編排來實現,這兩個後面會詳細介紹。

需要注意的是卡槽(slots),這個是 RxEditor 的原創設計,原生 Schema 直接支援卡槽,可以很大程度上支援現有元件,比如很多 React antd 元件,不需要封裝就可以直接拉到設計器裡來用,關於卡槽後面還會有更詳細的介紹。

元件形態

專案中的前端元件,要在兩個地方渲染,一是設計引擎的畫布,另一處是預覽頁面。這兩處使用的是不同渲染引擎,對元件的要求也不一樣,所以把元件分定義為兩個形態: - 設計形態,在設計器畫布內渲染,需要提供ref或者轉發rx-id,有能力跟設計引擎互動。 - 預覽形態,預覽引擎使用,渲染機制跟執行時渲染一樣。相當於普通的前端元件。

設計形態的元件跟預覽形態的元件,對應的是同一份schema,只是在渲染時,使用不同的元件實現。

接下來,以React為例,詳細介紹元件設計形態與預覽形態之間的區別與聯絡,同時也介紹瞭如何製作設計形態的元件。

有 React ref 的元件

這部分元件是最簡單的,直接拿過來使用就好,這些元件的設計形態跟預覽形態是一樣的,在設計引擎這樣渲染: ``` export const ComponentDesignerView = memo((props: { nodeId: string }) => { const { nodeId } = props; //獲取資料模型樹中對應的節點 const node = useTreeNode(nodeId); //通過ref,給 dom 賦值rx-id const handleRef = useCallback((element: HTMLElement | undefined) => { element?.setAttribute("rx-id", node.id) }, [node.id]) //拿到設計形態的元件 const Component = useDesignComponent(node?.meta?.componentName);

return ( ) })) ``` 只要 rx-id 被新增到 dom 節點上,就建立了 dom 與設計器內部資料模型的聯絡。

預覽引擎的渲染相對更簡單直接: ``` export type ComponentViewProps = { node: IComponentRenderSchema, }

export const ComponentView = memo(( props: ComponentViewProps ) => { const { node, ...other } = props //拿到預覽形態的元件 const Component = usePreviewComponent(node.componentName)

return ( { node.children?.map(child => { return () }) } ) }) ```

無ref,但可以把未知屬性轉發到合適的dom節點上

比如一個React元件,實現方式是這樣的: export const ComponentA = (props)=>{ const {propA, propB, ...rest} = props ... return( <div {...rest}> ... </div> ) } 除了 propA 跟 propB,其它的屬性被原封不動的轉發到了根div上,這樣的元件在設計引擎裡面可這樣渲染: ``` export const ComponentDesignerView = memo((props: { nodeId: string }) => { const { nodeId } = props; //獲取資料模型樹中對應的節點 const node = useTreeNode(nodeId);

//拿到設計形態的元件 const Component = useDesignComponent(node?.meta?.componentName);

return ( ) })) ``` 通過這樣的方式,rx-id 被同樣新增到 dom 節點上,從而建立了資料模型與 dom之間的關聯。

通過元件 id 拿到 ref

有的元件,既不能提供合適的ref,也不能轉發rx-id,但是這個元件有id屬性,可以通過唯一的id,來獲得對應 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((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) ``` 使用這種方式時,要確保元件的id沒有其它用途。

嵌入隱藏元素

如果一個元件,通過上述方式安插 rx-id 都不合適,這個元件恰好有 children 的話,可以在 children 裡面插入一個隱藏元素,通過隱藏元素 dom 的parentElement 獲取 ref,直接上高階元件: `` const HiddenElement = styled.div display: none; `

export function forwardRefByChildren(WrappedComponent: ReactComponent): ReactComponent {

return memo(forwardRef((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) ```

調整 ref 位置

有的元件,提供了 ref,但是 ref 位置並不合適,基於 ref 指示的 dom 節點畫編輯時的輪廓線的話,會顯的彆扭,有個這樣實現的元件: export const ComponentA = forwardRef<HTMElement>((props: any, ref) => { return (<div style={padding:16}> <div ref={ref}> ... </div> </div>) }) 編輯時這個元件的輪廓線,會顯示在內層 div,距離外層 div 差了16個畫素。為了把rx-id插入到外層 div, 加入一個轉換 ref 的高階元件: ``` // 傳出真實ref用的回撥 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((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) ```

元件外層包一個 div

如果一個元件,既不能提供合適的ref,不能轉發rx-id,沒有id屬性,也沒有children, 可以在元件外層直接包一個 div,使用div 的 ref : export const WrappedComponentA = forwardRef((props, ref)=>{ return( <div ref={ref}> <ComponentA {...props}/> </div> ) }) 提取成高階元件: ``` export type ReactComponent = React.FC | React.ComponentClass | string export function wrapWithRef(WrappedComponent: ReactComponent):ReactComponent{ return memo(forwardRef((props: any, ref) => { return

</div })) }

export const WrappedComponentA = wrapWithRef(ComponentA) ``` 這個實現方式有個明顯的問題,憑空添加了一個div,隔離了 css 上下文,為了保證設計器的顯示效果跟預覽時一樣,所見即所得,需要在元件的預覽形態上也加一個div,就是說直接修改原生元件,設計形態跟預覽形態都使用轉換後的元件。即便是這樣,也像做不可描述的事情時帶T一樣,有些許不爽。

帶卡槽(slots)的元件

Vue 中有卡槽,分為具名卡槽跟不具名卡槽,不具名卡槽就是 children。React 中沒有明確的卡槽概念,但是React.ReactNode 型別的 props 就相當於具名卡槽了。

在視覺化設計器中,是需要卡槽的。

卡槽可以非常清晰的區分組建的各個區域,並且能很好地複用邏輯。

視覺化編輯器中的拖拽,是把元件拖入(拖出)children(非具名卡槽),對於具名卡槽,這種普通拖放是無能無力的。

如果schema不支援卡槽,通常會特殊處理一下元件,就是在元件外封裝一層,並且還用不了高階元件。比如 antd 的 List 元件,它有 header 跟 footer 兩個 React.ReactNode 型別的屬性,這就是兩個卡槽。要想在設計器中使用這兩個卡槽,設計形態的元件一般會這麼寫: 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(()=>{ //這裡根據Schema樹和children構造footer卡槽 ... }, [children, hasFooter]) const header = useMemo(()=>{ //這裡根據Schema樹和children構造header卡槽 ... }, [children, hasHeader]) return(<AntdList header = {header} header={footer} {...rest}}/>) } 元件的設計形態也需要類似的封裝,這裡就不詳細展開了。

這個方式,相當於把所有的具名卡槽轉換成非具名卡槽,然後在渲染的時候,再根據配置把非具名卡槽解析成具名卡槽。hasHeader這類屬性不設定,也能解析,只是換了種實現方式,並無本質區別。

擁有具名卡槽的前端庫太多了,每一種元件都這樣處理,複雜而繁瑣,並且違背了設計原則:“儘量減少對元件的入侵,最大程度使用已有元件資源”。

基於這個因素,把卡槽(slots)放入了 schema,只需要在渲染的時候跟非具名卡槽稍微做一下區別,就可以插入插槽: ``` export type ComponentViewProps = { node: IComponentRenderSchema, }

export const ComponentView = memo(( props: ComponentViewProps ) => { const { node, ...other } = props //拿到預覽形態的元件 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] = } } return slts }, [node?.slots])

return ( { node.children?.map(child => { return () }) } ) }) ``` 這是預覽形態的渲染程式碼,設計形態類似,此處不詳細展開了。

用這樣的方式處理卡槽,卡槽是不能被拖入的,只能通過屬性面板的配置開啟或者關閉卡槽: image

並且,卡槽只能是一個獨立節點,不能是節點陣列,相當於把React.ReactNode轉換成了React.ReactElement,不過這個轉換對使用者體驗的影響並不大。

需要獨立製作設計形態的元件

通過上述各種高階元件、schema原生支援的slots,已有的元件,基本上不需要修改就可以納入視覺化設計。

但是,也有例外。有些元件,還是需要獨立製作設計形態。需要獨立製作設計形態的元件,一般基於兩個方面的考慮: - 使用者體驗; - 業務邏輯複雜。 在使用者體驗方面,看一個例子,antd 的 Button 元件。Button的使用程式碼: <Button type="primary"> Primary Button </Button> 元件的children可以是 text 文字,text 文字不是一個元件,在編輯器中式很難被拖入的,要想拖入的話,可以加一個文字型別的元件 Text: <Button type="primary"> <Text>Primary Button</Text> </Button> 這樣就解決了拖放問題,並且Text元件可以在很多地方被使用,也不算增加實體。但是這樣每個Button 巢狀一個 Text方式,會大量增加設計器畫布中控制元件的數量,使用者體驗並不好。這種情況,最好重寫Buton元件: ``` import {Button as AntdButton, ButtonProps} from "antd"

export Button = memo(forwardRef( (props: ButtonProps&{title?:string}}, ref) => { const {title, ...rest} = props return ( {title} ) } 進一步提取為高階元件: export function mapComponent(WrappedComponent: ReactComponent, maps: { [key: string]: string }): ReactComponent {

return memo(forwardRef((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' }) ``` 業務邏輯複雜的例子,典型的是table,設計形態跟預覽形態的區別:

設計形態

image

預覽形態

image

這種元件,是需要特殊製作的,沒有什麼簡單的辦法,具體實現請參考原始碼。

Material,物料的定義

一個Schema,只是用來描述一個元件,這個元件相關的配置,比如多語言資訊、在工具箱中的圖示、編輯規則(比如:它可以被放置在哪些元件下,不能被放在什麼元件下)等等這些資訊,需要一個配置來描述,這個就是物料的定義。具體定義: ``` export interface IBehaviorRule { disabled?: boolean | AbleCheckFunction //預設false selectable?: boolean | AbleCheckFunction //是否可選中,預設為true droppable?: boolean | AbleCheckFunction//是否可作為拖拽容器,預設為false draggable?: boolean | AbleCheckFunction //是否可拖拽,預設為true deletable?: boolean | AbleCheckFunction //是否可刪除,預設為true cloneable?: boolean | AbleCheckFunction //是否可拷貝,預設為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 { //npm包名 生成程式碼用 packageName?: string, //元件名稱,要唯一,可以加點號:. componentName: string, //元件的預覽形態 component: ComponentType, //元件的設計形態 designer: ComponentType, //元件編輯規則,比如是否能作為另外元件的children behaviorRule?: IBehaviorRule //右側屬性面板的配置Schema designerSchema?: INodeSchema //元件的多語言資源 designerLocales?: ILocales //元件設計時的特殊props配置,比如Input元件的readOnly屬性 designerProps?: IDesignerProps //元件在工具箱中的配置 resource?: IResource //卡槽slots用到的元件,值為true時,用預設元件DefaultSlot, // string時,存的是已經註冊過的component resource名字 slots?: { [name: string]: IComponentConfig | true | string | undefined }, //右側屬性面板用的多語言資源 toolsLocales?: ILocales, //右側屬性面板用到的擴充套件元件。是的,組合式設計,都可以配置 tools?: { [name: string]: ComponentType | undefined }, } IBehaviorRule介面定義組建的編輯規則,隨著專案的逐步完善,這個介面大概率會變化,這裡也沒必要在意這麼細節的東西,要重點關注的是IComponentConfig介面,這就是一個物料的定義,泛型使用的ComponetType是為了區別前端差異,比如React的物料定義是這樣: export type ReactComponent = React.FC | React.ComponentClass | string export interface IComponentMaterial extends IComponentConfig { } ```

物料如何使用

物料定義,包含了一個元件的所有內容,直接註冊進設計器,就可以使用。後面會有相關講述。

物料的熱載入

一個不想熱載入的低程式碼平臺,不是一個有出息的平臺。但是,這個版本並沒有來得及做熱載入,後續版本會補上。這裡簡單分享前幾個版本的熱載入經驗。

一個物料的定義是一個js物件,只要能拿到這個隊形,就可以直接使用。熱載入要解決的問題式拿到,具體拿到的方式可能有這麼幾種:

import

js 原生import可以引入遠端定義的物料,但是這個方式有個明顯的缺點,就是不能跨域。如果沒有跨域需求,可以用這種方式。

webpack元件聯邦

看網上介紹,這種方式似乎可行,但並沒有嘗試過,有類似嘗試的朋友,歡迎留言。

src引入

這種方式可行的,並且以前的版本中已經成功實現,具體做法是在編譯的物料庫裡,把物料的定義掛載到全域性window物件上,在編輯器裡動態建立一個 script 元素,在load事件中,從全域性window物件上拿到定義,具體實現: ``` function loadJS(src: string, clearCache = false): Promise { const p = new Promise((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錯誤", e) reject(e) }); } document.head.appendChild(script);

})

return p; }

export function loadPlugin(url: string): Promise { const path = trimUrl(url); const indexJs = path + "index.js";

const p = new Promise((resolve, reject) => { loadJS(indexJs, true) .then((script) => { //從全域性window上拿到物料的定義 const rxPlugin = window.rxPlugin console.log("載入結果", window.rxPlugin) window.rxPlugin = undefined rxPlugin && resolve(rxPlugin); script?.remove(); }) .catch(err => { reject(err); }) })

return p; } ``` 物料的單獨打包使用webpack,這個工具不是很熟練,勉強能用。有熟悉的大佬歡迎留言指導一下,不勝感激。

設計器的畫布目前使用的iframe,選擇iframe的原因,後面會有詳細介紹。使用iframe時,相當於一個應用啟動了兩套React,如果從設計器通過window物件,把物料傳給iframe畫布,react會報錯。所以需要在iframe內部單獨熱載入物料,切記!

狀態管理

如果不考慮其它前端庫,只考慮React的話,狀態管理肯定會選擇recoil。如果要考慮vue、angular等其它前端,就只能放棄recoil,從知道的其它庫裡選:redux、mobx、rxjs。

rxjs雖然看起來不錯,但是沒有使用經驗,暫時放棄了。mobx,個人不喜歡,與上面的設計原則“儘量減少對元件的入侵,最大程度使用已有元件資源”相悖,也只能放棄。最後,選擇了Redux。

雖然Redux的程式碼看起來會繁瑣一些,好在這種視覺化專案本身的狀態並不多,這種繁瑣度是可以接受的。

在使用過程中發現,Redux做低程式碼狀態管理,有很多不錯的優勢。足夠輕量,資料的流向清晰明瞭,可以精確控制訂閱。並且,Redux對配置是友好的,在視覺化業務編排裡,配置訂閱其狀態資料非常方便。

年少無知的的時候,曾經詆譭過Reudx。不管以前說過多少Redux壞話,它還是優雅地在那裡,任你隨時取用,不介曾經意被你誤解過,不在意是否被你咒罵過。或許,這就是開源世界的包容。

目前專案裡,有三個地方用到了Redux,這三處位置以後會獨立成三個npm包,所以各自維護自己的狀態樹的Root 節點,也就是分別維護自己的狀態樹。這三個狀態樹分別是:

設計器狀態樹 設計器引擎邏輯上維護一棵節點樹,節點樹跟帶 rx-id 的 dom 節點一一對應。前面定義的schema,是協議性質,用於傳輸、儲存。設設計引擎會把schema轉換成節點樹,然後展平儲存在Redux裡面。節點樹的定義: //這個INodeMeta跟上面Schema定義部分提到的,是一個 export interface INodeMeta<IField = any, IReactions = any> { componentName: string, props?: { [key: string]: any, }, "x-field"?: IField, "x-reactions"?: IReactions, } //節點經由Schema轉換而成 export interface ITreeNode { //節點唯一ID,對應dom節點上的rx-id id: ID //元件標題 title?: string //元件描述 description?: string //元件Schema meta: INodeMeta //父節點Id parentId?: ID //子節點Id children: ID[] 是否是卡槽節點 isSlot: boolean, //卡槽節點id鍵值對 slots?: { [name: string]: ID } //文件id,設計器底層模型支援多文件 documentId: ID //標識專用屬性,不通過外部傳入,系統自動構建 //包含rx-id,rx-node-type,rx-status三個屬性 rxProps?: RxProps //設計時的屬性,比如readOnly, open等 designerProps?: IDesignerProps //用來編輯屬性的schema designerSchema?: INodeSchema //設計器專用屬性,比如是否鎖定 //designerParams?: IDesignerParams } 展平到Redux裡面: ``` //多文件模型,一個文件的狀態 export type DocumentState = { //知否被修改過 changed: boolean, //被選中的節點 selectedIds: ID[] | null //操作快照 history: ISnapshot[] //根節點Id rootId?: ID } export type DocumentByIdState = { [key: string]: DocumentState | undefined } export type NodesById = {

} export type State = { //狀態id stateId: StateIdState //所有的文件模型 documentsById: DocumentByIdState //當前啟用文件的id activedDocumentId: ID | null //所有文件的節點,為了以後支援跨文件拖放,全部節點放在根下 nodesById: NodesById } ```

資料模型狀態樹 fieldy模組的資料模型主要用來管理頁面的資料模型,樹狀結構,Immutble的。資料模型中的資料,通過 schema 的 x-field 屬性繫結到具體元件。

預覽頁面、右側屬性面板都是用這個模型(右側屬性面板就是一個執行時模組,根頁面預覽使用相同的渲染引擎,就是說右側屬性面板是基於低程式碼配置來實現的)。

狀態定義: //欄位狀態 export type FieldState = { //自動生成id,用於元件key值 id: string; //欄位名 name?: string; //基礎路徑 basePath?: string; //路徑,path=basePath + "." + name path: string; //欄位是否已被初始化 initialized?: boolean; //欄位是否已掛載 mounted?: boolean; //欄位是否已解除安裝 unmounted?: boolean; //觸發 onFocus 為 true,觸發 onBlur 為 false active?: boolean; //觸發過 onFocus 則永遠為 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的朋友,會發現這個結構定義跟fomily很像。沒錯,就是這個介面的定義就是借鑑(抄)了formily。

邏輯編排設計器狀態樹 這個有機會再單獨成文介紹吧。

軟體架構

軟體被劃分為兩個比較獨立的部分: - 設計器,用於設計頁面,消費的是設計形態的元件。生成頁面Schema。 - 執行時,把設計器生成的頁面Schema,渲染為正常執行的頁面,消費的是預覽形態的元件。 採用分層設計架構,上層依賴下層。

設計器架構

設計器的最底層是core包,在它之上是react-core、vue-core,再往上就是shell層,比如Antd shell、Mui shell等。下圖是架構圖,圖中虛線表示只是規劃尚未實現的部分,實線是已經實現的部分。後面的介紹,也是以已經實現的 React 為主。 image

core包是整個設計器的基礎,包含了 Redux 狀態樹、頁面互動邏輯,編輯器的各種狀態等。

react-core 包定義了 react 相關的基礎元件,把 core 包功能封裝為hooks。

react-shells 包,針對不同元件庫的具體實現,比如 antd 或者 mui 等。

執行時架構

執行時包含三個包:ComponentRender、fieldy跟minions,前者依賴後兩者。 image

fieldy 是資料模型,用於組織頁面資料,比如表單、欄位等。

minions(小黃人)是控制器部分,用於控制頁面的業務邏輯以及元件間的聯動關係。

ComponertRender 負責把Schema 渲染為正常執行的頁面。

core包的設計

Core包是基於介面的設計,這樣的設計方式有個明顯的優點,就是清晰模組間的依賴關係,封裝了具體的實現細節,能方便的單獨替換某個模組。Core 包含的模組: image

設計器引擎是 IDesignerEngine 介面的具體實現,也是 Core 包入口,通過 IDesignerEngine 可以訪問包內的其它模組。介面定義: export interface IDesignerEngine { //獲取設計器當前語言程式碼,比如:zh-CN, en-US... getLanguage(): string //設定設計設計語言程式碼 setLanguage(lang: string): void //中建立一個文件模型,注:設計器是多文件模型,core支援同時編輯多個文件 createDocument(schema: INodeSchema): IDocument //通過 id 獲取文件模型 getDocument(id: ID): IDocument | null //通過節點 id 獲取節點所屬文件模型 getNodeDocument(nodeId: ID): IDocument | null //獲取所有文件模型 getAllDocuments(): IDocument[] | null //獲取監視器 monitor,監視器用於傳遞Redux store的狀態資料 getMonitor(): IMonitor //獲取Shell模組,shell用與獲取設計器的事件,比如滑鼠移動等 getShell(): IDesignerShell //獲取元件管理器,元件管理器管理元件物料 getComponentManager(): IComponentManager //獲取資源管理器,資源是指左側工具箱上的資源,一個資源對應一個元件或者一段元件模板 getResourceManager(): IResourceManager //獲取國語言資源管理器 getLoacalesManager(): ILocalesManager //獲取裝飾器管理器,裝飾器是設計器的輔助工具,主要用於給畫布內的節點新增附加dom屬性,比如outline,輔助邊距,資料繫結提示等 getDecoratorManager(): IDecoratorManager //獲取設計動作,動作的實現方法,大部分會轉換成redux的action getActions(): IActions //註冊外掛,rxeditor是組合式設計,外掛沒有功能性介面,只是為了統一銷燬被組合的物件,提供了簡單的銷燬介面 registerPlugin(pluginFactory: IPluginFactory): void //獲取外掛 getPlugin(name: string): IPlugin | null //傳送 redux action dispatch(action: IAction<any>): void //銷燬設計器 destory(): void //獲取一個節點的行為規則,比如是否可拖放等 getNodeBehavior(nodeId: ID): NodeBehavior } Redux store 是設計其引擎的狀態管理模組,通過Monitor模組跟文件模型,把最新的狀態傳遞出去。

監視器(IMonitor)模組,提供訂閱介面,釋出設計器狀態。

動作管理(IActions)模組,把部分常用的Redux actions 封裝成通用介面。

文件模型(IDocument),Redux store儲存了文件的狀態資料,文件模型直接使用Redux store,並將其分裝為更直觀的介面: export interface IDocument { //唯一標識 id: ID //銷燬文件 destory(): void //初始化 initialize(rootSchema: INodeSchema, documentId: ID): void //把一個節點移動到樹形結構的指定位置 moveTo(sourceId: ID, targetId: ID, pos: NodeRelativePosition): void //把多個節點移動到樹形結構的指定位置 multiMoveTo(sourceIds: ID[], targetId: ID, pos: NodeRelativePosition): void //新增新節點,把元件從工具箱拖入畫布,會呼叫這個方法 addNewNodes(elements: INodeSchema | INodeSchema[], targetId: ID, pos: NodeRelativePosition): NodeChunk //刪除一個節點 remove(sourceId: ID): void //克隆一個節點 clone(sourceId: ID): void //修改節點meta資料,右側屬性面板呼叫這個方法修改資料 changeNodeMeta(id: ID, newMeta: INodeMeta): void //刪除元件卡槽位的元件 removeSlot(id: ID, name: string): void //給一個元件卡槽插入預設元件 addSlot(id: ID, name: string): void //傳送一個redux action dispatch(action: IDocumentAction<any>): void //把當前文件狀態備份為一個快照 backup(actionType: HistoryableActionType): void //撤銷時呼叫 undo(): void //重做是呼叫 redo(): void //定位到某個操作快照,撤銷、重做的補充 goto(index: number): void //獲取文件根節點 getRootNode(): ITreeNode | null //通過id獲取文件節點 getNode(id: ID): ITreeNode | null //獲取節點schema,相當於把ItreeNode樹轉換成 schema 樹 getSchemaTree(): INodeSchema | null } 元件管理器(IComponentManager),管理元件資訊(元件註冊、獲取等)。

資源管理器(IResourceManager),管理工具箱的元件、模板資源(資源註冊、資源獲取等)。

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

Shell管理(IDesignerShell),與介面互動的通用邏輯,基於事件模型實現,類圖: image

DesignerShell類聚合了多個驅動(IDriver),驅動通過IDispatchable介面(DesignerShell就實現了這個介面,程式碼中使用的就是DesignerShell)把事件傳送給 DesignerShell,再由 DesignerShell 把事件分發給其它訂閱者。驅動的種類有很多,比如鍵盤事件驅動、滑鼠事件驅動、dom事件驅動等。不同的shell實現,需要的驅動也不一樣,比如畫布用div實現跟iframe實現,需要的驅動會略有差異。

隨著後續的進展,可以有更多的驅動被組合進專案。

外掛(IPlugin),RxEditor組合式的編輯器,只要拿到 IDesignerEngine 例項,就可以擴充套件編輯器的功能。只是有的時候需要在編輯器退出的時候,需要統一銷燬某些資源,故而加入了一個簡單的IPlugin介面: export interface IPlugin { //唯一名稱,可用於覆蓋預設值 name: string, destory(): void, } 程式碼中的 core/auxwidgets 跟 core/controllers 都是 IPlugin 的實現,檢視這些程式碼,就可以明白具體功能是怎麼被組合進設計器的。實際程式碼中,為了更好的組合,還定義了一個工廠介面: export type IPluginFactory = ( engine: IDesignerEngine, ) => IPlugin 建立 IDesignerEngine 的時候直接傳入不同的 Plugin 工廠就可以: ``` export function createEngine( plugins: IPluginFactory[], options: { languange?: string, debugMode: boolean, } ): IDesignerEngine { //構建IDesignerEngine .... }

const eng = createEngine( [ StartDragController, SelectionController, DragStopController, DragOverController, ActiveController, ActivedOutline, SelectedOutline, GhostWidget, DraggedAttenuator, InsertionCursor, Toolbar, ], { debugMode: false } ) ``` 裝飾器管理(IDecoratorManager),裝飾器用於給畫布內的節點,插入html標籤或者屬性。這些插入的元素不依賴於節點的編輯狀態(依賴於編輯狀態的,通過外掛插入,比如輪廓線),比如給所有的節點加入輔助的outline,或者標識出已經綁定了後端資料的節點。可以自定義多種型別的裝飾器,動態插入編輯器。

裝飾器的介面定義: ``` export interface IDecorator { //唯一名稱 name: string //附加裝飾器到dom節點 decorate(el: HTMLElement, node: ITreeNode): void; //從dom節點,解除安裝裝飾器 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 } 一個輔助輪廓線的示例: 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 包

這個包是使用 React 對 core 進行的封裝,並且提供一些通用 React 元件,不依賴具體的元件庫(類似antd,mui等)。

上下文(Contexts)

DesignerEngineContext 設計引擎上下文,用於下發 IDesignerEngine 例項,包裹在設計器最頂層。

DesignComponentsContext 設計形態元件上下文,註冊進設計器的元件,它們的設計形態通過這個上下文下發。

PreviewComponentsContext 預覽形態元件上下文,註冊進設計器的元件,他們的預覽形態通過這個上下文下發。

DocumentContext 文件上下文,下發一個文件模型(IDocument),包裹在文件檢視的頂層。

NodeContext 節點上下文,下發 ITreeNode,每個節點包裹一個這樣的上下文。

通用元件

Designer 設計器根元件。

DocumentRoot 文件檢視根元件。

ComponentTreeWidget 在畫布上渲染節點樹,呼叫 ComponentDesignerView 遞迴實現。

畫布(Canvas)

實現不依賴具體畫布。使用 ComponentTreeWidget 元件實現。

core 包定義了畫布介面 IShellPane,和不同的畫布實現邏輯(headless的):IFrameCanvasImpl(把畫布包放入iframe的實現邏輯),ShadowCanvasImpl(把畫布放入Web component的實現邏輯)。如果需要,可以做一個div的畫布實現。

在react-core包,把畫布的實現邏輯跟具體介面元件掛接到一起,具體可以閱讀相關程式碼,有問題歡迎留言。

畫布的實現方式大概有三種方式,都有各自的優缺點,下面分別說說。

div實現方式,把設計器元件樹渲染在一個div內,跟設計器沒有隔離,這中實現方式比較簡單,效能也好。缺點就是js上下文跟css樣式沒有隔離機制,被設計頁面的樣式不夠獨立。類似 position:fixed 的樣式需要在畫布最外層加一個隔離,比如:transform:scale(1) 。

響應式佈局,是指隨著瀏覽器的大小改變,會呈現不同的樣式,css中使用的是 @media 查詢,比如: @media (min-width: 1200){ //>=1200的裝置 } @media (min-width: 992px){ //>=992的裝置 } @media (min-width: 768px){ //>=768的裝置 } 一個設計器中,如果能通過調整畫布的大小來觸發@media的選擇,就可以直觀的看到被設計的內容在不同裝置上的外觀。div作為畫布,是模擬不了瀏覽器大小的,無法觸發@media 查詢,對響應式頁面的設計並不十分友好。

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

iframe實現方式,把設計器元件樹渲染在 iframe 內,iframe會隔離js跟css,並且iframe尺寸的變化也會觸發 @media 查詢,是非常理想的實現方式,RxEditor 最終也鎖定在了這種實現方式上。

往iframe內部渲染元件,也有不同的渲染方式。在 RxEditor 專案中,嘗試過兩種方式:

ReactDOM.Root.render渲染,這種方式需要拿到iframe裡面第一個div的dom,然後傳入ReactDOM.createRoot。相當於在主程式渲染畫布元件,這種實現方式效能還是不錯的,畫面沒有閃爍感。但是,元件用的css樣式跟js連結,需要從外部傳入iframe內部。很多元件庫的不相容這樣實現方式,比如 antd 的 popup 系列元件,在這種方式下很難正常工作,要實現類似功能,不得不重寫元件,與設計原則 “儘量減少對元件的入侵,最大程度使用已有元件資源” 相悖。 iframe.src方式渲染,定義一個畫布渲染元件,並配置路由,把路由地址傳入iframe.src: ``` ... } > ...

//iframe渲染

``` 這樣的渲染方式,完美解決了上述各種問題,就是渲染畫布的時候,需要一段時間初始化React,效能上比上述方式略差。另外,熱載入進來的元件不能通過window全域性物件的形式傳入iframe,熱載入需要在iframe內部完成,否則React會報衝突警告。

react-shells 包

依賴於元件庫部分的實現,目前只是先了 antd 版本。程式碼就是普通react元件跟鉤子,直接翻閱一下原始碼就好,有問題歡迎留言。

runner 包

這個包是執行時,以正常執行的方式渲染設計器生產的頁面,消費的是預覽形態的元件。設計器右側的屬性面板也是基於低程式碼實現,使用的是這個包。

runner 包能渲染一個完整的前端應用,包含表單資料繫結,元件的聯動。採用模型資料、行為、UI介面三者分離的方式。

資料模型在 fieldy 模組定義,基於Redux實現,前面已經介紹過其介面。這個模組,在邏輯上管理一棵資料樹,元件可以繫結樹的具體節點,一個節點可以繫結多個元件。繫結方式,在 schema 的 x-field 欄位定義。

本文的開始的設計原則中說過,儘量減少對元件的入侵,最大程度使用已有元件資源。這就意味著,控制組件的時候,不要重寫元件或者侵入其內部,而是通過元件對外的介面props來控制。在元件外層,包裝一個控制器,來實現對元件的控制。比如一個元件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() 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}>    
)

}) ``` 這段程式碼,相當於把元件的控制邏輯抽象到ControllerA內部,通過 props 更改 ComponentA 的狀態。ControllerA 的例項可以註冊到全域性或者通過Context下發到子元件(上面算是虛擬碼,未展示這部分),其它元件可以通過ControllerA 的例項,傳遞聯動控制。

在RxEditor中,控制器例項是通過Context逐級下發的,子元件可以呼叫所有父元件的控制器,因為控制器本身是個類,所以可以通過屬性變數傳遞資料,實際的控制器定義如下: ``` //變數控制器,用於元件間共享資料 export interface IVariableController { setVariable(name: string, value: any): void, getVariable(name: string): any, subscribeToVariableChange(name: string, listener: VariableListener): void }

//屬性控制器,用於設定元件屬性 export interface IPropController { setProp(name: string, value: any): void }

//元件控制器介面 export interface IComponentController extends IVariableController, IPropController { //唯一Id id: string, //並稱,編排時作為標識 name?: string, //邏輯編排的meta資料 meta: IControllerMeta, subscribeToPropsChange(listener: PropsListener): UnListener destory(): void, //其它 ... } ``` runner 渲染跟設計器一樣,是通過 ComponentView 元件遞迴完成的。所以 ComponentAController 可以提取為一個高階元件 withController(具體實現請閱讀程式碼),ComponentView 渲染元件時,根據schema配置,如果配置了 x-reactions,就給元件包裹高階元件withController,實現元件控制器的繫結。如果配置了x-field,就給元件包裹一個數據繫結的高階元件 withBind。

ComponentRender 呼叫 ComponentView, 通過遞迴機制把schema樹渲染為真實頁面。渲染時,會根據x-field的配置渲染fieldy模組的一些元件,完成資料模型的建立。

另外,IComponentController 的具體實現,依賴邏輯編排,邏輯編排的實現原理在下一節介紹。

邏輯編排

一直對邏輯編排不是很感興趣,覺得用圖形化的形式實現程式碼邏輯,不會有什麼優勢。直到看到 mybricks 的邏輯編排,才發現換個思路,可以把業務邏輯元件化,邏輯編排其實大有可為。

接下來,以打地鼠邏輯為例,說一下邏輯編排的實現思路。

打地鼠的介面: image

左側9個按鈕是地鼠,每隔1秒會隨機活動一隻(變為藍色),滑鼠點選活動地鼠為擊中(變為紅色,並且積分器上記1分),右側上方的輸入框為計分器,下面是兩個按鈕用來開始或者結束遊戲。

前面講過,RxEditor 元件控制器是通過Context下發到子元件的,就是是說只有子元件能訪問父元件的控制器,父元件訪問不了子元件的控制器,兄弟元件之間也不能相互訪問控制器。如果通過全域性註冊控制器的方式,元件之間就可以隨意訪問控制器,實現這種地鼠邏輯會簡單些。但是,如果全域性的方式註冊控制器,會帶來一個新的問題,就是動態表格的控制器不好註冊,表格內的控制元件是動態生成的,他的控制器不好在設計時繫結,所以目前只考慮Context的實現方式。

遊戲主控制器 在最頂層的元件 antd Row 上加一個一個遊戲控制,控制器取名“遊戲容器”: image

這個控制器的視覺化配置: image

這個視覺化配置的實現原理,改天再寫吧,這裡只介紹如何用它實現邏輯編排。

這是一個基於資料流的邏輯編排引擎,資料從節點的輸入埠(左側埠)流入,經過處理以後,再從輸出埠(右側埠)流出。流入與流出是基於回撥的方式實現(類似Promise),並且每個節點可以有自己的狀態,所以上圖跟流程圖有個本質的不同,流程圖是單線指令碼,而上圖每一個節點是一個物件,有點像電影《超級奶爸》裡面的小黃人,所以我給這個邏輯編排功能起名叫minions(小黃人),不同的是,這裡的小黃人可以組合成另外一個小黃人,可以任意巢狀、任意組合。

這樣的實現機制相當於把業務邏輯元件化了,然後再把業務邏輯元件視覺化。

控制器的事件元件內建的,antd 的 Row 內建了三個事件:初始化、銷燬、點選。可以在這些事件裡實現具體的業務邏輯。本例中的初始化事件中,實現了打地鼠的主邏輯: image

監聽“執行”變數,如果為true,啟動一個訊號發生器,訊號發生器每1000毫秒產生一個訊號,遊戲開始;如果為false,則停止訊號發生器,遊戲結束。訊號發生器產生訊號以後,傳遞給一個隨機數生成器,用於生成一個代表地鼠編號的隨機數,這個隨機數賦值給變數”活躍地鼠“,地鼠元件會訂閱變數”活躍地鼠“,如果變數值跟自己的編號一致,就把自己變為啟用狀態

互動相當於類的方法(實際上用一個類來實現),是自定義的。這裡定義了三個互動:開始、結束、計分,一個互動就是一個類,可以通過Context下發到子元件,子元件可以例項化並用它們來組合自己的邏輯。

開始,就是把變數”執行“賦值為true,用於啟動遊戲。

結束,就是把變數”執行“賦值為false,用於結束遊戲。

計分,就是把成績+1

變數相當於元件控制器類的屬性,外部可以通過 subscribeToVariableChange 方法訂閱變數的變化。

地鼠控制器

在初始化事件中,地鼠訂閱父元件”遊戲容器“的活躍地鼠變數,通過條件判斷節點判斷是否跟自己編號一致,如果一致,把按鈕的disabled屬性設定為常量false,並啟動延時器,延時2000毫秒以後,設定disabled為常量true,並重置按鈕顏色(danger屬性設定為false)。

點選事件的編排邏輯: image

給danger屬性賦值常量true(按鈕變紅),呼叫遊戲容器的計分方法,增加積分。

其它元件也是類似的實現方式,這裡就不展開了。具體的實現例子,請參考線上演示。

這裡只是初步介紹了邏輯編排的大概原理,詳細實現有機會再起一篇專門文章來寫吧。

總結

本文介紹了一個視覺化前端的實現原理,包括視覺化編輯、執行時渲染等方面內容,所涵蓋內容,可以構建一個完整低程式碼前端,只是限於精力有限、篇幅有限,很多東西沒有展開,詳細的可以翻閱一下實現程式碼。有問題,歡迎留言