如何制作一個高擴展、可視化低代碼前端?(如何制作一個高擴展,可視化低代碼前端的軟件)
RxEditor是一款開源企業(yè)級可視化低代碼前端,目標是可以編輯所有 HTML 基礎的組件。比如支持 React、VUE、小程序等,目前僅實現(xiàn)了 React 版。
RxEditor運行快照:
項目地址:https://github.com/rxdrag/rxeditor
演示地址( Vercel 部署,需要科學的方法才能訪問):https://rxeditor.vercel.app/
本文介紹RxEditor 設計實現(xiàn)方法,盡可能包括技術(shù)選型、軟件架構(gòu)、具體實現(xiàn)中碰到的各種小坑、預覽渲染、物料熱加載、前端邏輯編排等內(nèi)容。
注:為了方便理解,文中引用的代碼濾除了細節(jié),是實際實現(xiàn)代碼的簡化版
設計原則
- 盡量減少對組件的入侵,最大程度使用已有組件資源。
- 配置優(yōu)先,腳本輔助。
- 基礎功能原子化,組合式設計。
- 物料插件化、邏輯組件化,盡可能動態(tài)插入系統(tǒng)。
基礎原理
項目的設計目標,是能夠通過拖拽的方式操作基于 HTML 制作的組件,如:調(diào)整這些組件的包含關(guān)系,并設置組件屬性。
不管是 React、Vue、Angluar、小程序,還是別的類似前端框架,最終都是要把 JS 組件,以DOM節(jié)點的形式渲染出來。
編輯器(RxEditor)要維護一個樹形模型,這個模型描述的是組件的隸屬關(guān)系,以及 props。同時還能跟 dom 樹交互,通過各種 dom 事件,操作組件模型樹。
這里關(guān)鍵的一個點是,編輯器需要知道 dom 節(jié)點跟組件節(jié)點之間的對應關(guān)系。在不侵入組件的前提下,并且還要忽略前端庫的差異,比較理想的方法是給 dom 節(jié)點賦一個特殊屬性,并跟模型中組件的 id 對應,在 RxEditor 中,這個屬性是rx-id,比如在dom節(jié)點中這樣表示:
<div rx-id="one-uuid"> </div>
編輯器監(jiān)聽 dom 事件,通過事件的 target 的 rx-id 屬性,就可以識別其在模型中對應組件節(jié)點。也可以通過 document.querySelector([rx-id="${id}"])方法,查找組件對應的 dom 節(jié)點。
除此之外,還加了 rx-node-type 跟 rx-status 這兩個輔助屬性。rx-node-type 屬性主要用來識別是工具箱的Resource、畫布內(nèi)的普通節(jié)點還是編輯器輔助組件,rx-status 計劃是多模塊編輯使用,不過目前該功能尚未實現(xiàn)。
rx-id 算是設計器的基礎性原理,它給設計器內(nèi)核抹平了前端框架的差異,幾乎貫穿設計器的所有部分。
Schema 定義
編輯器操作的是JSON格式的組件樹,設計時,設計引擎根據(jù)這個組件樹渲染畫布;預覽時,執(zhí)行引擎根據(jù)這個組件樹渲染實際頁面;代碼生成時,可以把這個組件樹生成代碼;保存時,直接把它序列化存儲到數(shù)據(jù)庫或者文件。這個組件樹是設計器的數(shù)據(jù)模型,通常會被叫做 Schema。
像阿里的 formily,它的Schema 依據(jù)的是JSON Schema 規(guī)范,并在上面做了一些擴展,他在描述父子關(guān)系的時候,用的是properties鍵值對:
{ <---- 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" }, ...... }}
用鍵值對的方式存子組件(children)有幾個明顯的問題:
- 用這樣的方式渲染預覽界面時,一個字段只能綁定一個控件,無法綁定多個,因為key值唯一。
- 鍵值對不攜帶順序信息,存儲到數(shù)據(jù)庫JSON類型的字段時,具體的后端實現(xiàn)語言要進行序列化與反序列化的操作,不能保證順序,為了避免出問題,不得不加一個類似index的字段來記錄順序。
- 設計器引擎內(nèi)部操作時,用的是數(shù)組的方式記錄數(shù)據(jù),傳輸?shù)胶蠖舜鎯r,不得不進行轉(zhuǎn)換。
鑒于上述問題,RxEditor采用了數(shù)組的形式來記錄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的例子,相應轉(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 是組件控制邏輯,通過前端編排來實現(xiàn),這兩個后面會詳細介紹。
需要注意的是卡槽(slots),這個是 RxEditor 的原創(chuàng)設計,原生 Schema 直接支持卡槽,可以很大程度上支持現(xiàn)有組件,比如很多 React antd 組件,不需要封裝就可以直接拉到設計器里來用,關(guān)于卡槽后面還會有更詳細的介紹。
組件形態(tài)
項目中的前端組件,要在兩個地方渲染,一是設計引擎的畫布,另一處是預覽頁面。這兩處使用的是不同渲染引擎,對組件的要求也不一樣,所以把組件分定義為兩個形態(tài):
- 設計形態(tài),在設計器畫布內(nèi)渲染,需要提供ref或者轉(zhuǎn)發(fā)rx-id,有能力跟設計引擎交互。
- 預覽形態(tài),預覽引擎使用,渲染機制跟運行時渲染一樣。相當于普通的前端組件。
設計形態(tài)的組件跟預覽形態(tài)的組件,對應的是同一份schema,只是在渲染時,使用不同的組件實現(xiàn)。
接下來,以React為例,詳細介紹組件設計形態(tài)與預覽形態(tài)之間的區(qū)別與聯(lián)系,同時也介紹了如何制作設計形態(tài)的組件。
有 React ref 的組件
這部分組件是最簡單的,直接拿過來使用就好,這些組件的設計形態(tài)跟預覽形態(tài)是一樣的,在設計引擎這樣渲染:
export const ComponentDesignerView = memo((props: { nodeId: string }) => { const { nodeId } = props; //獲取數(shù)據(jù)模型樹中對應的節(jié)點 const node = useTreeNode(nodeId); //通過ref,給 dom 賦值rx-id const handleRef = usecallback((element: HTMLElement | undefined) => { element?.setAttribute("rx-id", node.id) }, [node.id]) //拿到設計形態(tài)的組件 const Component = useDesignComponent(node?.meta?.componentName); return (<Component ref={handleRef} {...realProps} > </Component>)}))
只要 rx-id 被添加到 dom 節(jié)點上,就建立了 dom 與設計器內(nèi)部數(shù)據(jù)模型的聯(lián)系。
預覽引擎的渲染相對更簡單直接:
export type ComponentViewProps = { node: IComponentRenderSchema,}export const ComponentView = memo(( props: ComponentViewProps) => { const { node, ...other } = props //拿到預覽形態(tài)的組件 const Component = usePreviewComponent(node.componentName) return ( <Component {...node.props} {...other}> { node.children?.map(child => { return (<ComponentView key={child.id} node={child} />) }) } </Component> )})
無ref,但可以把未知屬性轉(zhuǎn)發(fā)到合適的dom節(jié)點上
比如一個React組件,實現(xiàn)方式是這樣的:
export const ComponentA = (props)=>{ const {propA, propB, ...rest} = props ... return( <div {...rest}> ... </div> )}
除了 propA 跟 propB,其它的屬性被原封不動的轉(zhuǎn)發(fā)到了根div上,這樣的組件在設計引擎里面可這樣渲染:
export const ComponentDesignerView = memo((props: { nodeId: string }) => { const { nodeId } = props; //獲取數(shù)據(jù)模型樹中對應的節(jié)點 const node = useTreeNode(nodeId); //拿到設計形態(tài)的組件 const Component = useDesignComponent(node?.meta?.componentName); return (<Component rx-id={node.id} {...node?.meta?.props} > </Component>)}))
通過這樣的方式,rx-id 被同樣添加到 dom 節(jié)點上,從而建立了數(shù)據(jù)模型與 dom之間的關(guān)聯(lián)。
通過組件 id 拿到 ref
有的組件,既不能提供合適的ref,也不能轉(zhuǎn)發(fā)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<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)
使用這種方式時,要確保組件的id沒有其它用途。
嵌入隱藏元素
如果一個組件,通過上述方式安插 rx-id 都不合適,這個組件恰好有 children 的話,可以在 children 里面插入一個隱藏元素,通過隱藏元素 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é)點畫編輯時的輪廓線的話,會顯的別扭,有個這樣實現(xiàn)的組件:
export const ComponentA = forwardRef<HTMElement>((props: any, ref) => { return (<div style={padding:16}> <div ref={ref}> ... </div> </div>)})
編輯時這個組件的輪廓線,會顯示在內(nèi)層 div,距離外層 div 差了16個像素。為了把rx-id插入到外層 div, 加入一個轉(zhuǎn)換 ref 的高階組件:
// 傳出真實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)
組件外層包一個 div
如果一個組件,既不能提供合適的ref,不能轉(zhuǎn)發(fā)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<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)
這個實現(xiàn)方式有個明顯的問題,憑空添加了一個div,隔離了 css 上下文,為了保證設計器的顯示效果跟預覽時一樣,所見即所得,需要在組件的預覽形態(tài)上也加一個div,就是說直接修改原生組件,設計形態(tài)跟預覽形態(tài)都使用轉(zhuǎn)換后的組件。即便是這樣,也像做不可描述的事情時帶T一樣,有些許不爽。
帶卡槽(slots)的組件
Vue 中有卡槽,分為具名卡槽跟不具名卡槽,不具名卡槽就是 children。React 中沒有明確的卡槽概念,但是React.ReactNode 類型的 props 就相當于具名卡槽了。
在可視化設計器中,是需要卡槽的。
卡槽可以非常清晰的區(qū)分組建的各個區(qū)域,并且能很好地復用邏輯。
可視化編輯器中的拖拽,是把組件拖入(拖出)children(非具名卡槽),對于具名卡槽,這種普通拖放是無能無力的。
如果schema不支持卡槽,通常會特殊處理一下組件,就是在組件外封裝一層,并且還用不了高階組件。比如 antd 的 List 組件,它有 header 跟 footer 兩個 React.ReactNode 類型的屬性,這就是兩個卡槽。要想在設計器中使用這兩個卡槽,設計形態(tài)的組件一般會這么寫:
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樹和children構(gòu)造footer卡槽 ... }, [children, hasFooter]) const header = useMemo(()=>{ //這里根據(jù)Schema樹和children構(gòu)造header卡槽 ... }, [children, hasHeader]) return(<AntdList header = {header} header={footer} {...rest}}/>)}
組件的設計形態(tài)也需要類似的封裝,這里就不詳細展開了。
這個方式,相當于把所有的具名卡槽轉(zhuǎn)換成非具名卡槽,然后在渲染的時候,再根據(jù)配置把非具名卡槽解析成具名卡槽。hasHeader這類屬性不設置,也能解析,只是換了種實現(xiàn)方式,并無本質(zhì)區(qū)別。
擁有具名卡槽的前端庫太多了,每一種組件都這樣處理,復雜而繁瑣,并且違背了設計原則:“盡量減少對組件的入侵,最大程度使用已有組件資源”。
基于這個因素,把卡槽(slots)放入了 schema,只需要在渲染的時候跟非具名卡槽稍微做一下區(qū)別,就可以插入插槽:
export type ComponentViewProps = { node: IComponentRenderSchema,}export const ComponentView = memo(( props: ComponentViewProps) => { const { node, ...other } = props //拿到預覽形態(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> )})
這是預覽形態(tài)的渲染代碼,設計形態(tài)類似,此處不詳細展開了。
用這樣的方式處理卡槽,卡槽是不能被拖入的,只能通過屬性面板的配置打開或者關(guān)閉卡槽:
并且,卡槽只能是一個獨立節(jié)點,不能是節(jié)點數(shù)組,相當于把React.ReactNode轉(zhuǎn)換成了React.ReactElement,不過這個轉(zhuǎn)換對用戶體驗的影響并不大。
需要獨立制作設計形態(tài)的組件
通過上述各種高階組件、schema原生支持的slots,已有的組件,基本上不需要修改就可以納入可視化設計。
但是,也有例外。有些組件,還是需要獨立制作設計形態(tài)。需要獨立制作設計形態(tài)的組件,一般基于兩個方面的考慮:
- 用戶體驗;
- 業(yè)務邏輯復雜。
在用戶體驗方面,看一個例子,antd 的 Button 組件。Button的使用代碼:
<Button type="primary"> Primary Button</Button>
組件的children可以是 text 文本,text 文本不是一個組件,在編輯器中式很難被拖入的,要想拖入的話,可以加一個文本類型的組件 Text:
<Button type="primary"> <Text>Primary Button</Text></Button>
這樣就解決了拖放問題,并且Text組件可以在很多地方被使用,也不算增加實體。但是這樣每個Button 嵌套一個 Text方式,會大量增加設計器畫布中控件的數(shù)量,用戶體驗并不好。這種情況,最好重寫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>)}
進一步提取為高階組件:
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è)務邏輯復雜的例子,典型的是table,設計形態(tài)跟預覽形態(tài)的區(qū)別:
設計形態(tài)
預覽形態(tài)
這種組件,是需要特殊制作的,沒有什么簡單的辦法,具體實現(xiàn)請參考源碼。
Material,物料的定義
一個Schema,只是用來描述一個組件,這個組件相關(guān)的配置,比如多語言信息、在工具箱中的圖標、編輯規(guī)則(比如:它可以被放置在哪些組件下,不能被放在什么組件下)等等這些信息,需要一個配置來描述,這個就是物料的定義。具體定義:
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<ComponentType = any> { //npm包名 生成代碼用 packageName?: string, //組件名稱,要唯一,可以加點號:. componentName: string, //組件的預覽形態(tài) component: ComponentType, //組件的設計形態(tài) designer: ComponentType, //組件編輯規(guī)則,比如是否能作為另外組件的children behaviorRule?: IBehaviorRule //右側(cè)屬性面板的配置Schema designerSchema?: INodeSchema //組件的多語言資源 designerLocales?: ILocales //組件設計時的特殊props配置,比如Input組件的readOnly屬性 designerProps?: IDesignerProps //組件在工具箱中的配置 resource?: IResource //卡槽slots用到的組件,值為true時,用缺省組件DefaultSlot, // string時,存的是已經(jīng)注冊過的component resource名字 slots?: { [name: string]: IComponentConfig | true | string | undefined }, //右側(cè)屬性面板用的多語言資源 toolsLocales?: ILocales, //右側(cè)屬性面板用到的擴展組件。是的,組合式設計,都可以配置 tools?: { [name: string]: ComponentType | undefined },}
IBehaviorRule接口定義組建的編輯規(guī)則,隨著項目的逐步完善,這個接口大概率會變化,這里也沒必要在意這么細節(jié)的東西,要重點關(guān)注的是IComponentConfig接口,這就是一個物料的定義,泛型使用的ComponetType是為了區(qū)別前端差異,比如React的物料定義是這樣:
export type ReactComponent = React.FC<any> | React.ComponentClass<any> | stringexport interface IComponentMaterial extends IComponentConfig<ReactComponent> {}
物料如何使用
物料定義,包含了一個組件的所有內(nèi)容,直接注冊進設計器,就可以使用。后面會有相關(guān)講述。
物料的熱加載
一個不想熱加載的低代碼平臺,不是一個有出息的平臺。但是,這個版本并沒有來得及做熱加載,后續(xù)版本會補上。這里簡單分享前幾個版本的熱加載經(jīng)驗。
一個物料的定義是一個js對象,只要能拿到這個隊形,就可以直接使用。熱加載要解決的問題式拿到,具體拿到的方式可能有這么幾種:
import
js 原生import可以引入遠程定義的物料,但是這個方式有個明顯的缺點,就是不能跨域。如果沒有跨域需求,可以用這種方式。
webpack組件聯(lián)邦
看網(wǎng)上介紹,這種方式似乎可行,但并沒有嘗試過,有類似嘗試的朋友,歡迎留言。
src引入
這種方式可行的,并且以前的版本中已經(jīng)成功實現(xiàn),具體做法是在編譯的物料庫里,把物料的定義掛載到全局window對象上,在編輯器里動態(tài)創(chuàng)建一個 script 元素,在load事件中,從全局window對象上拿到定義,具體實現(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錯誤", 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;}
物料的單獨打包使用webpack,這個工具不是很熟練,勉強能用。有熟悉的大佬歡迎留言指導一下,不勝感激。
設計器的畫布目前使用的iframe,選擇iframe的原因,后面會有詳細介紹。使用iframe時,相當于一個應用啟動了兩套React,如果從設計器通過window對象,把物料傳給iframe畫布,react會報錯。所以需要在iframe內(nèi)部單獨熱加載物料,切記!
狀態(tài)管理
如果不考慮其它前端庫,只考慮React的話,狀態(tài)管理肯定會選擇recoil。如果要考慮vue、angular等其它前端,就只能放棄recoil,從知道的其它庫里選:redux、mobx、rxjs。
rxjs雖然看起來不錯,但是沒有使用經(jīng)驗,暫時放棄了。mobx,個人不喜歡,與上面的設計原則“盡量減少對組件的入侵,最大程度使用已有組件資源”相悖,也只能放棄。最后,選擇了Redux。
雖然Redux的代碼看起來會繁瑣一些,好在這種可視化項目本身的狀態(tài)并不多,這種繁瑣度是可以接受的。
在使用過程中發(fā)現(xiàn),Redux做低代碼狀態(tài)管理,有很多不錯的優(yōu)勢。足夠輕量,數(shù)據(jù)的流向清晰明了,可以精確控制訂閱。并且,Redux對配置是友好的,在可視化業(yè)務編排里,配置訂閱其狀態(tài)數(shù)據(jù)非常方便。
年少無知的的時候,曾經(jīng)詆毀過Reudx。不管以前說過多少Redux壞話,它還是優(yōu)雅地在那里,任你隨時取用,不介曾經(jīng)意被你誤解過,不在意是否被你咒罵過?;蛟S,這就是開源世界的包容。
目前項目里,有三個地方用到了Redux,這三處位置以后會獨立成三個npm包,所以各自維護自己的狀態(tài)樹的Root 節(jié)點,也就是分別維護自己的狀態(tài)樹。這三個狀態(tài)樹分別是:
設計器狀態(tài)樹
設計器引擎邏輯上維護一棵節(jié)點樹,節(jié)點樹跟帶 rx-id 的 dom 節(jié)點一一對應。前面定義的schema,是協(xié)議性質(zhì),用于傳輸、存儲。設設計引擎會把schema轉(zhuǎn)換成節(jié)點樹,然后展平存儲在Redux里面。節(jié)點樹的定義:
//這個INodeMeta跟上面Schema定義部分提到的,是一個export interface INodeMeta<IField = any, IReactions = any> { componentName: string, props?: { [key: string]: any, }, "x-field"?: IField, "x-reactions"?: IReactions,}//節(jié)點經(jīng)由Schema轉(zhuǎn)換而成export interface ITreeNode { //節(jié)點唯一ID,對應dom節(jié)點上的rx-id id: ID //組件標題 title?: string //組件描述 description?: string //組件Schema meta: INodeMeta //父節(jié)點Id parentId?: ID //子節(jié)點Id children: ID[] 是否是卡槽節(jié)點 isSlot: boolean, //卡槽節(jié)點id鍵值對 slots?: { [name: string]: ID } //文檔id,設計器底層模型支持多文檔 documentId: ID //標識專用屬性,不通過外部傳入,系統(tǒng)自動構(gòu)建 //包含rx-id,rx-node-type,rx-status三個屬性 rxProps?: RxProps //設計時的屬性,比如readOnly, open等 designerProps?: IDesignerProps //用來編輯屬性的schema designerSchema?: INodeSchema //設計器專用屬性,比如是否鎖定 //designerParams?: IDesignerParams}
展平到Redux里面:
//多文檔模型,一個文檔的狀態(tài)export type DocumentState = { //知否被修改過 changed: boolean, //被選中的節(jié)點 selectedIds: ID[] | null //操作快照 history: ISnapshot[] //根節(jié)點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 //當前激活文檔的id activedDocumentId: ID | null //所有文檔的節(jié)點,為了以后支持跨文檔拖放,全部節(jié)點放在根下 nodesById: NodesById}
數(shù)據(jù)模型狀態(tài)樹
fieldy模塊的數(shù)據(jù)模型主要用來管理頁面的數(shù)據(jù)模型,樹狀結(jié)構(gòu),Immutble的。數(shù)據(jù)模型中的數(shù)據(jù),通過 schema 的 x-field 屬性綁定到具體組件。
預覽頁面、右側(cè)屬性面板都是用這個模型(右側(cè)屬性面板就是一個運行時模塊,根頁面預覽使用相同的渲染引擎,就是說右側(cè)屬性面板是基于低代碼配置來實現(xiàn)的)。
狀態(tài)定義:
//字段狀態(tài)export type FieldState = { //自動生成id,用于組件key值 id: string; //字段名 name?: string; //基礎路徑 basePath?: string; //路徑,path=basePath "." name path: string; //字段是否已被初始化 initialized?: boolean; //字段是否已掛載 mounted?: boolean; //字段是否已卸載 unmounted?: boolean; //觸發(fā) onFocus 為 true,觸發(fā) onBlur 為 false active?: boolean; //觸發(fā)過 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的朋友,會發(fā)現(xiàn)這個結(jié)構(gòu)定義跟fomily很像。沒錯,就是這個接口的定義就是借鑒(抄)了formily。
邏輯編排設計器狀態(tài)樹
這個有機會再單獨成文介紹吧。
軟件架構(gòu)
軟件被劃分為兩個比較獨立的部分:
- 設計器,用于設計頁面,消費的是設計形態(tài)的組件。生成頁面Schema。
- 運行時,把設計器生成的頁面Schema,渲染為正常運行的頁面,消費的是預覽形態(tài)的組件。
采用分層設計架構(gòu),上層依賴下層。
設計器架構(gòu)
設計器的最底層是core包,在它之上是react-core、vue-core,再往上就是shell層,比如Antd shell、Mui shell等。下圖是架構(gòu)圖,圖中虛線表示只是規(guī)劃尚未實現(xiàn)的部分,實線是已經(jīng)實現(xiàn)的部分。后面的介紹,也是以已經(jīng)實現(xiàn)的 React 為主。
core包是整個設計器的基礎,包含了 Redux 狀態(tài)樹、頁面互動邏輯,編輯器的各種狀態(tài)等。
react-core 包定義了 react 相關(guān)的基礎組件,把 core 包功能封裝為hooks。
react-shells 包,針對不同組件庫的具體實現(xiàn),比如 antd 或者 mui 等。
運行時架構(gòu)
運行時包含三個包:ComponentRender、fieldy跟minions,前者依賴后兩者。
fieldy 是數(shù)據(jù)模型,用于組織頁面數(shù)據(jù),比如表單、字段等。
minions(小黃人)是控制器部分,用于控制頁面的業(yè)務邏輯以及組件間的聯(lián)動關(guān)系。
ComponertRender 負責把Schema 渲染為正常運行的頁面。
core包的設計
Core包是基于接口的設計,這樣的設計方式有個明顯的優(yōu)點,就是清晰模塊間的依賴關(guān)系,封裝了具體的實現(xiàn)細節(jié),能方便的單獨替換某個模塊。Core 包含的模塊:
設計器引擎是 IDesignerEngine 接口的具體實現(xiàn),也是 Core 包入口,通過 IDesignerEngine 可以訪問包內(nèi)的其它模塊。接口定義:
export interface IDesignerEngine { //獲取設計器當前語言代碼,比如:zh-CN, en-US... getLanguage(): string //設置設計設計語言代碼 setLanguage(lang: string): void //中創(chuàng)建一個文檔模型,注:設計器是多文檔模型,core支持同時編輯多個文檔 createDocument(schema: INodeSchema): IDocument //通過 id 獲取文檔模型 getDocument(id: ID): IDocument | null //通過節(jié)點 id 獲取節(jié)點所屬文檔模型 getNodeDocument(nodeId: ID): IDocument | null //獲取所有文檔模型 getAllDocuments(): IDocument[] | null //獲取監(jiān)視器 monitor,監(jiān)視器用于傳遞Redux store的狀態(tài)數(shù)據(jù) getMonitor(): IMonitor //獲取Shell模塊,shell用與獲取設計器的事件,比如鼠標移動等 getShell(): IDesignerShell //獲取組件管理器,組件管理器管理組件物料 getComponentManager(): IComponentManager //獲取資源管理器,資源是指左側(cè)工具箱上的資源,一個資源對應一個組件或者一段組件模板 getResourceManager(): IResourceManager //獲取國語言資源管理器 getLoacalesManager(): ILocalesManager //獲取裝飾器管理器,裝飾器是設計器的輔助工具,主要用于給畫布內(nèi)的節(jié)點添加附加dom屬性,比如outline,輔助邊距,數(shù)據(jù)綁定提示等 getDecoratorManager(): IDecoratorManager //獲取設計動作,動作的實現(xiàn)方法,大部分會轉(zhuǎn)換成redux的action getActions(): IActions //注冊插件,rxeditor是組合式設計,插件沒有功能性接口,只是為了統(tǒng)一銷毀被組合的對象,提供了簡單的銷毀接口 registerPlugin(pluginFactory: IPluginFactory): void //獲取插件 getPlugin(name: string): IPlugin | null //發(fā)送 redux action dispatch(action: IAction<any>): void //銷毀設計器 destory(): void //獲取一個節(jié)點的行為規(guī)則,比如是否可拖放等 getNodeBehavior(nodeId: ID): NodeBehavior}
Redux store 是設計其引擎的狀態(tài)管理模塊,通過Monitor模塊跟文檔模型,把最新的狀態(tài)傳遞出去。
監(jiān)視器(IMonitor)模塊,提供訂閱接口,發(fā)布設計器狀態(tài)。
動作管理(IActions)模塊,把部分常用的Redux actions 封裝成通用接口。
文檔模型(IDocument),Redux store存儲了文檔的狀態(tài)數(shù)據(jù),文檔模型直接使用Redux store,并將其分裝為更直觀的接口:
export interface IDocument { //唯一標識 id: ID //銷毀文檔 destory(): void //初始化 initialize(rootSchema: INodeSchema, documentId: ID): void //把一個節(jié)點移動到樹形結(jié)構(gòu)的指定位置 moveTo(sourceId: ID, targetId: ID, pos: NodeRelativePosition): void //把多個節(jié)點移動到樹形結(jié)構(gòu)的指定位置 multiMoveTo(sourceIds: ID[], targetId: ID, pos: NodeRelativePosition): void //添加新節(jié)點,把組件從工具箱拖入畫布,會調(diào)用這個方法 addNewNodes(elements: INodeSchema | INodeSchema[], targetId: ID, pos: NodeRelativePosition): NodeChunk //刪除一個節(jié)點 remove(sourceId: ID): void //克隆一個節(jié)點 clone(sourceId: ID): void //修改節(jié)點meta數(shù)據(jù),右側(cè)屬性面板調(diào)用這個方法修改數(shù)據(jù) changeNodeMeta(id: ID, newMeta: INodeMeta): void //刪除組件卡槽位的組件 removeSlot(id: ID, name: string): void //給一個組件卡槽插入默認組件 addSlot(id: ID, name: string): void //發(fā)送一個redux action dispatch(action: IDocumentAction<any>): void //把當前文檔狀態(tài)備份為一個快照 backup(actionType: HistoryableActionType): void //撤銷時調(diào)用 undo(): void //重做是調(diào)用 redo(): void //定位到某個操作快照,撤銷、重做的補充 goto(index: number): void //獲取文檔根節(jié)點 getRootNode(): ITreeNode | null //通過id獲取文檔節(jié)點 getNode(id: ID): ITreeNode | null //獲取節(jié)點schema,相當于把ItreeNode樹轉(zhuǎn)換成 schema 樹 getSchemaTree(): INodeSchema | null}
組件管理器(IComponentManager),管理組件信息(組件注冊、獲取等)。
資源管理器(IResourceManager),管理工具箱的組件、模板資源(資源注冊、資源獲取等)。
多語言管理器(ILocalesManager),管理多語言資源。
Shell管理(IDesignerShell),與界面交互的通用邏輯,基于事件模型實現(xiàn),類圖:
DesignerShell類聚合了多個驅(qū)動(IDriver),驅(qū)動通過IDispatchable接口(DesignerShell就實現(xiàn)了這個接口,代碼中使用的就是DesignerShell)把事件發(fā)送給 DesignerShell,再由 DesignerShell 把事件分發(fā)給其它訂閱者。驅(qū)動的種類有很多,比如鍵盤事件驅(qū)動、鼠標事件驅(qū)動、dom事件驅(qū)動等。不同的shell實現(xiàn),需要的驅(qū)動也不一樣,比如畫布用div實現(xiàn)跟iframe實現(xiàn),需要的驅(qū)動會略有差異。
隨著后續(xù)的進展,可以有更多的驅(qū)動被組合進項目。
插件(IPlugin),RxEditor組合式的編輯器,只要拿到 IDesignerEngine 實例,就可以擴展編輯器的功能。只是有的時候需要在編輯器退出的時候,需要統(tǒng)一銷毀某些資源,故而加入了一個簡單的IPlugin接口:
export interface IPlugin { //唯一名稱,可用于覆蓋默認值 name: string, destory(): void,}
代碼中的 core/auxwidgets 跟 core/controllers 都是 IPlugin 的實現(xiàn),查看這些代碼,就可以明白具體功能是怎么被組合進設計器的。實際代碼中,為了更好的組合,還定義了一個工廠接口:
export type IPluginFactory = ( engine: IDesignerEngine,) => IPlugin
創(chuàng)建 IDesignerEngine 的時候直接傳入不同的 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),裝飾器用于給畫布內(nèi)的節(jié)點,插入html標簽或者屬性。這些插入的元素不依賴于節(jié)點的編輯狀態(tài)(依賴于編輯狀態(tài)的,通過插件插入,比如輪廓線),比如給所有的節(jié)點加入輔助的outline,或者標識出已經(jīng)綁定了后端數(shù)據(jù)的節(jié)點??梢宰远x多種類型的裝飾器,動態(tài)插入編輯器。
裝飾器的接口定義:
export interface IDecorator { //唯一名稱 name: string //附加裝飾器到dom節(jié)點 decorate(el: HTMLElement, node: ITreeNode): void; //從dom節(jié)點,卸載裝飾器 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 設計引擎上下文,用于下發(fā) IDesignerEngine 實例,包裹在設計器最頂層。
DesignComponentsContext 設計形態(tài)組件上下文,注冊進設計器的組件,它們的設計形態(tài)通過這個上下文下發(fā)。
PreviewComponentsContext 預覽形態(tài)組件上下文,注冊進設計器的組件,他們的預覽形態(tài)通過這個上下文下發(fā)。
DocumentContext 文檔上下文,下發(fā)一個文檔模型(IDocument),包裹在文檔視圖的頂層。
NodeContext 節(jié)點上下文,下發(fā) ITreeNode,每個節(jié)點包裹一個這樣的上下文。
通用組件
Designer 設計器根組件。
DocumentRoot 文檔視圖根組件。
ComponentTreeWidget 在畫布上渲染節(jié)點樹,調(diào)用 ComponentDesignerView 遞歸實現(xiàn)。
畫布(Canvas)
實現(xiàn)不依賴具體畫布。使用 ComponentTreeWidget 組件實現(xiàn)。
core 包定義了畫布接口 IShellPane,和不同的畫布實現(xiàn)邏輯(headless的):IFrameCanvasImpl(把畫布包放入iframe的實現(xiàn)邏輯),ShadowCanvasImpl(把畫布放入Web component的實現(xiàn)邏輯)。如果需要,可以做一個div的畫布實現(xiàn)。
在react-core包,把畫布的實現(xiàn)邏輯跟具體界面組件掛接到一起,具體可以閱讀相關(guān)代碼,有問題歡迎留言。
畫布的實現(xiàn)方式大概有三種方式,都有各自的優(yōu)缺點,下面分別說說。
div實現(xiàn)方式,把設計器組件樹渲染在一個div內(nèi),跟設計器沒有隔離,這中實現(xiàn)方式比較簡單,性能也好。缺點就是js上下文跟css樣式?jīng)]有隔離機制,被設計頁面的樣式不夠獨立。類似 position:fixed 的樣式需要在畫布最外層加一個隔離,比如:transform:scale(1) 。
響應式布局,是指隨著瀏覽器的大小改變,會呈現(xiàn)不同的樣式,css中使用的是 @media 查詢,比如:
@media (min-width: 1200){ //>=1200的設備 }@media (min-width: 992px){ //>=992的設備 }@media (min-width: 768px){ //>=768的設備 }
一個設計器中,如果能通過調(diào)整畫布的大小來觸發(fā)@media的選擇,就可以直觀的看到被設計的內(nèi)容在不同設備上的外觀。div作為畫布,是模擬不了瀏覽器大小的,無法觸發(fā)@media 查詢,對響應式頁面的設計并不十分友好。
web component沙箱方式,用 shadow dom 作為畫布,把設計器組件樹渲染在 shadow dom 內(nèi)。這樣的實現(xiàn)方式,性能跟div方式差不多,還可以有效隔離js上下文跟css樣式,比div的實現(xiàn)方式稍微好一些,類似 position:fixed 的樣式還是需要在畫布最外層加一個隔離,比如:transform:scale(1) 。并且 shadow dom 不能模擬瀏覽器大小,它的大小改變也不能觸發(fā)無法觸發(fā)@media 查詢。
iframe實現(xiàn)方式,把設計器組件樹渲染在 iframe 內(nèi),iframe會隔離js跟css,并且iframe尺寸的變化也會觸發(fā) @media 查詢,是非常理想的實現(xiàn)方式,RxEditor 最終也鎖定在了這種實現(xiàn)方式上。
往iframe內(nèi)部渲染組件,也有不同的渲染方式。在 RxEditor 項目中,嘗試過兩種方式:
ReactDOM.Root.render渲染,這種方式需要拿到iframe里面第一個div的dom,然后傳入ReactDOM.createRoot。相當于在主程序渲染畫布組件,這種實現(xiàn)方式性能還是不錯的,畫面沒有閃爍感。但是,組件用的css樣式跟js鏈接,需要從外部傳入iframe內(nèi)部。很多組件庫的不兼容這樣實現(xiàn)方式,比如 antd 的 popup 系列組件,在這種方式下很難正常工作,要實現(xiàn)類似功能,不得不重寫組件,與設計原則 “盡量減少對組件的入侵,最大程度使用已有組件資源” 相悖。
iframe.src方式渲染,定義一個畫布渲染組件,并配置路由,把路由地址傳入iframe.src:
<Routes> ... <Route path={'/canvas-render'} element={<IFrameCanvasRender designers={designers} />} > </Route> ...</Routes>//iframe渲染<iframe ref={ref} src={'/canvas-render'} onLoad={handleLoaded}></iframe>
這樣的渲染方式,完美解決了上述各種問題,就是渲染畫布的時候,需要一段時間初始化React,性能上比上述方式略差。另外,熱加載進來的組件不能通過window全局對象的形式傳入iframe,熱加載需要在iframe內(nèi)部完成,否則React會報沖突警告。
react-shells 包
依賴于組件庫部分的實現(xiàn),目前只是先了 antd 版本。代碼就是普通react組件跟鉤子,直接翻閱一下源碼就好,有問題歡迎留言。
runner 包
這個包是運行時,以正常運行的方式渲染設計器生產(chǎn)的頁面,消費的是預覽形態(tài)的組件。設計器右側(cè)的屬性面板也是基于低代碼實現(xiàn),使用的是這個包。
runner 包能渲染一個完整的前端應用,包含表單數(shù)據(jù)綁定,組件的聯(lián)動。采用模型數(shù)據(jù)、行為、UI界面三者分離的方式。
數(shù)據(jù)模型在 fieldy 模塊定義,基于Redux實現(xiàn),前面已經(jīng)介紹過其接口。這個模塊,在邏輯上管理一棵數(shù)據(jù)樹,組件可以綁定樹的具體節(jié)點,一個節(jié)點可以綁定多個組件。綁定方式,在 schema 的 x-field 字段定義。
本文的開始的設計原則中說過,盡量減少對組件的入侵,最大程度使用已有組件資源。這就意味著,控制組件的時候,不要重寫組件或者侵入其內(nèi)部,而是通過組件對外的接口props來控制。在組件外層,包裝一個控制器,來實現(xiàn)對組件的控制。比如一個組件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}> )})
這段代碼,相當于把組件的控制邏輯抽象到ControllerA內(nèi)部,通過 props 更改 ComponentA 的狀態(tài)。ControllerA 的實例可以注冊到全局或者通過Context下發(fā)到子組件(上面算是偽代碼,未展示這部分),其它組件可以通過ControllerA 的實例,傳遞聯(lián)動控制。
在RxEditor中,控制器實例是通過Context逐級下發(fā)的,子組件可以調(diào)用所有父組件的控制器,因為控制器本身是個類,所以可以通過屬性變量傳遞數(shù)據(jù),實際的控制器定義如下:
//變量控制器,用于組件間共享數(shù)據(jù)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數(shù)據(jù) meta: IControllerMeta, subscribeToPropsChange(listener: PropsListener): UnListener destory(): void, //其它 ...}
runner 渲染跟設計器一樣,是通過 ComponentView 組件遞歸完成的。所以 ComponentAController 可以提取為一個高階組件 withController(具體實現(xiàn)請閱讀代碼),ComponentView 渲染組件時,根據(jù)schema配置,如果配置了 x-reactions,就給組件包裹高階組件withController,實現(xiàn)組件控制器的綁定。如果配置了x-field,就給組件包裹一個數(shù)據(jù)綁定的高階組件 withBind。
ComponentRender 調(diào)用 ComponentView, 通過遞歸機制把schema樹渲染為真實頁面。渲染時,會根據(jù)x-field的配置渲染fieldy模塊的一些組件,完成數(shù)據(jù)模型的建立。
另外,IComponentController 的具體實現(xiàn),依賴邏輯編排,邏輯編排的實現(xiàn)原理在下一節(jié)介紹。
邏輯編排
一直對邏輯編排不是很感興趣,覺得用圖形化的形式實現(xiàn)代碼邏輯,不會有什么優(yōu)勢。直到看到 mybricks 的邏輯編排,才發(fā)現(xiàn)換個思路,可以把業(yè)務邏輯組件化,邏輯編排其實大有可為。
接下來,以打地鼠邏輯為例,說一下邏輯編排的實現(xiàn)思路。
打地鼠的界面:
左側(cè)9個按鈕是地鼠,每隔1秒會隨機活動一只(變?yōu)樗{色),鼠標點擊活動地鼠為擊中(變?yōu)榧t色,并且積分器上記1分),右側(cè)上方的輸入框為計分器,下面是兩個按鈕用來開始或者結(jié)束游戲。
前面講過,RxEditor 組件控制器是通過Context下發(fā)到子組件的,就是是說只有子組件能訪問父組件的控制器,父組件訪問不了子組件的控制器,兄弟組件之間也不能相互訪問控制器。如果通過全局注冊控制器的方式,組件之間就可以隨意訪問控制器,實現(xiàn)這種地鼠邏輯會簡單些。但是,如果全局的方式注冊控制器,會帶來一個新的問題,就是動態(tài)表格的控制器不好注冊,表格內(nèi)的控件是動態(tài)生成的,他的控制器不好在設計時綁定,所以目前只考慮Context的實現(xiàn)方式。
游戲主控制器
在最頂層的組件 antd Row 上加一個一個游戲控制,控制器取名“游戲容器”:
這個控制器的可視化配置:
這個可視化配置的實現(xiàn)原理,改天再寫吧,這里只介紹如何用它實現(xiàn)邏輯編排。
這是一個基于數(shù)據(jù)流的邏輯編排引擎,數(shù)據(jù)從節(jié)點的輸入端口(左側(cè)端口)流入,經(jīng)過處理以后,再從輸出端口(右側(cè)端口)流出。流入與流出是基于回調(diào)的方式實現(xiàn)(類似Promise),并且每個節(jié)點可以有自己的狀態(tài),所以上圖跟流程圖有個本質(zhì)的不同,流程圖是單線腳本,而上圖每一個節(jié)點是一個對象,有點像電影《超級奶爸》里面的小黃人,所以我給這個邏輯編排功能起名叫minions(小黃人),不同的是,這里的小黃人可以組合成另外一個小黃人,可以任意嵌套、任意組合。
這樣的實現(xiàn)機制相當于把業(yè)務邏輯組件化了,然后再把業(yè)務邏輯組件可視化。
控制器的事件組件內(nèi)置的,antd 的 Row 內(nèi)置了三個事件:初始化、銷毀、點擊??梢栽谶@些事件里實現(xiàn)具體的業(yè)務邏輯。本例中的初始化事件中,實現(xiàn)了打地鼠的主邏輯:
監(jiān)聽“運行”變量,如果為true,啟動一個信號發(fā)生器,信號發(fā)生器每1000毫秒產(chǎn)生一個信號,游戲開始;如果為false,則停止信號發(fā)生器,游戲結(jié)束。信號發(fā)生器產(chǎn)生信號以后,傳遞給一個隨機數(shù)生成器,用于生成一個代表地鼠編號的隨機數(shù),這個隨機數(shù)賦值給變量”活躍地鼠“,地鼠組件會訂閱變量”活躍地鼠“,如果變量值跟自己的編號一致,就把自己變?yōu)榧せ顮顟B(tài)
交互相當于類的方法(實際上用一個類來實現(xiàn)),是自定義的。這里定義了三個交互:開始、結(jié)束、計分,一個交互就是一個類,可以通過Context下發(fā)到子組件,子組件可以實例化并用它們來組合自己的邏輯。
開始,就是把變量”運行“賦值為true,用于啟動游戲。
結(jié)束,就是把變量”運行“賦值為false,用于結(jié)束游戲。
計分,就是把成績 1
變量相當于組件控制器類的屬性,外部可以通過 subscribeToVariableChange 方法訂閱變量的變化。
地鼠控制器
在初始化事件中,地鼠訂閱父組件”游戲容器“的活躍地鼠變量,通過條件判斷節(jié)點判斷是否跟自己編號一致,如果一致,把按鈕的disabled屬性設置為常量false,并啟動延時器,延時2000毫秒以后,設置disabled為常量true,并重置按鈕顏色(danger屬性設置為false)。
點擊事件的編排邏輯:
給danger屬性賦值常量true(按鈕變紅),調(diào)用游戲容器的計分方法,增加積分。
其它組件也是類似的實現(xiàn)方式,這里就不展開了。具體的實現(xiàn)例子,請參考在線演示。
這里只是初步介紹了邏輯編排的大概原理,詳細實現(xiàn)有機會再起一篇專門文章來寫吧。
總結(jié)
本文介紹了一個可視化前端的實現(xiàn)原理,包括可視化編輯、運行時渲染等方面內(nèi)容,所涵蓋內(nèi)容,可以構(gòu)建一個完整低代碼前端,只是限于精力有限、篇幅有限,很多東西沒有展開,詳細的可以翻閱一下實現(xiàn)代碼。有問題,歡迎留言