There are a thousand Hamlets in a thousand people's eyes. ----- 威廉·莎士比亞
免責宣告:以下充滿個人觀點,辯證學習
React 目前開發以函陣列件為主,輔以 hooks
實現大部分的頁面邏輯。目前數棧的 react 版本是 16.13.1,該版本是支援 hooks
的,故以下實踐是 hooks
相關的最佳實踐。
首先,應當明確 React 所推崇的函數語言程式設計以及 f(data) = UI
是什麼?
函數語言程式設計是什麼?這裡的函數並非 JavaScript
中的函數,或任何語言中的函數。此處的函數是數學概念中的函數。讓我們來回憶一下數學中的函數是什麼。
在數學概念中,函數即一種特殊的對映,如何理解對映?
以一元二次方程為例, f(x) 是一種對映關係,給定一個的 x ,則存在 y 與之對應。
我們將一元二次方程理解為一個黑盒,該黑盒存在一個輸入,一個輸出,在輸入端我們給入一個值 x = 2 則輸出端必然會給出一個 y = 4 。
f(data)=UI
在瞭解了數學中函數的概念後,將其概念套用到 React 中,我們就可以明白 f(data)=UI 到底指什麼意思?
結論:個人理解,將當前元件內部的所有邏輯視為一個黑盒,該黑盒有且僅有一個輸入,有且僅有一個輸出,輸入端為 props
,輸出端則是當前元件的 UI
,不同的輸入會決定不同的輸出,就像把 x = 1 和 x = 2 給到所得到的結果是不一樣的一樣。而將這樣的元件組合起來就是每一個頁面,即 React 應用。
目前絕大部分的後臺管理系統,不僅僅是數棧,包括所有 ERP 系統,圖書管理系統等等。其主體頁面大致可以包括一下三類:
以上是這次在資產中負責開發的檔案治理規則頁面,屬於是第一類的典型頁面,那這一類的頁面應該如何開發才是最佳實踐?
首先,我們將這一類頁面抽象成如下結構
如此一來,我們不難實現如下的 dom 結構
<div className="container">
<div className="header">
<div className="filter">篩選區</div>
<div className="buttonGroup">按鈕區</div>
</div>
<div className="content">表格區</div>
</div>
接下來,我們需要補充各個區域的內容。
篩選區通常會有一些篩選項,這裡我們有兩個選擇,如果比較複雜的篩選項,如存在 5 個以上或存在聯動的互動,則選擇通過 Form 來實現,而上圖中這種比較簡單的則可以直接實現。
這裡我們不難觀察到我們需要 3 個 value 值來實現這 3 個輸入框或下拉框的受控模式,這裡我們通過宣告一個 filter 的變數,將這 3 個值做一個整合。
即
function (){
const [filter, setFilter] = useState({
a: undefined,
b: undefined,
c: undefined
});
return <><>
}
提問:為什麼要 3 個值都放入到一個變數裡?
答:1. 減少冗長的定義。 2. 為後續鋪墊。
宣告完 3 個值後,我們把元件填入
function (){
const [filter, setFilter] = useState({
a: undefined,
b: undefined,
c: undefined
});
return (
<div className="container">
<div className="header">
<div className="filter">
<Input value={filter.a} onChange={(e) => setFilter(f => ({...f, a: e.target.value})} />
<Input value={filter.b} onChange={(e) => setFilter(f => ({...f, b: e.target.value})} />
<Input value={filter.c} onChange={(e) => setFilter(f => ({...f, c: e.target.value})} />
</div>
<div className="buttonGroup">按鈕區</div>
</div>
<div className="content">表格區</div>
</div>
)
}
接著,我們實現表格區,有經驗的同學可以知道,一個 Table 需要 dataSource
資料,columns
,pagination
,total
,有時候還需要 selectedRow
和 sorter
和 filter
。
我們先考慮 dataSource 和 columns。首先,我們先考慮資料獲取,實現 getDataSourceList
interface ITableProps {}
function (){
const [filter, setFilter] = useState({
a: undefined,
b: undefined,
c: undefined
});
const [loading, setLoading] = useState(false);
const [dataSource, setDataSource] = useState<ITableProps>([]);
const getDataSourceList = () => {
setLoading(true);
Promise.then(() => {
/// xxxx
}).finally(() => {
setLoading(false);
})
};
return (
<div className="container">
<div className="header">
<div className="filter">
<Input value={filter.a} onChange={(e) => setFilter(f => ({...f, a: e.target.value})} />
<Input value={filter.b} onChange={(e) => setFilter(f => ({...f, b: e.target.value})} />
<Input value={filter.c} onChange={(e) => setFilter(f => ({...f, c: e.target.value})} />
</div>
<div className="buttonGroup">按鈕區</div>
</div>
<div className="content">表格區</div>
</div>
)
}
可以看到,我們新增了 loading 變數,用來優化互動的過程,新增了 ITableProps
型別,用來宣告表格資料的型別,此時應當和伺服器端的同學溝通,從介面檔案中獲取到相關的資料結構,並補全這一塊的型別。
假設我們此時已經完成了型別的補全,那接下來我們需要完善 columns
const columns: ColumnType<ITableProps>[] = [];
這裡存在部分分歧,有部分人認為需要加上 useMemo
。
為什麼不加 useMemo?
在完善 columns 後,我們繼續剛才提到的 Pagination
和 total
欄位。
這裡我們需要宣告兩個變數
const [pagination, setPagination] = useState({current: 1, pageSize: 20});
const [total, setTotal] = useState(0);
思考:這裡的 total 和 pagination 是否可以合併成一個變數?
答:可以,但是我不願意,因為我們後面會有存在如下程式碼,會導致無限迴圈
useEffect(() => {
// getDataSourceList 中存在改變 total 的值,會導致無限迴圈
// 如果要解決這個問題,則需要在 depths 中分別寫 current 和 pageSize
// 我不願意
getDataSourceList();
},[pagination]);
然後接下來我們要實現請求功能,不難總結出我們需要在以下幾種情況下做請求:
除了最後這個暫時不做考慮,第三和第四項的請求我們可以再細分為如圖所示
那麼,就可以和前兩項進行合併,總結如下:
將上述思路轉化為程式碼可得
function (){
const [filter, setFilter] = useState<Record<string, any>>({
a: undefined,
b: undefined,
c: undefined,
d: undefined,
sorter: undefined
});
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
const getDataSourceList = () => {};
useEffect(() => {
setPagination(p => ({ ...p, current: 1 }));
}, [filter]);
useEffect(() => {
getDataSourceList();
}, [pagination]);
}
到這裡,我們已經實現了絕大部分主要功能,接下來我們簡單實現一個 selectedRows
即可。
function (){
const [selectedRows, setSelectedRows] = useState([]);
return (
<Table
rowSelection={{
selectedRowKeys,
onChange: (selected) => setSelectedRows(selected)
}}
/>
)
}
問:為什麼這裡的 rowSelection 不提出來,放到 useMemo 裡去?
至此,一個滿足業務的一類業務相關的框架程式碼已經編寫完成。
到這裡之後,一類業務頁面還有剩下什麼東西需要開發?
二類頁面的特點通常是新增和編輯複用同一個頁面,需要 Form 表單。
除此之外,通常二類頁面有通過 Drawer 或者 Modal 或者跳轉路由的方式,但這不影響程式碼的書寫。
不論是 Drawer 還是 Modal 還是路由的方式,我們都需要將表單內容抽離出一個新的元件。
首先,我們看一個比較普遍且較為簡單的新增或編輯頁面
可以觀察到,這個表單通過 Steps 元件分割成了 3 個步驟
這裡我們需要明確一個思想,即上面強調的 ,那麼這裡不論是新增還是編輯,其差異只存在於 data 中是否存在 id 值。
這裡有兩種做法,第一種做法是 3 個步驟只用一個 Form,第二種做法是 3 個步驟 3 個 Form。我個人比較喜歡第一種做法。理由如下:
form.getFieldValue('xxx')
比較複雜的表單通常都有聯動情況,比如資料同步任務的表單,或者這裡的資料來源和表選擇的互動。
這一類互動在 antd@3
中通常實現起來會比較的繁瑣,在 antd@4
中善用 dependencies
和 onValuesChanged
可以很好地解決這一類問題。
{[TRINO, KINGBASEES8, SAPHANA1X].includes(dataSourceType) && (
<FormItem
name="schemaName"
label="選擇Schema"
initialValue={schemaName}
rules={[
{
required: true,
message: '請選擇Schema',
},
]}
>
<Select
showSearch
style={{
width: '100%',
}}
onSelect={this.onSchemaChange}
onPopupScroll={this.handleSchemaScroll}
onSearch={this.handleSchemaSearch}
>
{this.renderSchemaListOption(currentSchema)}
</Select>
</FormItem>
)}
如上程式碼所示, schema
的欄位只有在所選擇的資料來源是 xxx 這幾種情況下才會展示,如果我們按照上述程式碼的寫法的話,需要在 state
中新增 dataSourceType
欄位
那如果用 dependencies
的話,可以改成如下寫法:
<FormItem noStyle dependencies={['sourceId']}>
{({ getFieldValue }) => (
isXXXXX(options.find(o => o.sourceId === getFieldValue('sourceId'))?.type) && (
<FormItem
name="schemaName"
label="選擇Schema"
initialValue={schemaName}
rules={[
{
required: true,
message: '請選擇Schema',
},
]}
>
<Select
showSearch
style={{
width: '100%',
}}
onSelect={this.onSchemaChange}
onPopupScroll={this.handleSchemaScroll}
onSearch={this.handleSchemaSearch}
>
{this.renderSchemaListOption(currentSchema)}
</Select>
</FormItem>
)
)}
</FormItem>
更進一步,可以把 find
抽象一個 getTypeBySourceId
函數出來,即優化了可維護性,又減少了變數宣告。
除此之外,還有下拉式選單的聯動,如 A 的選擇會引起 B 的下拉式選單獲取,B 的下拉式選單可能又會引起 C 的下拉式選單改變,如此鏈路下去,會導致宣告的 handleChange
函數又多又長
function(){
const handleAChanged = () => {};
const handleBChanged = () => {};
const handleCChanged = () => {};
return (
<Form>
<FormItem name ="a">
<Select onChange={handleAChanged} />
</FormItem>
<FormItem name ="b">
<Select onChange={handleBChanged} />
</FormItem>
<FormItem name ="c">
<Select onChange={handleCChanged} />
</FormItem>
</Form>
)
}
那我們可以藉助 antd@4
的 onValuesChanged
函數,來把所有相關元件的 onChange 做合併,即如下:
function(){
const handleFormFieldChanged = (changed: Partial<IFormFieldProps>) => {
if('a' in changed){
// do something about a
getOptionsForB();
form.resetFields(['b', 'c']);
}
if('b' in changed){
// do something about b
getOptionsForC();
form.resetFields(['c']);
}
if('c' in changed){
// do something about c
getOptionsForD();
}
}
return (
<Form onValuesChanged={handleFormFieldChanged}>
<FormItem name ="a">
<Select />
</FormItem>
<FormItem name ="b">
<Select />
</FormItem>
<FormItem name ="c">
<Select />
</FormItem>
</Form>
)
}
同時,在這個函數裡我們也可以順便把 reset
的操作做了
到這裡,我們大致完成了 Form 表單的架構思路。接下來,我們需要處理新增和編輯的區分。
通常來說,新增是不需要賦初始值的,而編輯是需要賦初始值的。
這裡需要注意的點在於,我們所理解的初始值並不是 Form 元件中 initialValue 的含義。(至少我認為不是)
我認為 Form 元件的生命週期應當分為如下部分:
如上階段中說明所示,我認為初始值的操作應當通過 set 操作完成,那程式碼實現起來應當如下所示:
function(){
useEffect(() => {
if(router.record.id){
setLoading(true);
api.getxxx({recordId: record.id}).then(res => {
if(res.success){
form.setFieldsValue({
a: res.data.a,
b: res.data.b
})
}
}).finally(() => {
setLoading(false);
});
}
}, []);
}
把編輯的賦值操作全都放到 useEffect
中執行,統一了書寫的地方,有利於後期的維護。
總結後,可以得出整個 Form 表單頁面的概覽大致如下圖所示
相信各位同學到這裡之後,面對一個表單的原型圖,心裡已經有一個大致的虛擬碼的實現了。
通常概覽頁面的要素是,統計資料、時間選擇、圖表。這一類頁面由於通常是作為使用者第一個開啟的頁面,所以需要額外注意的是 loading
狀態的展示。
需要注意的是這裡的 loading 會存在以下幾種情況:
Promise.all
或 Promise
.allSettled 實現。其他沒什麼好說的,主要是 CSS 的要求會更高。大致的虛擬碼會如下:
function(){
const [options, setOptions] = useState({...defaultOptions});
const [loading, setLoading] = useState(false);
const [timeRange, setTimeRange] = useState([moment(), moment()]);
const [statistic, setStatistic] = useState({
a: 0,
b: 0,
});
const getStatistic = () => {
return new Promise((resolve) => {
setStatistic({a: 1000, b: 30});
resolve();
});
};
const getCharts = () => {
return new Promise((resolve) => {
options.xxx = xxxx;
setOptions({...options});
});
};
useEffect(() => {
setLoading(true);
Promise.all([getStatistic(), getCharts()]).finally(() => {
setLoading(false);
});
}, [timeRange]);
return (
<>
<DateRange value={timeRange} onChange={(val) => setTimeRange(val)} />
<Statistic />
<LineCharts />
</>
)
}
通常來說,我個人的習慣是先實現需求,再進行程式碼優化和分割,模組的提取等。所以以下優化都是基於所有業務邏輯已經完成的情況下。
通常,在完成業務後,一個元件內部會包含大量的 hooks 相關的東西。通常優化手段如下:
useState
的使用,將不影響渲染的資料放到 useRef
裡去,甚至說常數可以放到函數外部。同時,如果是同一個種類的可以進行合併useMemo
的時候,普通的賦值或宣告或簡單的計算完全不需要引入 useMemo
,可以在複雜的計算時加上 useMemo
useCallback
的使用,目前想到的 useCallback
的場景,只有addEventListener
的時候,其餘情況下大部分都用不到。useEffect
可以進行寫多個的,所以有些時候不同的邏輯可以放到不同的 useEffect
裡去useRef
可以大量持有,useRef
能 cover
的場景遠大於 ref
useContext
在簡單場景下完全可以替代 redux
,但是有效能問題。所以複雜場景下,建議是配合use-context-selector
使用,或者選擇其他狀態管理工具,如:recoil
useLayoutEffect
和 useEffect
在絕大部分應用情況下沒有差異,只需要直接使用 useEffect
即可。目前考慮前者的唯一不可替代性僅存在於「閃爍」場景。useReducer
一般來說用不到,其使用場景應該是當存在多個資料,而某一個引數的改變會引起其他資料同時改變,從而引起頁面重渲染。const initialState = {count: 0};
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
}
useImperativeHandler
在通常情況下避免使用,使用場景應該只有兩種。第一種是要做 ref 的轉發,比如封裝了一個元件,需要把元件內部的某一個 input 的 ref 轉發給父元件。第二種是封裝了一個元件,該元件內部實現邏輯是非 JSX 的,存在範例,則需要通過該 hooks 把相關範例轉發給父元件。useDebugValue
不熟useDeferredValue
不熟useTransition
不熟useId
不熟useSyncExternalStore
不熟useInsertionEffect
不熟眾所周知,React 庫搭配 Ant Design 食用是後臺管理系統的高效的原因之一。
Ant Design 相關實踐如下:
Space
元件,個人感覺有點類似於簡單場景的 Grid
元件Steps+Form
而不是 Steps.item+Form
AutoComplete
個人感覺有很多問題,譬如資料回填高亮等問題,個人感覺不如直接 Input
Form
元件比較複雜,且配合其他元件的用法比較多,如 Form+Steps
Form+Tabs
Form+Modal
等,需要注意 inactiveDestroy + preserve={false}
。另外需要注意 requiredMark
和 required
的區別,validator 和其他 rules 的區別,setFieldsValue
和 setFields
的區別Input
等輸入控制元件需要注意 placeholder
的值,Select 需要額外主要 allowSearch
Select
個人習慣直接通過 options
賦值,而不是 children
(原因:相比 jsx 會有更加好的渲染效能)select
可以通過 dropdownRender
自定義下拉內容Tree
元件面向的場景比較複雜的情況下,個人覺得 antd 的 tree 元件不好用,偏向於自己手寫popup
相關元件都可以設定 getPopupContainer
,該屬性用來修改元件渲染位置,如果遇到彈出層沒有隨卷軸捲動可以設定,但是設定完可能需要考慮 overflow:hidden
的問題Table
元件的 rowKey 屬性很重要,必加。Table + Modal
的寫法,不要把 Modal
寫到操作列裡面去Modal
和 Drawer
元件不推薦用 visible && <Modal />
的寫法,會導致動畫丟失。建議在寫 Modal
或者 Drawer
的時候,把「空狀態」考慮進去。同時可以配合 Modal
的 destroyOnClose
屬性可以讓每次 Modal 開啟內容都是新內容。(PS:Form 除外)Spin
可以多,但不能少div
,不要動不動就搞個 div
,過多的層級結構會影響載入速度的。量變引起質變ref
的使用,在通常情況下如果考慮用 ref
解決問題的話,那可能代表了你的思路不對或程式碼設計不對。IIFE
。做到不脫離當前的上下文,又實現了相關邏輯的提取。比函數中定義函數更好一些。以上的相關實踐,是本人在日積月累中總結和摸索出來的。
如有雷同,說明你和我有一樣的感受。