基於分步表單的實踐探索

2023-07-11 12:00:41

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。。

本文作者:修能

以下內容充滿個人觀點。◡ ヽ(`Д´)ノ ┻━┻

前言

基於分佈表單的需求,在中後臺管理中是一個非常常見的需求,通常具有如下佈局:

其中,自定義需求度從高到低為,正文 > 按鈕區 > 步驟條。

雖然佈局類似,但是實現的方式卻是天差地別,這裡就探究一下究竟怎麼樣實現可以兼具程式碼的可維護性和可讀性呢?

指出問題

Container

我們這裡,以「指標-資料模型」的程式碼為例。

首先先來看看資料模型這裡的程式碼是如何實現的?

export default () => {
  ...
  return (
    <>
      <header>
        <Steps current={current}>
          {['tab1', 'tab2', 'tab3', 'tab4', 'tab5'].map(
      	     (title, index) => (
                <Step key={index} title={title} />
             )
          )}
        </Steps>
      </header>
      <Spin>
        {stepRender(current, {
           childRef,
           modelDetail,
           globalStep: globalStep.current,
           mode,
           isModelTypeDisabled,
           setModelDetail,
           setDisabled,
           onModelNameChange: handleModelNameChange,
        })}
        <Modal>...</Modal>
      </Spin>
      <footer>
        {current === EnumModifyStep.tab1 ? (
           <Button
             onClick={() => router.push('/url')}
           >
             取消
           </Button>
        ) : null}
        ...
      </footer>
    </>
  )
}

這是資料模型編輯頁面 Steps所在的容器元件的 DOM 部分的程式碼。

可以看出來,設計者的思路是比較明確的,通過 header,content,和 footer 進行分層, 增加程式碼的可讀性。

在 header 中,通過宣告 title 陣列的方式建立 Steps 的方式簡潔又不失可讀性。

在 content 中,有幾個問題的存在:

  1. 既然 header 和 footer 都有語意化的標籤強化可讀性,我認為這裡其實也可以新增語意化的標籤強化可讀性,譬如 main或者section,當然同時還需要考慮會不會造成過深的層級。
  2. stepRender函數的實現把一大堆 params 傳到子元件是否合適。
  3. 為何 content 區域內,會存在 Modal?對於沒有設定 getPopupContainer 的 Modal 來說,其會通過 createPortal在 body 上建立,那麼在這裡不論是寫在 content 還是 header,都不會影響它的渲染,所以我推薦把 Modal 寫到最角落裡,不影響可讀性。
  4. 在 footer 中,通過 current === 步驟 的方式去定義按鈕,我認為這種方式會使程式碼顯得較為冗餘。

Tab1

我們這裡以指標相關程式碼為例,以簡見深,以小見大

export default (props) => {
  ...
 const { cref, modelDetail, mode, onModelNameChange } = props;

  useImperativeHandle(cref, () => {
    return {
      validate: () => {...},
      getValue: () => {...},
    }
  });

   useEffect(() => {
     setFieldsValue({
       a: modelDetail.a,
       b: modelDetail.b,
       c: modelDetail.c,
     });
    }, [modelDetail]);

  return (
    <Form>
      <Row gutter={40}>
        <Col span={12}>
            ...
        </Col>
        <Col span={12}>
            ...
        </Col>
    </Row>
    <Row gutter={40}>
      <Col span={12}>
         ...
      </Col>
    </Row>
    </Form>
  )
}

這裡我想指出的第一個問題是,ref 的使用,由於 ref 無法在 props 中傳遞,需要通過 forwardRef 才能拿到。然而這裡通過 cref 這種比較 hack 的方式進行一個操作。我認為這是一個不推薦的做法,如果需要拿 ref 我建議是老老實實通過 forwardRef 拿。

其次是 Row 和 Col 的使用,並不是說 Col 達到 24 之後就需要再寫一個 Row,你可以繼續寫的呀,童鞋!

這裡需要提出來的一個論點是,每一個子元件裡去寫 Form 的方式好(即上面的這種寫法),還是總體寫一個 Form 的方式更好?個人認為前者存在的問題如下:

  1. 由於子元件寫 Form,但是提交(或下一步)按鈕在外面,那麼必然需要用 ref 拿到子元件的範例,並呼叫相關方法。(上面是 validate 和 getValue 分別對應下一步和上一步呼叫)
  2. 沒有遵循 single source of truth(單一事實來源)
  3. 如果多層級結構,例如 RelationTableSelect 的話,每一層都有填寫內容,那麼需要大量 Form + ref,降低可維護性。

除此之外,由於基礎資訊比較簡單,所以不存在 props 層層往下傳遞的問題,但是複雜元件就會存在層層往下傳遞的情況,那麼就涉及到是否需要 context 的問題了。當然,我推薦是需要 context 的。

Tab2

這裡再看一眼第二步關聯表的設計

interface ITab2Props {
  cref: IModifyRef;
  modelDetail?: Partial<IModelDetail>;
  mode: any;
  globalStep: number;
  updateModelDetail: Function;
  setDisabled?: Function;
}

const RelationTableSelect = (props: ITab2Props) => {}

首先,這裡需要支援的一個設計思路是,通常情況下,切忌直接把 dispatch 傳遞給子元件

關聯表這裡的設計由於層級巢狀很深,子元件非常多,導致updateModelDetail不斷往下傳遞,你完全不知道哪層元件在什麼情況下會去修改這個值!!! 這對於 SSOT 來說,是毀滅性的打擊。

再加上 modelDetail 是一個很複雜的資料,對於可維護性來說,屬於是力中暴力地打擊了。

解決問題

綜上,我們設計分佈表單的時候,需要規避以上的問題,遵循如下原則:

  1. SSOT
  2. 可維護性
  3. 可延伸性

首先實現如下元件:

<StepsForm
  current={current}
  onChange={setCurrent}
  titles={['tab1', 'tab2', 'tab3', 'tab4', 'tab5']}
/>

這一塊程式碼比較簡單,無非就是投傳幾個值到對應的元件中去。

接下來考慮底部按鈕的可延伸性。

通過 submitter 屬性支援客製化按鈕的互動屬性。

<StepsForm
  current={current}
  onChange={setCurrent}
  submitter={[
    {
      [StepsForm.PREV]: {
        children: '取消',
      },
    },
    null,
    {
      [PREVIEW]: {
        danger: true,
        children: '預覽',
      },
    },
  ]}
/>

接下來要解決按鈕的事件,這裡有兩種方案,一種是將事件掛載在 Container 上(即這裡的 StepsForm 元件),通過諸如 onCancel,onSubmit,onPrev等方式進行反饋。
我認為這種方式不夠好,原因有如下幾點

  1. 通常我們會把子元件提出來,不會和 Container 元件寫在一起,這就會使得我們需要在不同的元件中寫按鈕的互動邏輯和 UI 邏輯,存在隔離感
  2. 有時候我們需要把 Select.Option 相關的資料一起放到資料裡給到伺服器端,這種方式互動需要把 Option 的資料提取到 Container 中
  3. 需要通過 ref 去子元件獲取值

而目前我考慮通過事件訂閱對按鈕事件觸發,通過 useEffect 監聽事件,但是這種方式的缺點如下:

  1. 不夠直觀,和我們通常來說的元件開發有一定相悖的思路

除了以上兩種方式以外,其實還有一種方式,即通過實現 Children 元件,將 Children 元件作為 StepsForm 的子元件,從而使得將每一步相關的 title 和 onSubmit 等方式都掛載在 Children 元件上。即 ant-design-pro 中的 StepsForm 的實現方式。我認為這種方式的優點在於直觀,不割裂。缺點在於如下:

  1. 為了獲取 title 不得不先渲染子元件,從而導致 DOM 先渲染出來,然後通過 active 判斷表單是否渲染。
  2. 導致子元件無法通過 useEffect獲取資料

其中第二點我認為是無法忍受的,這和開發元件的思路完全相悖,故摒棄這種方式
暫時考慮不清楚是第一種好還是第二種好。

這裡先考慮實現第二種方式後元件書寫的效果:

export function () {
  ...
  StepsForm.useFooterEffect(
    ({ prev }) => {
      prev(() => {});
    },
    [StepsForm.PREV],
  );

  StepsForm.useFooterEffect(() => {
    message.info('預覽')
  }, [PREVIEW]);

  StepsForm.useFooterEffect(
    ({ next }) => {
      next(() => {
        return new Promise((resolve) => {
          setTimeout(() => {
            resolve();
          }, 1000);
        });
      });
    },
    [StepsForm.NEXT],
  );

  return (
    ...
  )
}

hook 的實現方式也比較簡單,基於事件訂閱,結合每一個按鈕都賦予一個唯一值。
實現按鈕互動觸發後,通過事件分發,觸發當前渲染的元件中的監聽 hook。

總結

本文意在探索分步表單的最佳實踐,防止不同的同學在開發該型別的需求會寫出五花八門的程式碼,從而導致降低可維護性。

本文提到的解決方案也不認為是最佳實踐,其中不同的方法經過分析都存在優點和缺點。在實際的開發過程中,仍然需要根據具體的需求進行調整。

但是基於分步表單的特性和使用場景,總結出適用大部分情況下的方法論是有必要的。


最後

歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧UED團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎star