接著視覺化搭建的理論抽象,我們開始勾勒一個具體的 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
,但因為子元件概念太常見,建議children
與props.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
的命名,可能會產生分歧,比如還有其他命名風格如render
、renderer
、reactNode
等等,但不管叫什麼名字,只要是基於 React 響應式定義的,最終應該都殊途同歸,最多對於各類 Key 的名稱定義有所不同,這塊可以保留自己的觀點。
我們繼續聚焦元件元資訊的 element
屬性,看以下 element 程式碼:
const divMeta = {
componentName: "div",
element: ({ children, header }) => (
<div>
{children}
{header}
</div>
),
};
上面的例子中,我們可以識別出 children
與 header
型別嗎?可以識別一部分:
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 元件例項與函式,現在我們解決了傳元件例項的問題,至於如何傳函式,我們下一小節再講。
這樣設計存在兩個缺陷:
- 由於 ComponentTreeLike 會自動轉成例項,所以沒有辦法讓元件拿到 ComponentTreeLike 的原始值。
- 由於 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,並支援傳入 componentTree
與 componentMetas
,有了元件樹與元件元資訊,就可以實現視覺化搭建畫布的渲染了。
我們還介紹瞭如何在元件元資訊定義元件的渲染函式,如何給渲染函式 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 許可證)