接下來需要解決兩個問題:
- 視覺化搭建的其他業務元素如何與畫布互動。比如擴充屬性配置皮膚、圖層列表、拖拽新增元件、定位錨點、主題等等。
runtimeProps
如何訪問到當前元件例項的props
。
這兩個問題非常重要,而恰好又可以透過良好的資料流設計一次性解決,接下來讓我們分別分析討論一下。
問題一:視覺化搭建的其他業務元素如何與畫布互動。比如擴充屬性配置皮膚、圖層列表、拖拽新增元件、定位錨點、主題等等
需要設計一個 Hooks API,可以訪問到畫布提供的方法、資料。在 React 設計中,訪問 Hooks API 需要在一定上下文內,所以可以將 <Designer>
拆為 <Designer>
與 <Canvas>
,其中 <Designer>
提供 Hooks 上下文,<Canvas>
負責渲染畫布。這樣開發者的使用方式就變成了這樣:
import { createDesigner } from 'designer'
const { Designer, Canvas, useDesigner } = createDesigner()
const EditPanel = {
const { addComponent } = useDesigner()
return <button onClick={() => addComponent(/** ... */)}>建立元件</button>
}
const App = () => {
<Designer>
<Canvas />
<EditPanel />
</Designer>
}
為了支援多個 Designer 例項間隔離,透過 createDesigner
建立一套上下文獨立的 API,這樣就可以讓畫布、配置皮膚同時用 Designer 實現,用一套技術方案同時實現畫布與配置表單,這樣學習上下文、元件規範都可以統一為一套,表單、畫布能力也可以共享。
在 <Designer>
內的元件可以透過 useDesigner
直接訪問資料與方法,比如上面例子在直接訪問內建方法 addComponent
時,不需要附加任何參加,而 addComponent
方法也永遠保持引用不變,此時 useDesigner
不會導致 EditPanel
重渲染。
如果需要訪問當前元件樹,並在元件樹變化時重渲染,可以透過如下方式訪問:
const EditPanel = {
const { componentTree } = useDesigner(state => ({
componentTree: state.componentTree
}))
}
該寫法的效果是,當 state.componentTree
變化了,會觸發 EditPanel
重新渲染,並拿到最新值。
同時也可以傳入第二個引數 compare
自定義對比方法,預設為 shallowEqual
:
useDesigner(
(state) => ({
componentTree: state.componentTree,
}),
isEqual
);
如此一來,無論給畫布擴充多少 UI 元素都沒有問題,而且 UI 元素可以自由的訪問畫布方法與資料。
問題二:runtimeProps
如何訪問到當前元件例項的 props
在 componentMeta.runtimeProps
中,我們構造一個 selector
函式用於訪問當前元件 props:
const divMeta = {
componentName: "div",
runtimeProps: ({ selector }) => {
const name = selector(({ props }) => props.name)
return {
fullName: `full-${name}`
}
}
element: /** ... */
};
首先支援從 runtimeProps
回撥裡拿到 selector
,並且該 selector
支援傳入一個回撥函式,該回撥函式的引數中 props
指向當前元件例項的 props
,透過該方法就可以訪問元件 props 了。
該 selector 僅在 props.name
改變時重新執行,並且也遵循 compare
對比規則,即當 props.name
變化時,selector
回撥函式的返回值透過 compare
與上一次值進行對比,如果沒有變化就返回上一次的舊值,變化了則返回新值。預設對比函式為 shallowEqual
,與 useDesigner
類似,也可以在第二個引數位置覆寫 compare
方法。
那元件元資訊如何訪問內建靜態方法呢?由於靜態方法引用不變,因此可以在 selector
同級直接傳入:
const divMeta = {
componentName: "div",
runtimeProps: ({ addComponent }) => {
return {
add: () => {
/** addComponent(...) */
}
}
}
element: /** ... */
};
如此一來,我們就將資料流與元件元資訊打通了,即 UI 可以透過 useDesigner
訪問與運算元據流,元件元資訊也可以直接拿到方法,或透過 selector
拿到資料,相應的也可以訪問與運算元據流。這樣的設計在以後擴充更多元件元資訊函式時,都可以繼承下來,開發者只要學習一次語法,就可以獲得非常強力的擴充性。
擴充應用狀態與靜態方法
剛才介紹了一些內建的狀態(componentTree
)與方法(addComponent
),在下一接會系統介紹筆者梳理了哪些內建狀態與方法。首先拋開內建狀態與方法不談,應用肯定需要定義自己的狀態與方法,我們可以提供兩種模式給使用者。
第一種是應用的狀態與方法定義在外部,對應受控模式。
假設你的應用在對接 Designer 之前就已經用 Redux、Dva、Zustand 等狀態管理庫,那麼就可以使用受控模式直接接入:
const App = () => {
// 虛擬碼,不管是 useState 還是其他資料流管理狀態,假這裡拿到了資料與方法
const { getAppInfo } = useSomeLib();
const { userName } = useSomeLib("userName");
return <Designer actions={{ getAppInfo }} state={{ userName }} />;
};
將方法傳給 actions
,狀態傳給 state
。
第二種是應用的狀態與方法透過 <Designer>
定義,對用非受控模式。
假設你的應用之前沒有使用任何資料流,那麼也可以直接將 Designer 的資料流作為專案資料流使用:
import { createMiddleware, createDesigner } from "designer";
const middleware1 = createMiddleware({
state: { userName: "bob " },
actions: { getAppInfo: () => {} },
});
const { Designer } = createDesigner(middleware1);
const App = () => {
return <Designer />;
};
透過 createMiddleware
建立一箇中介軟體定義狀態與函式,傳入 createDesigner
即可生效。
也可以在 createMiddleware
裡透過第二個引數定義自定義 hooks,或者拿到方法更改 State:
const middleware1 = createMiddleware(
{
state: { userName: "bob " },
},
({ setState }) => {
const setUserName = React.useCallback((newName: string) => {
setState((state) => ({
...state,
userName: newName,
}));
});
return { setUserName };
}
);
Designer 內部採用最樸素的 Redux 管理狀態,提供了最基礎的 getState
與 setState
獲取與修改狀態,基於它們封裝業務函式即可。
無論是受控模式,還是非受控模式(亦或兩種模式同時使用),定義的狀態與方法都可以在以下兩個位置訪問,第一個位置是 useDesigner
:
const {
/** 自定義函式 */,
setUserName,
/** 自定義函式 */
getAppInfo,
/** 內建函式 */
addComponent,
// 內建變數
componentTree,
// 自定義變數
userNamee
} = useDesigner(state => ({
componentTree: state.componentTree,
userName: state.userName
}))
第二個位置是元件元資訊上的回撥函式,比如 runtimeProps
:
const divMeta = {
componentName: "div",
runtimeProps: ({
selector,
/** 自定義函式 */,
setUserName,
/** 自定義函式 */
getAppInfo,
/** 內建函式 */
addComponent
}) => {
const {
/** 內建變數 */
componentTree,
/** 自定義變數 */
userName
} = selector(({ state }) => ({
componentTree: state.componentTree,
userName: state.userName
}))
return { componentTree, userName }
}
element: /** ... */
};
至此,我們實現了一套完整的資料流定義,包括:
- 不同 Designer 之間上下文隔離。
- 可無縫對接專案資料流,也可作為獨立資料流方案提供。
- 內建變數與函式與自定義變數、函式混合。
- 無論在 UI 透過
useDesigner
,還是在元件元資訊透過selector
都可訪問這些變數與函式。
總結
一個基本可用的視覺化搭建框架在本章就算設計完了。但這只是視覺化搭建問題的冰山一角,未來的章節,筆者會逐漸為大家介紹更多視覺化搭建的設計。
但無論框架未來怎麼發展,也永遠會基於這前三章的基本設定,總結一下,這三章的基本設定就是:設計一個邏輯與 UI 分離的視覺化搭建協議,資料流、元件元資訊、元件例項是永遠的鐵三角,資料流可以對接任意已存在的實現,或基於 Designer 規範實現,元件元資訊與元件例項僅儲存最基本資訊,得益於資料流的自定義能力,以及無論何處都有完全的資料流訪問能力,使業務框架既遵循規則,又可以千變萬化。
拋開具體 API 設計或者命名不談,一個有簡潔、抽象,又提供極少量 API 卻能滿足所有業務定製訴求,是視覺化搭建永遠追求的目標。只要熟悉了這套規範,就可以幾乎僅根據業務表現,一眼猜出是基於哪些 API 封裝實現的,那麼維護成本與理解成本將大大降低,規範的意義就體現在這裡。
也許有同學會覺得,現在各個大廠都有無數視覺化搭建的實現,視覺化搭建概念都已經爛大街了,為什麼還要重新設計一個呢?
因為也許數量不代表質量,維護的時間越久,參與的同學越多,越容易使設計變得冗餘,概念變得複雜,要對抗這些遞增的熵,唯有不斷重新設計,從零開始反思方案。
下一講理論思考會少一些,介紹視覺化搭建框架會考慮內建哪些變數與方法。
討論地址是:精讀《畫布與元件元資訊資料流》· Issue #466 · dt-fe/weekly
如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。
關注 前端精讀微信公眾號
<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">
版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證)