>我們是[袋鼠雲數棧 UED 團隊](http://ued.dtstack.cn/),致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。。 >本文作者:修能 ***以下內容充滿個人觀點。◡ ヽ(`Д´)ノ ┻━┻*** # 前言 基於分佈表單的需求,在 ...
我們是袋鼠雲數棧 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 中,有幾個問題的存在:
- 既然 header 和 footer 都有語義化的標簽強化可讀性,我認為這裡其實也可以添加語義化的標簽強化可讀性,譬如
main
或者section
,當然同時還需要考慮會不會造成過深的層級。 stepRender
函數的實現把一大堆params
傳到子組件是否合適。- 為何 content 區域內,會存在 Modal?對於沒有設置
getPopupContainer
的 Modal 來說,其會通過createPortal
在 body 上創建,那麼在這裡不論是寫在 content 還是 header,都不會影響它的渲染,所以我推薦把 Modal 寫到最角落裡,不影響可讀性。 - 在 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 的方式更好?個人認為前者存在的問題如下:
- 由於子組件寫 Form,但是提交(或下一步)按鈕在外面,那麼必然需要用 ref 拿到子組件的實例,並調用相關方法。(上面是 validate 和 getValue 分別對應下一步和上一步調用)
- 沒有遵循 single source of truth(單一事實來源)
- 如果多層級結構,例如 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
是一個很複雜的數據,對於可維護性來說,屬於是力中暴力地打擊了。
解決問題
綜上,我們設計分佈表單的時候,需要規避以上的問題,遵循如下原則:
- SSOT
- 可維護性
- 可擴展性
首先實現如下組件:
<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
等方式進行反饋。
我認為這種方式不夠好,原因有如下幾點
- 通常我們會把子組件提出來,不會和 Container 組件寫在一起,這就會使得我們需要在不同的組件中寫按鈕的交互邏輯和 UI 邏輯,存在隔離感
- 有時候我們需要把 Select.Option 相關的數據一起放到數據里給到服務端,這種方式交互需要把 Option 的數據提取到 Container 中
- 需要通過 ref 去子組件獲取值
而目前我考慮通過事件訂閱對按鈕事件觸發,通過 useEffect 監聽事件,但是這種方式的缺點如下:
- 不夠直觀,和我們通常來說的組件開發有一定相悖的思路
除了以上兩種方式以外,其實還有一種方式,即通過實現 Children 組件,將 Children 組件作為 StepsForm 的子組件,從而使得將每一步相關的 title 和 onSubmit 等方式都掛載在 Children 組件上。即 ant-design-pro 中的 StepsForm
的實現方式。我認為這種方式的優點在於直觀,不割裂。缺點在於如下:
- 為了獲取 title 不得不先渲染子組件,從而導致 DOM 先渲染出來,然後通過 active 判斷表單是否渲染。
- 導致子組件無法通過
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
- 大數據分散式任務調度系統——Taier
- 輕量級的 Web IDE UI 框架——Molecule
- 針對大數據領域的 SQL Parser 項目——dt-sql-parser
- 袋鼠雲數棧前端團隊代碼評審工程實踐文檔——code-review-practices
- 一個速度更快、配置更靈活、使用更簡單的模塊打包器——ko