在設計好畫布與元件資料流體系後,理論上主體功能已經完成,但缺乏方便易用的 API,所以還需要內建一些狀態與方法。
但是內建狀態與方法必須尋求業務的最大公約數,極具抽象性,新增需慎重。
接下來我們從必須有與建議有的角度,看看一個視覺化搭建需要內建哪些 API。
狀態
狀態是可變的,引用方式有如下兩種。
第一種在任意 React 元件內透過 useDesigner
訪問,當狀態變化時會觸發所在元件重渲染:
const { componentTree } = useDesigner((state) => ({
componentTree: state.componentTree,
}));
第二種在任意元件元資訊內透過 selector
訪問,當狀態變化時會觸發不同行為,比如在 runtimeProps
會觸發元件重渲染,在 fetcher
會觸發重新查詢:
const tableMeta = {
/** ... */
runtimeProps: ({ selector }) => {
const { componentTree } = selector(({ state }) => ({
componentTree: state.componentTree,
}));
return { componentTree };
},
};
componentTree
- 評價:必須有
- 型別:
ComponentInstance
描述完整元件樹 JSON 結構。在非受控模式下,元件樹就儲存在 <Designer />
例項內部,而受控模式下,元件樹儲存在外部狀態。
但我們允許這兩種模式都可以訪問此狀態,這樣在開發視覺化搭建應用的過程中,就不用關心受控或非受控模式了,即一套程式碼同時相容受控與非受控模式。
selectedComponentIds
- 評價:建議有
- 型別:
string[]
定義當前選中元件例項 id 列表。
雖然這個狀態業務也可以定義,但選中元件在視覺化搭建是一種常見行為,以後定義外掛、自定義元件也許都會讀取當前選中的元件,如果框架定義了此通用 key,那麼外掛和自定義元件就可無縫結合到任意業務程式碼裡。反之如果在業務層定義該狀態,外掛或者自定義元件也不知道如何標準的讀取到當前選中的元件。
canUndo, canRedo
- 評價:建議有
- 型別:
boolean
描述當前狀態是否能撤銷或重做。
該狀態需要結合內建方法 undo()
redo()
一起提供,屬於 “有了更好” 的狀態。但有時候也會產生困擾,比如你的應用分了多個 sheet,每個 sheet 內是一個畫布例項,而你希望撤銷重做可以跨 sheet,那就不適合用單例項提供的方法了。
方法
狀態引用不可變,引用方式有如下兩種。
第一種在任意 React 元件內透過 useDesigner
訪問,它不會變化,因此不會導致元件重渲染:
const { addComponent } = useDesigner();
第二種在任意元件元資訊內透過回撥訪問:
const tableMeta = {
/** ... */
runtimeProps: ({ addComponent }) => {},
};
getState()
- 評價:必須有
- 型別:
() => State
獲取應用全部狀態,包括內建與業務自定義。
setState()
- 評價:必須有
- 型別:
(state: State) => void
更新應用全部狀態,包括內建與業務自定義。
getComponentTree()
- 評價:必須有
- 型別:
() => ComponentInstance
返回當前元件樹。
並不是有了 componentTree
狀態就萬事大吉了,很多回撥函式並不依賴元件樹重渲染,而僅僅在觸發時獲取其瞬時值必須呼叫此方法。
雖然該方法一定程度上可以用 getState().componentTree
代替,但元件樹概念太重要了,以至於單獨定義一個方法不會增加理解成本。另外在受控模式下,getState().componentTree
不一定等價於 getComponentTree()
,因為前者是從 <Designer />
拿元件樹,而後者直接請求外部狀態最新的元件樹,當元件樹受控模式沒有及時觸發渲染同步時,後者值會比前者更新。
setComponentTree()
- 評價:必須有
- 型別:
(callback: (now: ComponentInstance) => ComponentInstance) => boolean
更新當前元件樹。
在非受控模式下等價於 setState()
修改 componentTree
,但在非受控模式下,會直接透傳到外部狀態,直接修改一手元件樹,因此極端情況下表現更穩定。
addComponent()
- 評價:必須有
- 型別
(componentInstance, parentIdPath?, index?, position?) => void
新增元件例項。
基於 setComponentTree()
實現,但因為其太常見且意圖較為複雜,抽成一個獨立函式還是很有必要的。
componentInstance
必選,預設把元件例項新增到根節點的children
位置。parentIdPath
可選,描述要新增到的父節點 ID,當父節點沒定義元件 ID 時,也可以用例如children.0
這種元件樹路徑代替,所以名稱不叫parentId
,而是parentIdPath
。index
可選,描述要新增到父節點子元素下標,比如新增到children
的第幾項。position
可選,描述要新增到父節點children
還是props.header
等位置,畢竟元件例項並不只有children
一個地方。
deleteComponent()
- 評價:必須有
- 型別:
(componentIdPath: string) => boolean
刪除元件例項。
基於 setComponentTree()
實現,但同理太常用,所以單獨提供。
這裡還有個細節,就是 componentIdPath
指可傳元件 ID,也可傳元件樹路徑,而真正刪除肯定要從樹上刪,框架內部為了快速從元件 ID 定位到 treePath
,維護了一個對映表,因此使用該函式無論何時都是 O(1) 的時間複雜度。
getComponent()
- 評價:必須有
- 型別:
(componentIdPath: string) => ComponentInstance
查詢元件例項。
基於 getComponentTree()
實現。“增刪” 都有了,“查” 還能沒有嗎?
setComponent()
- 評價:必須有
- 型別:
(componentIdPath, callback) => boolean
修改元件例項。
基於 setComponentTree()
實現,“增改查” 都有了,就差一個 “改” 了。
setProps()
- 評價:建議有
- 型別:
(componentIdPath, callback) => boolean
修改元件例項的 props。
基於 setComponent()
實現,因為修改元件 props 屬性比修改整個元件例項常見,建議實現。
getProps()
- 評價:建議有
- 型別:
(componentIdPath) => any
獲取元件例項的 props。
基於 getComponent()
實現,同理,呼叫可能比 getComponent()
更常見,因此建議實現。
getComponents()
- 評價:建議有
- 型別:
() => ComponentInstance[]
獲取全量元件例項陣列。
因為元件樹是樹狀結構,業務除了用遞迴方式遍歷外,還可以提供這種獲取打平形式的元件樹以備不時之需。
getParentId()
- 評價:必須有
- 型別:
(componentIdPath: string) => string
獲取元件的父元件 ID。
以為 componentTree
為樹狀結構,所以直接從元件例項上找不到父節點,因此提供一個快速找父節點的函式是非常必要的。
當然框架內部實現尋找父節點肯定不會用遍歷,而是提前解析元件樹時就建立好關聯對映表,所有內建方法時間複雜度都是 O(1) 的。
getParentBy()
- 評價:建議有
- 型別:
(componentIdPath: string, finder: (parent: ComponentInstance) => boolean) => string
一直向上尋找父節點,直到找到為止。
基於 getParentId()
實現,方便業務向上尋找符合條件的父節點。
setParent()
- 評價:必須有
- 型別:
(componentIdPath, parentIdPath, index, position) => boolean
調整某個元件的父節點。引數和 addComponent()
很像,只是把第一個從元件例項改為了元件 ID,引數含義相同。
當畫布涉及元件跨父節點移動時,這個方法就顯得很關鍵了,雖然底層也是基於 setComponentTree
實現的。一個比較複雜的場景是,當元件跨節點移動時,在元件樹上操作還是比較複雜的,因為移除 + 新增無論先做哪個,都會導致元件樹變化,從而導致後一個操作位置可能錯誤。如果每次都重新定址效能會較差,如果想用聰明的方法繞過,邏輯還是比較複雜的,因此有必要內建該方法。
setComponentMeta()
- 評價:必須有
- 型別:
(componentName: string, componentMeta: ComponentMeta) => void
更新元件元資訊。
提供這個方法其實對框架的挑戰比較大,在提供很多生命週期的情況下,隨時可能發生元件例項的更新,要保證整體邏輯符合預期,需要仔細設計一下。
getComponentMeta()
- 評價:必須有
- 型別:
(componentName: string) => ComponentMeta
獲取元件元資訊。
既然可以註冊元件元資訊,就可以獲取它。注意透過 <Designer />
受控或者非受控模式註冊,或者直接呼叫 setComponentMeta
註冊的元件元資訊都應該可以正常獲取到。
getComponentMetas()
- 評價:建議有
- 型別:
() => ComponentMeta[]
批次獲取所有已註冊的元件元資訊。
說不定業務會有什麼特別的用途,建議提供。
clearComponentMetas()
- 評價:建議有
- 型別:
() => void
清空所有元件元資訊。
說不定業務會有什麼特別的用途,建議提供。
setSelectedComponentIds()
- 評價:建議有
- 型別:
(ids: string[]) => void
修改內建狀態 selectedComponentIds
。
如果你提供了 selectedComponentIds
這個內建狀態,那提供對應的修改方法就是強烈建議了。雖然也可透過 setState()
更新 selectedComponentIds
Key 來實現。
getTreePath()
- 評價:建議有
- 型別:
(componentIdPath: string) => string
根據元件 ID 查詢在元件樹上的路徑。
也許業務想要自己操作元件樹,那麼框架提供根據元件 ID 找到元件樹路徑的方法就挺合適。
undo(), redo()
- 評價:建議有
- 型別:
() => void
撤銷,重做。
如果提供了 canUndo
、canRedo
內建狀態,那麼一定要提供 undo()
、redo()
內建函式。
getMergedProps()
- 評價:建議有
- 型別:
(componentIdPath: string) => any
返回元件最終混合後的 props。
由於元件 props 可能來自元件樹,也可能來自 runtimeProps
,為了防止傻傻分不清,因此規定 getProps()
僅獲取元件樹上序列化的 props,而 getMergedProps()
獲取了包含 runtimeProps
處理後的最終 props。
getComponentDom()
- 評價:建議有
- 型別:
(componentIdPath: string) => HTMLElement
根據元件 ID 獲取 DOM 例項。
框架最好透過一些技巧,讓元件即便不用 forwardRef
也能拿到 DOM,那麼元件只要存在 DOM,就可以透過該方法拿到,非常方便。
afterDomRender()
- 評價:建議有
- 型別:
(componentIdPath: string, callback: () => void) => Promise
當元件 ID 的 DOM 例項掛載後,執行 callback
。
因為元件 DOM 依賴渲染,所以不能保證 getComponentDom
時 DOM 真的完成了渲染,因此可以將時機放在 afterDomRender()
後,保證一定可以拿到 DOM。
總結
這一章我們設計了內建 API,設計思路總結如下:
- 從元件樹這個核心概念散開,設定了必要的 API,以及一些邏輯複雜,或者使用很方便的推薦 API。
- 雖然元件樹是樹狀結構,但內建 API 需要考慮易用性,所有操作都以元件 ID 作為引數,在內部實現時轉化為操作元件樹,並內建好 O(1) 時間複雜度的最佳化措施。
- 核心 API 只有寥寥幾個,其餘 API 都以便利性為目的提供,且都以核心 API 為基礎實現,這樣框架核心會更穩定,框架大部分 API 只是一種實現規則,業務利用核心 API 擁有更大的實現自由。
討論地址是:精讀《視覺化搭建內建 API》· Issue #467 · dt-fe/weekly
如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)