畫布與元件元資訊資料流

黃子毅發表於2023-02-06

接下來需要解決兩個問題:

  1. 視覺化搭建的其他業務元素如何與畫布互動。比如擴充屬性配置皮膚、圖層列表、拖拽新增元件、定位錨點、主題等等。
  2. 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 管理狀態,提供了最基礎的 getStatesetState 獲取與修改狀態,基於它們封裝業務函式即可。

無論是受控模式,還是非受控模式(亦或兩種模式同時使用),定義的狀態與方法都可以在以下兩個位置訪問,第一個位置是 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 許可證

相關文章