作者:Alex Xu
閱讀時間大約15~20min
前言
React在16.8版本為我們正式帶來了Hooks
API。什麼是Hooks
?簡而言之,就是對函式式元件的一些輔助,讓我們不必寫class
形式的元件也能使用state和其他一些React特性。按照官網的介紹,Hooks
帶來的好處有很多,其中讓我感受最深的主要有這幾點:
- 函式式元件相比
class
元件通常可以精簡不少程式碼。 - 沒有生命週期的束縛後,一些相互關聯的邏輯不用被強行分割。比如在
componentDidMount
中設定了定時器,需要在componentWillUnmount
中清除;又或者在componentDidMount
中獲取了初始資料,但要記得在componentDidUpdate
中進行更新。這些邏輯由於useEffect
hook的引入而得以寫在同一個地方,能有效避免一些常見的bug。 - 有效減少與善變的
this
打交道。
既然Hooks
大法這麼好,不趕緊上車試試怎麼行呢?於是本人把技術專案的react
和react-dom
升級到了16.8.6版本,並按官方建議,漸進式地在新元件中嘗試Hooks
。不得不說,感覺還是很不錯的,確實敲少了不少程式碼,然而有個值得注意的地方,那就是結合React-Redux
的使用。
問題
我們先來看一段使用了Hooks的函式式元件結合React-Redux connect
的用法:
import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
// action creators
import { queryFormData } from "@/data/queryFormData/action";
import { submitFormData } from "@/data/submitFormData/action";
function Form(props) {
const {
formId
formData,
queryFormData,
submitFormData,
} = props;
useEffect(() => {
// 請求表單資料
queryFormData(formId);
},
// 指定依賴,防止元件重新渲染時重複請求
[queryFormData, formId]
);
// 處理提交
const handleSubmit = usefieldValues => {
submitFormData(fieldValues);
}
return (
<FormUI
data={formData}
onSubmit={handleSubmit}
/>
)
}
function mapStateToProps(state) {
return {
formData: state.formData
};
}
function mapDispatchToProps(dispatch, ownProps) {
// withRouter傳入的prop,用於程式設計式導航
const { history } = ownProps;
return {
queryFormData(formId) {
return dispatch(queryFormData(formId));
},
submitFormData(fieldValues) {
return dispatch(submitFormData(fieldValues))
.then(res) => {
// 提交成功則重定向到主頁
history.push('/home');
};
}
}
}
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(React.memo(Form));
複製程式碼
上面程式碼描述了一個簡單的表單元件,通過mapDispatchToProps
生成的queryFormData
prop請求表單資料,並在useEffect
中誠實地記錄了依賴,防止元件re-render時重複請求後臺;通過mapDispatchToProps
生成的submitFormData
prop提交表單資料,並在提交成功後使用React-Router提供的history
prop程式設計式導航回首頁;通過mapStateToProps
生成的formData
prop拿到後臺返回的資料。看起來似乎妹啥毛病?
其實有毛病。
問題就在於mapDispatchToProps
的第二個引數——ownProps
:
function mapDispatchToProps(dispatch, ownProps) { // **問題在於這個ownProps!!!**
const { history } = ownProps;
...
}
複製程式碼
在上面的例子中我們需要使用React-Router的withRouter
傳入的history
prop來進行程式設計式導航,所以使用了mapDispatchToProps
的第二個引數ownProps。然而關於這個引數,React-Redux官網上的這句話也許不是那麼的引人注意:
如果我們在宣告mapDispatchToProps
時使用了第二個引數(即便宣告後沒有真的用過這個ownProps
),那麼每當connected的元件接收到新的props時,mapDispatchTopProps
都會被呼叫。這意味著什麼呢?由於mapDispatchToProps
被呼叫時會返回一個全新的物件(上面的queryFormData
、submitFormData
也將會是全新的函式),所以這會導致上面傳入到<Form/>
中的queryFormData
和submitFormData
prop被隱式地更新,造成useEffect
的依賴檢查失效,元件re-render時會重複請求後臺資料。
對應的React-Redux原始碼是這段:
// selectorFactory.js
...
// 此函式在connected元件接收到new props時會被呼叫
function handleNewProps() {
if (mapStateToProps.dependsOnOwnProps)
stateProps = mapStateToProps(state, ownProps)
// 宣告mapDispatchToProps時如果使用了第二個引數(ownProps)這裡會標記為true
if (mapDispatchToProps.dependsOnOwnProps)
// 重新呼叫mapDispatchToProps,更新dispatchProps
dispatchProps = mapDispatchToProps(dispatch, ownProps)
// mergeProps的做法其實是:mergedProps = { ...ownProps, ...stateProps, ...dispatchProps }
// 最後傳入被connect包裹的元件
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
...
複製程式碼
解決方案
1. 最省事
給useEffect的第二個引數傳一個空陣列:
function Form(props) {
const {
formId,
queryFormData,
...
} = props;
useEffect(() => {
// 請求表單資料
queryFormData(formId);
},
// 傳入空陣列,起到類似componentDidMount的效果
[]
);
...
}
複製程式碼
這種方式相當於告訴useEffect
,裡面要呼叫的方法沒有任何外部依賴——換句話說就是不需要(在依賴更新時)重複執行,所以useEffect
就只會在元件第一次渲染後呼叫傳入的方法,起到類似componentDidMount
的效果。然而,這種方法雖然可行,但卻是一種欺騙React的行為(我們明明依賴了來自props的queryFormData
和formId
),很容易埋坑(見React官方的Hooks FAQ)。實際上,如果我們有遵循React官方的建議,給專案裝上eslint-plugin-react-hooks
的話,這種寫法就會收到eslint的告警。所以從程式碼質量的角度考慮,儘量不要偷懶採用這種寫法。
2. 不使用ownProps
引數
把需要用到ownProps的邏輯放在元件內部:
function Form(props) {
const {
formId
queryFormData,
submitFormData,
history
...
} = props;
useEffect(() => {
queryFormData(formId);
},
// 由於宣告mapDispatchToProps時沒使用ownProps,所以queryFormData是穩定的
[queryFormData, formId]
);
const handleSubmit = fieldValues => {
submitFormData(fieldValues)
// 把需要用到ownProps的邏輯遷移到元件內定義(使用了redux-thunk中介軟體,返回Promise)
.then(res => {
history.push('/home');
});
}
...
}
...
function mapDispatchToProps(dispatch) { // 不再宣告ownProps引數
return {
queryFormData(formId) {
return dispatch(queryFormData(formId));
},
submitFormData(fieldValues) {
return dispatch(submitFormData(fieldValues));
}
}
}
...
複製程式碼
同樣是改動較少的做法,但缺點是把相關聯的邏輯強行分割到了兩個地方(mapDispatchToProps
和元件裡)。同時我們還必須加上註釋,提醒以後維護的人不要在mapDispatchToProps
裡使用ownProps
引數(實際上如果有瞄過上面的原始碼,就會發現mapStateToProps
也有類似的顧忌),並不太靠譜。
3. 不使用mapDispatchToProps
如果不給connect
傳入mapDispatchToProps
,那麼被包裹的元件就會接收到dispatch
prop,從而可以把需要使用dispatch
的邏輯寫在元件內部:
...
// action creators
import { queryFormData } from "@/data/queryFormData/action";
import { submitFormData } from "@/data/submitFormData/action";
function Form(props) {
const {
formId
history,
dispatch
...
} = props;
useEffect(() => {
// 在元件內使用dispatch
// 注意這裡的queryFormData來自import,而非props,不會變,所以不用寫進依賴陣列
dispatch(queryFormData(formId))
},
[dispatch, formId]
);
const handleSubmit = fieldValues => {
// 在元件內使用dispatch
dispatch(submitFormData(fieldValues))
.then(res => {
history.push('/home');
});
}
...
}
...
// 不傳入mapDispatchToProps
export default withRouter(connect(mapStateToProps, null)(React.memo(Form));
複製程式碼
這是個人比較推薦的做法,不必分割相關聯的邏輯(這也是hooks的初衷之一),同時把dispatch的相關邏輯寫在useEffect裡也可以讓eslint自動檢查依賴,避免出bug。當然帶來的另一個問題是,如果需要請求很多條cgi,那把相關邏輯都寫在useEffect
裡好像會很臃腫?此時我們可以使用useCallback
:
import { actionCreator1 } from "@/data/actionCreator1/action";
import { actionCreator2 } from "@/data/actionCreator2/action";
import { actionCreator3 } from "@/data/actionCreator3/action";
...
function Form(props) {
const {
dep1,
dep2,
dep3,
dispatch
...
} = props;
// 利用useCallback把useEffect要使用的函式抽離到外部
const fetchUrl1() = useCallback(() => {
dispatch(actionCreator1(dep1));
.then(res => {...})
.catch(err => {...});
}, [dispatch, dep1]); // useCallback的第二個引數跟useEffect一樣,是依賴項
const fetchUrl2() = useCallback(() => {
dispatch(actionCreator2(dep2));
.then(res => {...})
.catch(err => {...});
}, [dispatch, dep2]);
const fetchUrl3() = useCallback(() => {
dispatch(actionCreator3(dep3));
.then(res => {...})
.catch(err => {...});
}, [dispatch, dep3]);
useEffect(() => {
fetchUrl1();
fetchUrl2();
fetchUrl3();
},
// useEffect的直接依賴變成了useCallback包裹的函式
[fetchUrl1, fetchUrl2, fetchUrl3]
);
// 為了避免子元件發生不必要的re-render,handleSubmit其實也應該用useCallback包裹
const handleSubmit = useCallback(fieldValues => {
// 在元件內使用dispatch
dispatch(submitFormData(fieldValues))
.then(res => {
history.push('/home');
});
});
return (
<FormUI
data={formData}
onSubmit={handleSubmit}
/>
)
}
...
複製程式碼
useCallback
會返回被它包裹的函式的memorized版本,只要依賴項不變,memorized的函式就不會更新。利用這一特點我們可以把useEffect
中要呼叫的邏輯使用useCallback
封裝到外部,然後只需要在useEffect
的依賴項裡新增memorized的函式,就可以正常運作了。
然而正如前文提到的,mapStateToProps
中的ownProps
引數同樣會引起mapStateToProps
的重新呼叫,產生新的state props:
// 此函式在connected元件接收到new props時會被呼叫
function handleNewProps() {
// 宣告mapStateToProps時如果使用了ownProps引數同樣會產生新的stateProps!
if (mapStateToProps.dependsOnOwnProps)
stateProps = mapStateToProps(state, ownProps)
if (mapDispatchToProps.dependsOnOwnProps)
dispatchProps = mapDispatchToProps(dispatch, ownProps)
mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
return mergedProps
}
複製程式碼
因此在這種方案中如果useEffect
有依賴這些state props的話還是有可能造成依賴檢查失效(比如說state props是引用型別)。
4. 使用React-Redux的hooks APIs(推薦)
既然前面幾種方案或多或少都有些坑點,那麼不妨嘗試一下React Redux在v7.1.0版本為我們帶來的官方hooks APIs,下面就展示下基本用法。
主要用到的API:
import { useSelector, useDispatch } from 'react-redux'
// selector函式的用法和mapStateToProps相似,其返回值會作為useSelector的返回值,但與mapStateToProps不同的是,前者可以返回任何型別的值(而不止是一個物件),此外沒有第二個引數ownProps(因為可以在元件內通過閉包拿到)
const result : any = useSelector(selector : Function, equalityFn? : Function)
const dispatch = useDispatch()
複製程式碼
使用:
...
import { useSelector, useDispatch } from "react-redux";
// action creators
import { queryFormData } from "@/data/queryFormData/action";
import { submitFormData } from "@/data/submitFormData/action";
function Form(props) {
const {
formId
history,
dispatch
...
} = props;
const dispatch = useDispatch();
useEffect(() => {
dispatch(queryFormData(formId))
},
[dispatch, formId]
);
const handleSubmit = useCallback(fieldValues => {
dispatch(submitFormData(fieldValues))
.then(res => {
history.push('/home');
});
}, [dispatch, history]);
const formData = useSelector(state => state.formData;);
...
return (
<FormUI
data={formData}
onSubmit={handleSubmit}
/>
);
}
...
// 無需使用connect
export default withRouter(React.memo(Form));
複製程式碼
可以看到和上面介紹的"不使用mapDispatchToProps"
的方式很相似,都是通過傳入dispatch
,然後把需要使用dispatch
的邏輯定義在元件內部,最大差異是把提取state
的地方從mapStateToProps
變成useSelector
。兩者的用法相近,但如果你想後者像前者一樣返回一個物件的話要特別注意:
由於useSelector
內部預設是使用===
來判斷前後兩次selector函式的計算結果是否相同的(如果不相同就會觸發元件re-render),那麼如果selector函式返回的是物件,那實際上每次useSelector
執行時呼叫它都會產生一個新物件,這就會造成元件無意義的re-render。要解決這個問題,可以使用reselect等庫建立帶memoized效果的selector ,或者給useSelector
的第二個引數(比較函式)傳入react-redux內建的shallowEqual
:
import { useSelector, shallowEqual } from 'react-redux'
const selector = state => ({
a: state.a,
b: state.b
});
const data = useSelector(selector, shallowEqual);
複製程式碼
用Hooks代替Redux?
自從Hooks
出現後,社群上一個比較熱門的話題就是用Hooks
手擼一套全域性狀態管理,一種常見的方式如下:
-
相關
Hooks
:useContext
,useReducer
-
實現:
import { createContext, useContext, useReducer, memo } from 'react'; function reducer(state, action) { switch (action.type) { case 'UPDATE_HEADER_COLOR': return { ...state, headerColor: 'yellow' }; case 'UPDATE_CONTENT_COLOR': return { ...state, contentColor: 'green' }; default: break; } } // 建立一個context const Store = createContext(null); // 作為全域性state const initState = { headerColor: 'red', contentColor: 'blue' }; const App = () => { const [state, dispatch] = useReducer(reducer, initState); // 在根結點注入全域性state和dispatch方法 return ( <Store.Provider value={{ state, dispatch }}> <Header/> <Content/> </Store.Provider> ); }; const Header = memo(() => { // 拿到注入的全域性state和dispatch const { state, dispatch } = useContext(Store); return ( <header style={{backgroundColor: state.headerColor}} onClick={() => dispatch('UPDATE_HEADER_COLOR')} /> ); }); const Content = memo(() => { const { state, dispatch } = useContext(Store); return ( <div style={{backgroundColor: state.contentColor}} onClick={() => dispatch('UPDATE_CONTENT_COLOR')} /> ); }); 複製程式碼
上述程式碼通過context
,把一個全域性的state
和派發actions
的dispatch
函式注入到被Provider
包裹的所有子元素中,再配合useReducer,看起來確實是個窮人版的Redux。
然而,上述程式碼其實有效能隱患:無論我們點選<Header/>
還是<Content/>
去派發一個action,最終結果是:
<Header/>
和<Content/>
都會被重新渲染!
因為很顯然,它們倆都消費了同一個state(儘管都只消費了state的一部分),所以當這個全域性的state被更新後,所有的Consumer自然也會被更新。
但我們不是已經用
memo
包裹元件了嗎?
是的,memo
能為我們守住來自props的更新,然而state
是在元件內部通過useContext
這個hook注入的,這麼一來就會繞過最外層的memo
。
那麼有辦法可以避免這種強制更新嗎? Dan Abramov大神給我們指了幾條明路:
-
拆分Context(推薦)。把全域性的State按需求拆分到不同的
context
,那麼自然不會相互影響導致無謂的重渲染; -
把元件拆成兩個,裡層的用
memo
包裹:const Header = () => { const { state, dispatch } = useContext(Store); return memo(<ThemedHeader theme={state.headerColor} dispatch={dispatch} />); }; const ThemedHeader = memo(({theme, dispatch}) => { return ( <header style={{backgroundColor: theme}} onClick={() => dispatch('UPDATE_HEADER_COLOR')} /> ); }); 複製程式碼
-
使用
useMemo
hook。思路其實跟上面的一樣,但不用拆成兩個元件:const Header = () => { const { state, dispatch } = useContext(Store); return useMemo( () => ( <header style={{backgroundColor: state.headerColor}} onClick={() => dispatch('UPDATE_HEADER_COLOR')} /> ), [state.headerColor, dispatch] ); }; 複製程式碼
可見,如果使用Context
+ Hooks
來代替Redux等狀態管理工具,那麼我們必須花費額外的心思去避免效能問題,然而這些dirty works其實React-Redux等工具已經默默替我們解決了。除此之外,我們還會面臨以下問題:
- 需要自行實現combineReducers等輔助功能(如果發現要用到)
- 失去Redux生態的中介軟體支援
- 失去Redux DevTools等除錯工具
- 出了坑不利於求助……
所以,除非是在對狀態管理需求很簡單的個人或技術專案裡,或者純粹想造輪子練練手,否則個人是不建議放棄Redux等成熟的狀態管理方案的,因為價效比不高。
總結
React Hooks給開發者帶來了清爽的使用體驗,一定程度上提升了鍵盤的壽命【並不,然而與原有的React-Redux connect
相關APIs結合使用時,需要特別小心ownProps
引數,很容易踩坑,建議儘快升級到v7.1.0版本,使用官方提供的Hooks API。
此外,使用Hooks自建全域性狀態管理的方式在小專案中固然可行,然而想用在較大型的、正式的業務中,至少還要花費心思解決效能問題,而這個問題正是React-Redux等工具已經花費不少功夫幫我們解決了的,似乎並沒有什麼充分的理由要拋棄它們。
參考
推薦閱讀
關注【IVWEB社群】公眾號獲取每週最新文章,通往人生之巔!