理解 Redux 原始碼 (5):從 HOC 到 Hooks,實作 useSelector 與 useDispatch
前言
在上篇理解 Redux 原始碼 (4):透過 Provider 與 connect 理解 React-Redux 的組合中,能理解 React 與 Redux 如何結合,並知道如何實作簡單的 Provider
與 connect
。
隨著 React 與 React-Redux 版本的提升,現在官方更建議使用 Hooks 的方式來使用 React-Redux,而非過去的 HOC(High Order Component) 模式 e.g.connect
。這樣的轉換是有好處的,將會在接下來的內文中分享。
而 React-Redux Hooks 中,最常用到的就是 useSelector
以及 useDispatch
,當使用兩者時,就無需再使用 connect
, mapStateToProps
, mapDispatchToProps
等,便能取用 Redux store 的資料與方法。因此本文關於 Hooks 的部分,將聚焦於探討這兩個 Hooks 的應用和原始碼,透過實作簡單的 useSelector
與 useDispatch
將更理解 React-Redux 的 Hooks 版本。
期待閱讀完本文後能:
- 理解為什麼要從 HOC 轉為 Hooks 的寫法,好處為何
- 理解
useSelector
與useDispatch
Hook APIs - 能實作簡單版本的
useSelector
與useDispatch
p.s. 隨著 React, React-Redux 版本不同,useSelector / useDispatch 的原始碼會有所不同,本文撰寫的範例不一定是最新版本的寫法,但核心概念差異不大,仍可參考並有所學習。
談談為何要從 HOC 轉為 Hooks
每個技術的轉折都有背後的原因,而這段會探討:「為什麼要將 HOC 轉為 Hooks 寫法」。
首先,快速複習 HOC(High Order Component) 的概念:
「HOC 能接收一個 component 作為參數,並最終返回一個新的 component。這個新的 component 通常會擴充或修改原始 component 的 props 或行為。」
connect
function 就是 HOC 的概念實踐,它的第一組參數,可以傳入 mapStateToProps
與 mapDispatchToProps
,而第二組參數可以傳入 React component,藉此讓 React component 獲得從 mapStateToProps
與 mapDispatchToProps
中選擇的 Redux state/dispatch 資料與方法。
講起來有些繞口,透過程式碼更直觀了解:
/*** Counter.js ***/
import { connect } from 'react-redux';
// 宣告 Counter component,並在最下方,有用 connect 包裹才 export,
// 藉此能從 props 中取到 store 的 state/dispatch
const Counter = ({
title,
count,
increment,
decrement,
submit
}) => {
return (
<div>
<div>{title}</div>
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
<button onClick={submit}>Submit</button>
</div>
);
}
// 宣告 mapStateToProps,設定 Counter 取用的 store state
const mapStateToProps = (state) => ({
count: state.count,
});
// 宣告 mapDispatchToProps,設定 Counter 取用 store dispatch actions
const mapDispatchToProps = (dispatch) => ({
increment: () => dispatch({ type: 'INCREMENT' }),
decrement: () => dispatch({ type: 'DECREMENT' }),
});
// 用 connect 將 Counter 與 store 連結,
// 藉此從 Counter props 中取用 count 資料 & increment, decrement 方法
export default connect(mapStateToProps, mapDispatchToProps)(Counter);
如果對 connect
不熟又想深入瞭解,可以閱讀本系列上篇文章。
關於「換成 Hooks 寫法有什麼好處」這個問題可以反過來討論,也就是「目前 HOC 的寫法有什麼壞處」,而好處通常就是補足壞處的部分。因此先了解 HOC 的問題:
HOC 帶來的問題
1. 資料流模糊 (Obscured data flow)
可以看到 Counter
的 component 有著多個 props,但有辦法直觀地分辨出「這些 props 各自從哪裡來」嗎?哪些是從父層級來的?哪些是從 Redux 來的?
const Counter = ({
title,
count,
increment,
decrement,
submit
}) => {
......
}
......
非常困難。
除非閱讀到 mapStateToProps
與 mapDispatchToProps
內的資料邏輯,才有辦法分辨出 title
, submit
是從父層級來的 props; 而 count
, increment
, decrement
是從 Redux 組合而來的 props。
當 component 資料邏輯更多或更複雜時,這個問題也會被放大,不小心就會弄混 props 的來源,進而導致開發上的問題。
2. props 命名衝突 (Name collisions)
connect
HOC 背後的概念是將原始 React component 的 props 與 mapStateToProps
和 mapDispatchToProps
返回的資料與方法結合,概念示意的程式碼如下:
const connect = (mapStateToProps, mapDispatchToProps) => {
return function (WrappedComponent) {
return function ConnectedComponent(ownProps) {
......
const stateProps = mapStateToProps ?
mapStateToProps(store.getState(), ownProps)
: {};
const dispatchProps = mapDispatchToProps ?
mapDispatchToProps(store.dispatch, ownProps)
: {};
......
// 最終渲染的元件,是透過所有 props 的組合
return (
<WrappedComponent
{...ownProps}
{...stateProps}
{...dispatchProps}
/>
)
}
}
}
重點是關於最後 WrapperdComponent
結合所有 props 的段落,可以想見,假設 ownProps
中,從父層級有個 name
props; 而在 stateProps
中,有從 mapStateToProps
提取 key 為 name
的資料,這兩者的資料命名一樣都是 name
,就有命名衝突的發生,進而導致部分資料被覆蓋的問題。
而這層命名衝突隱含在 HOC 實作邏輯中,不一定會在 component 層級被發現,因此不好 debug。
3. 靜態型別檢查困難 (Hard to statically type)
當使用 TypeScript 進行靜態型別檢查時,HOC 會讓靜態型別檢查的複雜度增加,最主要的原因是 props 混雜來自「父層級傳入」以及「Redux 傳入」。因此不能只定義其中之一的型別,必須注意兩者來源的型別都要定義好。
舉個簡單範例,說明漏掉 Type Check 的狀況:
type ReduxState = {
user: {
id: number;
name: string;
age: number;
};
};
type UserProfileProps = {
user: {
id: number;
name: string;
};
};
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
return <div>{user.name}</div>;
};
const mapStateToProps = (state: ReduxState) => ({
age: state.user.age,
});
// 最終 export 出去的 Connected UserProfile user props,包含 `age`
// 因此上方的 `React.FC<UserProfileProps>` 型別有問題
export default connect(mapStateToProps)(UserProfile);
由於 Connected 的元件混雜了不同來源的 props,因此在定義型別時,就要特別注意會否遺漏,造成開發上的負擔。
以上是 3 個常見的 HOC 問題,其實還有其他的問題,像是「多層 HOC 鑲嵌之下的偵錯困難、效能問題」等等。
那「Hooks 有解決上述 HOC 的問題嗎」?
Hooks 帶來的好處
這邊先直接換成 Hooks 寫法,用 useSelector
與 useDispatch
改寫上面 Counter
的程式碼,在此先不用深入理解 useSelector
與 useDispatch
的用法,只要專注在從 HOC 轉成 Hooks 後的程式碼變化及好處,轉換後的程式碼:
/*** Counter.js ***/
// 引入 useSelector, useDispatch 而非 connect
import { useSelector, useDispatch } from 'react-redux';
const Counter = ({
title,
submit
}) => {
// 使用 useSelector 取得 Redux store 的 count state
const count = useSelector(state => state.count);
// 使用 useDispatch 取得 dispatch 函數,
// 並定義 action creators: increment, decrement
const dispatch = useDispatch();
const increment = () => dispatch({ type: 'INCREMENT' });
const decrement = () => dispatch({ type: 'DECREMENT' });
return (
<div>
<div>{title}</div>
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
<button onClick={submit}>Submit</button>
</div>
);
}
export default Counter;
轉換成 Hooks 後有什麼好處:
1.資料來源區分明確
原本「來自父層級的 props」 與「來自 Redux 的 props」會混雜,導致「認知負擔增加」、「props 命名衝突進而互相覆蓋」、「型別混雜不好定義」等問題。
使用 Hooks 後,因為有明確區分兩者,所以獲得改善。現在資料來自父層級或者 Redux 一目瞭然,而且如果有命名衝突會報錯而非互相覆蓋,當然,型別定義也會更加單純。
// 來自父層級的資料
const Counter = ({
title,
submit
}) => {
// 來自 Redux 的資料
const count = useSelector(state => state.count);
const dispatch = useDispatch();
const increment = () => dispatch({ type: 'INCREMENT' });
const decrement = () => dispatch({ type: 'DECREMENT' });
......
}
2.程式碼更簡潔
因為無須宣告與關注 mapStateToProps
與 mapDispatchToProps
,也不用使用 HOC connect
包裹 React component,進而讓程式碼更加簡潔好讀。
3.減少元件的鑲嵌層級
如果多層的 HOC 結構之下,會導致偵錯困難,像是 connect()()()...
的狀況,就要一層一層去抓問題,然而 Hooks 讓原本多層的鑲嵌扁平化,進而減少偵錯時的困難度。
除此之外,由於 Hooks 就是一種 Function 型態,有著更直覺的 input/output,因此撰寫測試上也相對更容易。
在理解使用 Hooks 的好處後,接著將專注於 useSelector
與 useDispatch
兩個重要的 React-Redux Hook APIs。
深入認識 useSelector
useSelector
是 React-Redux 提供的 Hook API,用來取用 Redux store 中的狀態。當取用的狀態有所改變時,也會觸發 component 的 re-render。
useSelector
主要接受兩個參數 selector funtion 與 options,透過 selector function 就能取得需要的 Redux state,而透過 options 則能夠調整部分設定,通常只會用到第一個 selector 參數。
// useSelector API
const selectedState = useSelector(selector, options)
// useSelector API type
type RootState = ReturnType<typeof store.getState>
type SelectorFn = <Selected>(state: RootState) => Selected
type EqualityFn = (a: any, b: any) => boolean
type CheckFrequency = 'never' | 'once' | 'always'
interface UseSelectorOptions {
equalityFn?: EqualityFn
stabilityCheck?: CheckFrequency
noopCheck?: CheckFrequency
}
const result: Selected = useSelector(
selector: SelectorFn,
options?: EqualityFn | UseSelectorOptions
)
// useSelector examples
export const Counter = () => {
const counter = useSelector((state) => state.counter)
return <div>{counter}</div>
}
export const TodoListItem = ({ id }) => {
const customizedEqual = (oldValue, newValue) => shadowEqual(oldValue, newValue)
const todoItem = useSelector((state) => state.todos[id], customizedEqual)
return <div>{todoItem.text}</div>
}
這邊需要瞭解一個重要的概念「當取用的狀態有所改變時,也會觸發 component re-render。」,具體來說是:
-
首先,Redux 更新狀態:當 dispatch 某個 action,觸發 Readux store 更新機制,更新 store state。
-
再者,useSelector 判斷是否要重新渲染:當 Redux state 變化時,
useSelector
會比較取用的前後 state 是否有不同,如果有不同,才會強制觸發重新渲染。而比較前後兩者 state 是否有變化,預設是使用用===
嚴格比較的方式,兩者的 reference 必須完全相同時,才會被認為是相同,不需要重新渲染。另外,可以藉由第二個參數 options 傳入客製化的 equalityFn 藉此調整比較 state 是否有變化的方式。
特別注意的是,第二點提及的「比較新舊 state 是否有變化」的預設方式,新版的useSelector
與過去 connect
的比較方式是不同的:
- useSelector: 預設用嚴格比較(
===
),新舊 selectedState 兩者的 reference 不同時,觸發重新渲染。 - connect: 預設用淺層比較(
shallowEqual
),新舊 selectedState 兩者的值不同時,觸發重新渲染。
這段主要是來自 Redux 官方文件的描述:
useSelector() only forces a re-render if the selector result appears to be different than the last result. The default comparison is a strict === reference comparison. This is different than connect(), which uses shallow equality checks on the results of mapState calls to determine if re-rendering is needed.
雖然當開發者將程式碼從 connect
HOC 的方式,改寫成 useSelector
的方式時,在大部分的情況下,並不會因為這個差異而造成問題,然而依然需要了解這個差異,以確保正確使用,更能在發生問題時,更快思考到可能的原因。
實作簡易版的 useSelector
在理解 useSelector
API 後,接著來透過實作簡單版本的 useSelector
,藉此更認識它的底層邏輯。
這邊的實作將會聚焦在 useSelector(selector, equalityFn)
實踐,需要注意:
- 傳入的
selector
參數(reduxState) => reduxState.xxx
,能選擇出最終要回傳的 Redux state。 - 需要在 Redux store state 有任何改變時,觸發重新渲染 => 意即需訂閱 store ,在 store state 改變時觸發 forceRender。
- 更進一步,需要確認 Redux store state 的改變影響到 useSelector 所選的 state 時,才需要重新渲染,避免無謂的渲染 => 意即需要紀錄上次的 state,才能做新舊 state 比較。
- 傳入的
eqalityFn
參數,可作為判斷新舊 state 是否改變的函式,若沒有傳入,預設用===
判斷新舊 state 是否改變。
1.「製作 input / output」的介面
- input: selector 及 equalityFn
- output: selectedState
selector function 的形式會是 (reduxState) => reduxState.xxx
,而透過 selector(store.getState())
就能製作出最終回傳的 selectedState
。
/** useSelector.js file **/
import { useContext } from "react";
// 已製作的 ReduxContext,在此無需關注細節,有興趣瞭解可看上篇內容
import { ReduxContext } from './Provider'
const useSelector = (
selector,
equalityFn
) => {
// 從 ReduxContext 中取出 store,可以提供 getState/dispatch 方法
const store = useContext(ReduxContext);
// 透過 selector,選出最後要回傳的 Redux State
const selectedState = selector(store.getState());
// 回傳最後選出的 State
return selectedState;
}
export default useSelector;
2. 實踐「若 Redux state 有任何改變時,觸發重新渲染」
當 Redux state 改變時,要觸發重新渲染,換句話說就是利用 store.subscribe
訂閱 forceRender
,如此,當 store state 改變時,就會執行 forceRender
。
/** useSelector.js file **/
import {
useContext,
useReducer,
useEffect
} from "react";
import { ReduxContext } from './Provider'
const useSelector = (
selector,
equalityFn
) => {
const store = useContext(ReduxContext);
// 透過 useReducer,製作用來觸發重新渲染的 forceRender
const [, forceRender] = useReducer(s => s + 1, 0);
const selectedState = selector(store.getState());
useEffect(() => {
// 透過 store.subscribe 訂閱機制
// dispatch action 到 Redux store 時,呼叫 forceRender
const unsubscribe = store.subscribe(() => forceRender());
return unsubscribe;
}, []);
return selectedState;
}
export default useSelector;
3. 實踐「若 useSelecter 所選 state 有改變時,才觸發重新渲染」
可以透過 useRef
將前一版本的 selectedState
記錄下來,如此就能比對新舊版本的 state 究竟有沒有改變,如果有改變才需要去觸發 forceRender
。
/** useSelector.js file **/
import {
useContext,
useReducer,
useRef,
useEffect
} from "react";
import { ReduxContext } from './Provider'
const useSelector = (
selector,
equalityFn
) => {
const store = useContext(ReduxContext);
const [, forceRender] = useReducer(s => s + 1, 0);
// 利用 useRef 製作儲存上次的 selectedState
const prevSelectedState = useRef(null);
const selectedState = selector(store.getState());
// 實作 checkForUpdates 來檢查新舊 selectedState 是否有變化
function checkForUpdates() {
// 取出最新的 selectedState
const newSelectedState = selector(store.getState());
// 若新舊 selectedState 相等,不會重新渲染
if(newSelectedState === prevSelectedState.current) {
return;
}
// 若新舊 selectedState 不相等,觸發重新渲染
prevSelectedState.current = newSelectedState;
forceRender();
}
useEffect(() => {
// dispatch action 到 Redux store 時,呼叫 checkForUpdates
const unsubscribe = store.subscribe(checkForUpdates);
return unsubscribe;
}, []);
return selectedState;
}
export default useSelector;
4. 實踐「可用傳入的 equalityFn 客製化 function,比對新舊 state 是否有變化」
可以利用傳入的第二個參數 equalityFn 去判斷新舊的 selectedState 有沒有變化。
/** useSelector.js file **/
import {
useContext,
useReducer,
useRef,
useEffect
} from "react";
import { ReduxContext } from './Provider'
// 預設的 equalityFn,用 strict equal 模式
const defaultEqualityFn = (a, b) => a === b;
const useSelector = (
selector,
// 可傳入的客製化的 equalityFn,預設為 defaultEqualityFn
equalityFn = defaultEqualityFn
) => {
const store = useContext(ReduxContext);
const [, forceRender] = useReducer(s => s + 1, 0);
const prevSelectedState = useRef(null);
const selectedState = selector(store.getState());
function checkForUpdates() {
const newSelectedState = selector(store.getState());
// 用 equalityFn 來判斷新舊 selectedState 是否相等
// 若新舊 selectedState 相等,不會重新渲染
if(equalityFn(newSelectedState, latestSelectedState.current)) {
return;
}
// 若新舊 selectedState 不相等,觸發重新渲染
prevSelectedState.current = newSelectedState;
forceRender();
}
useEffect(() => {
// dispatch action 到 Redux store 時,呼叫 checkForUpdates
const unsubscribe = store.subscribe(checkForUpdates);
return unsubscribe;
}, []);
return selectedState;
}
export default useSelector;
至此,就完成了簡易版 useSelector
的核心邏輯,接著會討論 useDispatch
的部分囉。
深入認識 useDispatch
useDispatch
是 React-Redux 提供的另一個 Hook API,它會回傳 store 的 dispatch
方法,,透過 dispatch
方法就能發送 actions 到 Redux store,藉此更新 Redux store state。
使用 useDispatch
可以讓我們更簡單地在 component 中觸發 Redux actions,而不需要透過 connect HOC 方式或其他複雜的方法。
// useDispatch API
const dispatch = useDispatch()
// useDispatch API type
interface Dispatch<A extends Action = UnknownAction> {
<T extends A>(action: T, ...extraArgs: any[]): T
}
const dispatch: Dispatch = useDispatch()
// useDispatch example
export const Counter = ({ value }) => {
const dispatch = useDispatch()
const incrementCounter = () => dispatch(
{ type: 'INCREAMENT' }
)
return (
<div>
<span>{value}</span>
<button onClick={incrementCounter}>
Increment counter
</button>
</div>
)
}
值得注意的是,只要 Redux store 的 instance 是相同的情況下, dispatch
的 reference 都會是相同的,通常而言,一個 application 只會有一個 Provider 以及 create 一次 store 傳入,所以 dispatch
作為 useEffect
or 其他 Hooks 的 dependecy 是很安全的,不會觸發無限循環 loop 等問題。
這段是來自於 Redux 官方文件的描述:
The dispatch function reference will be stable as long as the same store instance is being passed to the <Provider>. Normally, that store instance never changes in an application.
接著,就來實作簡易版的 useDispatch
。
實作簡易版的 useDispatch
要實作簡易版的 useDispatch
,其實非常直觀。只需要從 ReduxContext
中取得 Redux store 的 dispatch function,然後回傳它,就可以達成。
/** useDispatch.js file **/
import { useContext } from "react";
import { ReduxContext } from './Provider';
const useDispatch = () => {
// 從 ReduxContext 中取得 store
const store = useContext(ReduxContext);
// 回傳 store 的 dispatch function
return store.dispatch;
}
export default useDispatch;
在這個簡易版的 useDispatch
中,先使用了 useContext(ReduxContext)
來從 ReduxContext
中取得 Redux store,接著再直接回傳 store 的 dispatch
函式。
如此一來,當開發者在元件中使用 useDispatch
時,就可以提取 dispatch
並且發送 actions 到 Redux store,而不需要透過 connect
加上 mapDispatchToProps
發送 actions 囉。
而當從 ReduxContext
取得 store
時,這個 store
是來自於 Provider
傳遞的 store
,而通常而言,store
在 App 中會是只在最初創建一次,透過 createStore(...)
等方式,所以 store
與 store.dispatch
都會是 stable,不會隨意改變 reference。
相比於 useSelector
,useDisatch
的實作相對簡單許多。
總結,回顧最初的目標
當閱讀過 HOC 與 Hooks 型態的原始碼時,的確會認為後者相對而言更容易理解,我覺得有個關鍵是在於鑲嵌層級比較沒那麼多,且 Hooks 比起來是更存粹的 Function,也令人更直覺地理解之。
當然,真正的原始碼更加複雜,有興趣的非常推薦直接去閱讀喔,比起 HOC 真的更好理解了。在此,回顧本文最初的目標:
1. 理解為什麼要從 HOC 轉為 Hooks 的寫法,好處為何
從 HOC 轉為 Hooks 主要有幾點好處:
資料來源區分明確
使用 Hooks 後,因為有明確區分「來自父層級的 props 資料方法」 與「來自 Redux 資料方法」,所以能解決「認知負擔增加」、「props 命名衝突進而互相覆蓋」、「型別混雜不好定義」等問題。
程式碼更簡潔
因為無須宣告與關注 mapStateToProps
與 mapDispatchToProps
,也不用使用 HOC connect
包裹 React component,進而讓程式碼更加簡潔好讀。
減少元件的鑲嵌層級
如果多層的 HOC 結構之下,會導致偵錯困難,像是 connect()()()...
的狀況,就要一層一層去抓問題,然而 Hooks 讓原本多層的鑲嵌扁平化,進而減少偵錯時的困難度。
讓測試更好撰寫
比起 HOC 的模式,Hooks 算是更簡單的 Function 結構,因此相對更好撰寫測試。
2. 理解 useSelector
與 useDispatch
Hook APIs
useSelector API
useSelector
是 React-Redux 提供的 Hook API,用來取用 Redux store 中的狀態。當取用的狀態有所改變時,也會觸發 component 的 re-render。
useSelector
主要接受兩個參數 selector funtion 與 options,透過 selector function 就能取得需要的 Redux state,而透過 options 則能夠調整部分設定。
const selectedState = useSelector(selector, options)
useDispatch API
useDispatch
是 React-Redux 提供的另一個 Hook API,它會回傳 store 的 dispatch
方法,,透過 dispatch
方法就能發送 actions 到 Redux store,藉此更新 Redux store state。
const dispatch = useDispatch()
3. 能實作簡單版本的 useSelector
與 useDispatch
實作 useSelector
/** useSelector.js file **/
import {
useContext,
useReducer,
useRef,
useEffect
} from "react";
import { ReduxContext } from './Provider'
// 預設的 equalityFn,用 strict equal 模式
const defaultEqualityFn = (a, b) => a === b;
const useSelector = (
// 可傳入負責選擇 selectedState 的 selector 函式
selector,
// 可傳入的客製的 equalityFn,預設為 defaultEqualityFn
equalityFn = defaultEqualityFn
) => {
const store = useContext(ReduxContext);
// 利用 useReducer 製作 forceRender,藉此在特定時機觸發重新渲染
const [, forceRender] = useReducer(s => s + 1, 0);
// 用 useRef 儲存前一次的 selectedState,
// 藉此用於判斷新舊 selectedState 是否改變
const prevSelectedState = useRef(null);
const selectedState = selector(store.getState());
function checkForUpdates() {
const newSelectedState = selector(store.getState());
// 用 equalityFn 來判斷新舊 selectedState 是否相等
// 若新舊 selectedState 相等,不會重新渲染
if(equalityFn(newSelectedState, latestSelectedState.current)) {
return;
}
// 若新舊 selectedState 不相等,觸發重新渲染
prevSelectedState.current = newSelectedState;
forceRender();
}
useEffect(() => {
// 若 dispatch action 到 Redux store 時,呼叫 checkForUpdates
const unsubscribe = store.subscribe(checkForUpdates);
return unsubscribe;
}, []);
// 回傳選擇出來的 selectedState
return selectedState;
}
export default useSelector;
實作 useDispatch
/** useDispatch.js file **/
import { useContext } from "react";
import { ReduxContext } from './Provider';
const useDispatch = () => {
// 從 ReduxContext 中取得 store
const store = useContext(ReduxContext);
// 回傳 store 的 dispatch function
return store.dispatch;
}
export default useDispatch;
以上就是本文的內容,希望對閱讀的你有幫助,若有興趣也可閱讀下方更多的參考資料囉。