元件註冊與畫布渲染

黃子毅發表於2023-01-16

接著視覺化搭建的理論抽象,我們開始勾勒一個具體的 React 視覺化搭建器。

精讀

假如我們將視覺化搭建整體定義為 <Designer>,那麼 API 可能是這樣的:

<Designer componentMetas={[]} componentTree={} />
  • componentMetas: 定義元件元資訊的陣列。
  • componentTree: 定義元件樹結構。

只要註冊了元件元資訊與元件樹,視覺化搭建的畫布就可以渲染出來了,這很好理解。

我們先看元件樹如何定義:

元件樹

元件樹裡有各元件的例項,那麼最好的設計是,元件樹與元件例項結構是同構的,稱為 ComponentInstance - 元件例項:

{
  "componentName": "container",
  "children": [
    {
      "componentName": "text",
      "props": {
        "name": "我是一個文字元件"
      }
    }
  ]
}

上面的結構既可以當做單個元件的 元件例項資訊,也可以認為是一個 元件樹,也就是元件樹的任何元件節點都可以拎出來成為一個新元件樹,這就是同構的含義。

我們定義了最最基礎的元件樹結構,以後所有功能都基於這三個要素來擴充:

  • componentName: 元件名,描述元件型別,比如是個文字、圖片還是表格。
  • props: 該元件例項的所有配置資訊,透傳給元件 props
  • children: 子元件,型別為 ComponentInstance[]

每一個概念都不可或缺,讓我們從概念必要性再分析一下這三個屬性:

  • componentName: 必須擁有的屬性,否則怎麼渲染該節點都無從談起。所以相應的,我們需要元件元資訊來定義每個元件名應該如何渲染。
  • props: 即便是相同元件名的不同例項,也可能擁有不同配置,這些配置放在 props 裡足夠了,沒必要開額外的其他屬性儲存各種各樣的業務配置。
  • children: 理論上可以合併到 props.children,但因為子元件概念太常見,建議 childrenprops.children 這兩種位置同時支援,同時定義時,前者優先順序更高。

除此之外,還有一個可選屬性 componentId,即元件唯一 ID。我們從可選性與必要性兩個角度分析一下這個屬性:

  • componentId 的可選性:元件例項在 元件樹的路徑 就是天然的元件唯一 ID,比如上面的文字元件的元件唯一 ID 可以認為是 children.0
  • componentId 的必要性:用元件樹路徑代替元件唯一 ID 的壞處是,元件在元件樹上移動後其唯一性就會消失,此時就要用上 componentId 了。

一個好的視覺化搭建實現是支援 componentId 的可選性。

元件元資訊

接著上面說的,至少要定義一個元件名是如何渲染的,所以元件元資訊(ComponentMeta)的必要結構如下:

const textMeta = {
  componentName: "text",
  element: ({ name }) => <span>{name}</span>,
};
  • componentName: 定義哪個元件名的元資訊。
  • element: 該元件的渲染函式。

實現這些最基礎功能後,雖然該視覺化搭建器沒有人任何實質性的功能,但至少完成了一個核心基礎工作:將元件樹結構的描述與實現分開了。哪怕以後什麼功能也不再增加,也永久的改變了開發模式,我們需要先定義元件元資訊,再將其放置在元件樹上。

對於畫板工具軟體,如果不考慮佈局等複雜的畫布功能,該結構描述足以完成大部分工作的技術抽象:配置皮膚修改元件例項的 props 屬性,甚至佈局位置也可以儲存在 props 上。

對於 element 的命名,可能會產生分歧,比如還有其他命名風格如 renderrendererreactNode 等等,但不管叫什麼名字,只要是基於 React 響應式定義的,最終應該都殊途同歸,最多對於各類 Key 的名稱定義有所不同,這塊可以保留自己的觀點。

我們繼續聚焦元件元資訊的 element 屬性,看以下 element 程式碼:

const divMeta = {
  componentName: "div",
  element: ({ children, header }) => (
    <div>
      {children}
      {header}
    </div>
  ),
};

上面的例子中,我們可以識別出 childrenheader 型別嗎?可以識別一部分:

  • children: 一定是 React 例項,可以是一個或多個元件例項。
  • header: 可能是數字、字串,也可能是 React 例項。

props.children 對應了 componentInstance.children 描述,那麼如何識別 header 是一個普通物件還是 React 例項呢?

Props 上的 ComponentTreeLike 屬性

ComponentTreeLike 指的是:元件 props 屬性上,識別出 “像元件例項的屬性”,並將其轉換為真正的元件例項傳給元件。

假設一個正常的 props.header 值為 "some text",那麼元件 props 實際拿到的 props.header 值也是字串 "some text"

{
  "componentName": "div",
  "props": {
    "header": "some text"
  }
}
const divMeta = {
  componentName: "div",
  element: ({ header }) => (
    <div>
      {header} {/** 字串 "some text" */}
    </div>
  ),
};

如果將 props.header 寫成類 children 結構,視覺化搭建框架就會識別為元件例項,將其轉化為真正的 React 例項再傳給元件:

{
  "componentName": "div",
  "props": {
    "header": [
      {
        "componentName": "text"
      }
    ]
  }
}
const divMeta = {
  componentName: "div",
  element: ({ header }) => (
    <div>
      {header} {/** React 元件例項,此時會渲染出元件例項 */}
    </div>
  ),
};

這樣設計是基於一個原則:元件樹應該能描述出任何元件想要的 props 屬性。我們反過來站在 element 角度來看,假設你注入了一個 Antd 等框架元件,如果在不改一行原始碼的情況下,就希望接入平臺,那平臺必須滿足可配置出任何 props 的能力。除了基礎變數外,更復雜的還有 React 元件例項與函式,現在我們解決了傳元件例項的問題,至於如何傳函式,我們下一小節再講。

這樣設計存在兩個缺陷:

  1. 由於 ComponentTreeLike 會自動轉成例項,所以沒有辦法讓元件拿到 ComponentTreeLike 的原始值。
  2. 由於 ComponentTreeLike 位置不確定,為了避免深層解析產生的效能損耗,只解析 props 的第一級節點會導致巢狀層級較深的 ComponentTreeLike 無法被解析到。

如果要解決這兩個缺陷,就需要在元件元資訊上定義 Props 的型別,比如:

const divMeta = {
  componentName: "div",
  propsType: {
    header: "element",
    content: ["element"],
    tabs: [
      {
        panel: "element",
      },
    ],
  },
};

解釋一下上面的例子代表的含義:

  • header: 是單個 React Element。
  • content: 是 React Element 陣列。
  • tabs: 是一個陣列結構,每一項是物件,其中 panel 是 React Element。

這樣配合以下元件樹的描述,就可以精確的將對應 element 型別轉化為元件例項了,而對於基本型別 primitive 保持原樣傳給元件:

{
  "componentName": "div",
  "props": {
    "header": {
      "componentName": "text"
    },
    "names": ["a", "b", "c"],
    "content": [
      {
        "componentName": "text"
      },
      {
        "componentName": "text"
      }
    ],
    "tabs": [
      {
        "title": "tab1",
        "panel": {
          "componentName": "text"
        }
      }
    ]
  }
}

如此一來,沒有定義為 Element 的屬性不會處理成 React 例項,第一個問題就自然解決了。透過配置更深層巢狀的結構,第二個問題也自然解決。

componentMeta.propsType 之所以不採用 JSONSchema 結構,是因為框架沒必要內建對 props 型別校驗的能力,這個能力可以交給業務層來處理,所以這裡就可以採用簡化版結構,方便書寫,也容易閱讀。

注意:propsType{} 表示 value 是物件,而 [] 表示 value 是陣列。為陣列時,僅支援單個子元素,因為單項即是對陣列每一項型別的定義。

給元件注入函式

現在已經能給 componentMeta.element 傳入任意基礎型別、React 例項的 props 了,現在還缺函式型別或者 Set、Map 等複雜型別問題需要解決。

由於元件樹結構需要序列化入庫,所以必須為一個可以序列化的 JSON 結構,而這個結構又需要暴露給開發者,所以也不適合定義一些 hack 的序列化、反序列化規則。因此要給元件 props 注入函式,需要定義在元件元資訊上,由於其定義了額外的 props 屬性,且不在元件樹中,所以我們將其命名為 runtimeProps:

const divMeta = {
  componentName: "div",
  runtimeProps: () => ({
    onClick: () => { console.log('click') }
  })
  element: ({ onClick }) => (
    <button onClick={onClick}>
      點選我
    </button>
  ),
};

點選按鈕後,會列印出 click。這是因為 runtimeProps 定義了函式型別 onClick 在執行時傳入了元件 props。

當元件樹與 componentMeta.runtimeProps 同時定義了同一個 key 時,runtimeProps 優先順序更高。

總結

本節我們介紹了元件註冊與畫布渲染的基礎內容,我們再重新梳理一下。

首先定義了 <Designer /> API,並支援傳入 componentTreecomponentMetas,有了元件樹與元件元資訊,就可以實現視覺化搭建畫布的渲染了。

我們還介紹瞭如何在元件元資訊定義元件的渲染函式,如何給渲染函式 props 傳入基本變數、React 例項以及函式,讓渲染函式可以對接任何成熟的元件庫,而不需要元件庫做任何適配工作。

但這只是視覺化搭建的第一步,在真正開始做專案後,你還會遇到越來越多的問題,比如除了渲染畫布,還要在業務層定義屬性配置皮膚、元件拖拽列表、圖層列表、撤銷重做等等功能,這些功能如何拿到畫布屬性?如何與畫布互動?runtimeProps 如何基於專案資料流給元件注入不同的屬性或函式?如何根據元件 props 的變化動態注入不同函式?如何保證注入的函式引用不變?

要解決這些問題,需要在本章的基礎上實現一套系統的資料流規則以及配套 API,這也是下一講的內容。

討論地址是:精讀《元件註冊與畫布渲染》· Issue #464 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章