實踐,製作一個高擴展、可視化低代碼前端,詳實、完整

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節點中這樣表示:

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

編輯器監聽 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 (<Component ref={handleRef} {...realProps} >
  </Component>)
}))

只要 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 (
    <Component {...node.props} {...other}>
      {
        node.children?.map(child => {
          return (<ComponentView key={child.id} node={child} />)
         })
      }
    </Component>
  )
})

無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 (<Component rx-id={node.id} {...node?.meta?.props} >
  </Component>)
}))

通過這樣的方式,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<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)

調整 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<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,不能轉發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> | string
export function wrapWithRef(WrappedComponent: ReactComponent):ReactComponent{
    return memo(forwardRef<HTMLDivElement>((props: any, ref) => {
        return <div ref = {ref}>
    	    <WrappedComponent {...props} />
        </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] = <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>
  )
})

這是預覽形態的渲染代碼,設計形態類似,此處不詳細展開了。

用這樣的方式處理卡槽,卡槽是不能被拖入的,只能通過屬性面板的配置打開或者關閉卡槽:
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<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' })

業務邏輯複雜的例子,典型的是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<ComponentType = any> {
  //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<any> 
    | React.ComponentClass<any> | string
export interface IComponentMaterial 
    extends IComponentConfig<ReactComponent> {
}

物料如何使用

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

物料的熱加載

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

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

import

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

webpack組件聯邦

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

src引入

這種方式可行的,並且以前的版本中已經成功實現,具體做法是在編譯的物料庫裏,把物料的定義掛載到全局window對象上,在編輯器裏動態創建一個 script 元素,在load事件中,從全局window對象上拿到定義,具體實現:

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("加載結果", 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 = {
  [id: ID]: ITreeNode
}
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:

<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內部完成,否則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<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內部,通過 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(按鈕變紅),調用遊戲容器的計分方法,增加積分。

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

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

總結

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章