利用 React Context API + useReducer 實作 Redux
25 May 2023前言
在 React 專案中,通常會使用 props 與 state 來進行元件間的資料傳遞與狀態管理。這種方法簡單易懂,讓資料傳遞過程清楚透明。然而,隨著專案規模的擴大,元件間的溝通變得更加複雜,使用 props 和 state 來管理資料變得困難,例如遇到 (1) prop drilling、(2) 難以集中管理與維持資料一致性等問題。此外,(3) 處理非同步請求和 (4) 重新渲染等效能問題也變得困難。
為了解決這些問題,通常會採用 Redux 或利用 context API + useReducer 等方法來進行資料傳遞與狀態管理。這些工具提供了更有效的方式來處理資料傳遞、狀態管理和處理效能議題,這在大型專案中尤為重要。
Redux 和利用 context API + useReducer 的資料傳遞與狀態管理方法,都是基於 Flux 的概念。Flux 的目標是維持資料的一致性,使資料只能單向流動。具體來說,是將資料集中儲存在 store 中,view 透過 action 觸發 dispatcher 來更新 store 的狀態,當狀態改變時,重新渲染 view。這樣設計的好處是 (1) 解決 prop drilling 問題,避免狀態的層層傳遞;(2) 使用 reducer 來處理狀態變化,避免直接修改資料,減少錯誤,並能夠追蹤資料的變化。這種設計能夠更有效地管理和控制狀態。
以下是 Redux data flow 概念圖。
大家應該都對 Redux 很熟悉,並且本篇文章主要想要聊聊怎麼利用 React Context API 與 useReducer 來實現類似 Redux 的功能,那先來了解一下 Context API 和 useReducer 是什麼吧。
Context API
來看怎麼儲存和傳遞資料,在這裡會用 Context API 來處理這部份。
Context API 是 React 提供的一種機制,用於在元件中共享全域的資料,讓元件可以直接存取這些資料,而不需要透過狀態設定和屬性的層層傳遞。
使用 Context API 的方法很簡單,首先利用 createContext 建立一個 context 物件,然後以向下廣播的方式將這個 context 從 parent component (provider) 傳遞到 child component。在 child component 中使用 useContext 來取得 context 的值 (consumer),再根據需求對這個值做出相應的反應。相較於 props,Context API 不需要將大量的資料細節層層傳遞下去,讓程式碼更簡潔且易於維護。
簡單範例如下。
import React, { createContext, useContext } from 'react';
const MyContext = createContext();
const ParentComponent = () => {
const data = 'Hello, Context!';
return (
<MyContext.Provider value={data}>
<ChildComponent />
</MyContext.Provider>
);
}
const ChildComponent = () => {
const value = useContext(MyContext);
return <div>{value}</div>;
}
const App = () => {
return <ParentComponent />;
}
在這個範例中使用 createContext 建立 MyContext。在 <ParentComponent>
中,使用 MyContext.Provider
作為資料的提供者,將要共享的資料 'Hello, Context!'
傳入 value。在 <ChildComponent>
中,使用 useContext 來取得 MyContext 的值,這樣就能直接共享全域的資料,而不需要透過屬性的層層傳遞。最後,在 <App>
中將 <ParentComponent>
渲染到畫面上。當 <ChildComponent>
渲染時,它會直接存取 MyContext 的資料並將其顯示在畫面上。這種方法避免了屬性的層層傳遞,使程式碼更加簡潔且易於維護。
了解了怎麼儲存和傳遞資料之後,接著要來看怎麼更新資料,在這裡會用 useReducer 來做狀態的更新。
useReducer
再來,實作處理管理與更新狀態的部份。
useReducer 是 React 提供的一個 hook,可用於處理較複雜的狀態邏輯。透過 useReducer 能夠管理元件的狀態並進行相應的狀態更新。useReducer 會回傳一個包含當前狀態和 dispatch 函式的陣列,我們可以使用 useReducer 的 state 來存放狀態,並透過呼叫 dispatch 函式與帶入相對應的 action 和 payload 來完成狀態的更新。簡而言之,useReducer 提供了一種簡易版的狀態管理系統,讓我們能夠更有效地處理複雜的狀態邏輯。
簡單範例如下。
import { useReducer } from 'react';
const initialState = { count: 0 };
const reducer = (state, action) => {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
default:
throw new Error();
}
};
const Counter = () => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
Count: {state.count}
<button onClick={() => dispatch({ type: 'increment' })}>
+
</button>
<button onClick={() => dispatch({ type: 'decrement' })}>
-
</button>
</div>
);
};
在這個範例中定義了初始狀態 initialState,並實作了一個 reducer 函式,根據不同的 action 類型和 payload 來更新狀態。在 <Counter>
元件中,使用 useReducer hook,將初始狀態和 reducer 函式作為參數傳入,並得到當前的狀態和 dispatch 函式。透過點擊 +
和 -
按鈕來呼叫 dispatch 函式並傳入對應的 action,從而更新狀態。透過 useReducer 能夠更好地組織和管理元件的狀態邏輯,特別是在處理複雜的狀態轉換時,它提供了一種更好讀也更好維護的解法。
手作 Redux
來將 React Context API + useReducer 結合起來,實現 Redux 功能吧!我們將做一個簡單的範例,在點擊按鈕後顯示 “Hello World!” 的訊息。
點擊按鈕之前。
在點擊按鈕之後,會顯示 Hello World!
的訊息。
完整的範例程式碼,可相互搭配比較
實作過程會按照以下幾個步驟進行。
- 首先,實作狀態處理的 reducer。reducer 是一個 pure function,用來更新狀態。我們將狀態存在 context 中,並用 dispatch 更新狀態。利用 context 的廣播特性,將更新後的狀態傳遞給下層元件。
- 接著,建立一個名為 helloReducer 的 reducer。通常情況下會有多個 reducer,稍後將使用 combineReducer 將它們合併。在 helloReducer 中定義了 action type、初始狀態,以及 reducer 這個更新 state 的函式。reducer 接受當前狀態和動作作為輸入,並回傳下一個狀態。在這裡定義了設定訊息的 action SET_MESSAGE,以及儲存訊息的狀態 message。helloReducer 接受 payload 作為輸入,並回傳一個包含新狀態的物件。
範例如下。
export const ACTION_TYPE = {
SET_MESSAGE: 'SET_MESSAGE'
};
const initState = {
message: '',
xxoo: 'abc'
};
const helloReducer = (state = initState, action) => {
switch (action.type) {
case ACTION_TYPE.SET_MESSAGE:
return {
...state,
message: action.payload.message
};
default:
return state;
}
};
export { helloReducer };
由於通常會有多個 reducer,因此需要使用 combineReducer 函數。在 reducers 中將 combineReducer 函數放置其中,以將所有的 reducer 合併在一起。這樣可以確保狀態的集中管理,而不會散佈在各處,這也能達成取得的狀態是同一份資料,避免不一致的問題。
combineReducer 函數執行以下幾個步驟…
- 第一,檢查各個 reducer 的 default state。當將 pure function reducer 作為參數傳入時,應該檢查是否傳入了不存在的 action type 或未設定狀態。如果沒有定義預設狀態且回傳 undefined,則很可能是在 reducer 中沒有正確定義預設值。在檢查完畢後,將這個預設狀態暫存起來,在建立 context 時可能需要將其作為初始值傳入。
- 第二,為了要回傳一個合併所有 reducer 的函數,首先把各個 reducer 中的狀態抽出來檢查。例如,在 helloReducer 中儲存了 message 和 xxoo 等狀態,我們將這些狀態抽取出來並暫存起來。然後,將剛才暫存的state、action type 和 payload 傳遞給各個 reducer,生成新的狀態。最後,將新的狀態作為一個新的物件回傳,這就是 combineReducer 函數最重要的任務了。
範例如下。
import { helloReducer } from './helloReducer';
const combineReducer = (reducers) => {
const reducerKeys = Object.keys(reducers);
let objInitState = {};
reducerKeys.forEach((key) => {
objInitState[key] = reducers[key](undefined, { type: '' });
});
return (state, action) => {
if (action) {
reducerKeys.forEach((key) => {
const previousState = objInitState[key];
objInitState[key] = reducers[key](previousState, action);
});
}
return { ...objInitState };
};
};
const reducers = combineReducer({
hello: helloReducer,
});
export { reducers };
再來,實作資料傳遞的 context provider,利用廣播的方式將資料傳遞給元件。實作的步驟如下…
- 首先,使用 createContext 函數建立一個 context 物件 ReducerContext,這個 context 物件稍後將用於廣播狀態給元件,讓它們可以存取資料。在實作過程中將以這個 context 物件取代 react-redux 的 provider,將所有資料放在應用程式的最上層。然後,在需要資料的元件中,使用 useContext 函數來取代 mapPropsToState 和 connect 的作用。
- 接著,使用 useReducer 函數來建立 reducer,並將前面所準備的合併後的大型 reducer 以及初始狀態傳入。然後,將這個 reducer 與初始狀態一起傳遞到 ReducerContext 中。
範例如下。
import { createContext, useReducer } from 'react';
import { reducers } from './reducers';
const ReducerContext = createContext(null);
export { ReducerContext };
const initState = reducers();
const ReducerContextProdiver = ({ children }) => {
const reducer = useReducer(reducers, initState);
return (
<ReducerContext.Provider value={reducer}>
{children}
</ReducerContext.Provider>
);
};
export default ReducerContextProdiver;
然後在最外面的用 <ReducerContextProvider>
包好下面要廣播的元件們,像是 <HelloWorld />
。
<ReducerContextProvider>
<HelloWorld />
</ReducerContextProvider>
當元件 (例如 <HelloWorld />
) 要用到已存好的狀態時,就用 useContext 把 ReducerContext 裡面的值取出來,像是以下這樣。
const [state, dispatch] = useContext(ReducerContext);
console.log(state.hello.message); // "Hello World"
這樣就大功告成了!
推薦這篇文章,對於如何利用 useContext + useReducer 實作 Redux 有很詳細的說明,還有附上 todo list 範例。
雖然 context API + useReducer 作為狀態管理的解法是很棒的,但並不是萬能,可參考這篇和那篇,探討各種狀態管理的疑難雜症以及效能問題與解法。
效能
提到效能,通常會用兩個方向來衡量
- Loading performance:利用 ChromeDev Tools (錄製超過 10 秒會當掉)、Lighthouse 來檢測 web vitals (LCP、FID and CLS)
- Rendering performance:利用 ChromeDev Tools、why-did-you-update 或直接 console timestamps 來計算元件 re-render 的次數。
測量 loading performance,FCP 和 LCP 應小於 2 秒才是優良。
Metrix | Context API + useReducer | Redux |
---|---|---|
FCP, LCP | 1102.3 ms | 1167.6 ms |
測量 rendering performance,檢測執行時間和 re-render 的次數。
Scenario | Context API + useReducer | Redux |
---|---|---|
Initial loading | Re-render 2 times | Re-render 2 times |
After click the button to show message | Re-render 2 times | Re-render 2 times |
Duplicate clicking | Do re-render (1) | Do not re-render |
Execution time (2) | 83ms | 82ms |
- (1) Make sure to have stable object references for the context value, e.g. by useMemo Hook. Reference here.
- (2) Execution time means from clicking the button to show message.
由以上比較可知,兩者並無太大差異。
總結
常有人問要選用 Redux 還是利用 context API + useReducer 來做狀態管理呢?context API + useReducer 能完全取代 Redux 嗎?這取決於專案需求和技術狀況。根據個人的實作經驗,以下提供一些小小的想法…
- 若專案架構簡單,使用 props 與 state 可能已經足夠,可參考比較 useState 與 useReducer。
- 若專案需要極度輕量化、最小化 package 並減少相依性,可以選擇使用 context API + useReducer 進行狀態管理;反之,則選擇 Redux。
- 注意 React 和 React Redux 的版本匹配,必須搭配適合的版本;16.8+ 可使用 hooks。
- 在 bundle size 方面,redux@4.2.1 (4.9KB) + react-redux@8.0.5 (14.7KB) 約為 19.6KB;上述實作的範例程式碼約為 1.6KB。
- 若有以下情況,則選用 Redux:
- 需要在元件外部呼叫、根據條件呼叫或在迴圈中使用狀態。
- 在非 UI 元件中處理狀態。
- 需要處理複雜的非同步操作情況,例如:整合
redux-saga
、redux-observable
。
最近剛好遇到在做一個 monorepo 架構的專案,除了我手邊的 package 需要用到狀態管裡外,其他 package 都用不到,因此在衡量的其他人不用到+需要極度輕量+使用情境單純的狀況下,而選用 context API + useReducer 自製 Redux 的解法。
最後,選擇 Redux 還是 context API + useReducer 取決於專案的具體需求和使用情境,大家可根據實際情況做出適合的選擇!