Skip to main content

[Implement] 60 行實現 react-redux

原始碼在這,歡迎來玩~

前言:Context 的效能問題

過去直接使用 Context,會造成 Child component 一直 re-render,就算該 component 實際用到的值沒有變更,還是會造成 re-render


以一個有 3 個 Counters,都包在 Context 底下的情境為例,Counters 條件如下

  • 2 個 Counters 的狀態使用 Context 提供的 count1, count2 state
  • 1 個 Counter 有自己的 state

Counters in Context

若我們來實際操作的話,就會有以下的效果



我們就會發現

  1. 當我對 Context 中的 count1 做加減時,整個 Context component, Counter1, Counter2 都會 re-render
  2. count2 同上


我們先來看看程式碼片段,來了解為什麼會這樣

const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);

// 當 count1, count2 有變動時, useMemo 就會產生新的 contextValue
// 造成有新的 reference,使 ContextCounter1, ContextCounter2 re-render
const contextValue = useMemo(
() => ({
count1,
count2,
setCount1,
setCount2,
}),
[count1, count2, setCount1, setCount2]
);

return (
<div>
<CounterContext.Provider value={contextValue}>
<h2>In Context</h2>
<ContextCounter1 />
<ContextCounter2 />
<SelfCounter />
</CounterContext.Provider>
</div>
);

主要是因為使用 useContext(context) 時,還是會取到整個 contextValue object,當 contextValue object 內有值變動時,就會 re-render,進而 create 一個新的 contextValue object,就算用了 useMemo 也一樣


詳細的過程如下:



改變 context 的某些 state ,卻造成其他沒有用這個 state,但有用到 Conext 的 components 都會 re-render,這不是我們想要的效果, 從上述的解說,我們也知道原因了,主要就是:

  1. 整個 context object 的參考會一直變動,造成所有使用此 context 的 component 一直 re-render
  2. 無法偵測特定的 context value 是否有變動,進而去決定是否要 re-render



為了解決這個問題,react-redux 就在引用 React 的 Context API 時做了一些優化如下



react-redux 方案:確定監聽值有變後才 re-render

以下我們就來看看怎麼時做一個簡單的 react-redux,來避免 Context 的效能問題,我們主要要建立 3 個 api,分別是:

  1. Provider
  2. useDispatch
  3. useSelector


實現 Provider

主要就是用 Redux store 當作 Context 的 value

  1. 建立一個 ReduxContext
  2. 建立一個 HOC 稱作 Provider,並接收一個 developer 傳入的 store,塞進 ReduxContext 中,作為 Redux Context 的 value

Context

import React, { useContext } from 'react';

const ReduxContext = createContext(null);

function useReduxContext() {
const store = useContext(ReduxContext);

if (!store) {
throw new Error('could not find react-redux context value; please ensure the component is wrapped in a <Provider>')
}

return store;
}


Provider

export function Provider({ store, children }) {
return (
<ReduxContext.Provider value={store}>
{children}
</ReduxContext.Provider>
);
}


實現 useDispatch

取得 ReduxContext 裡面的 store,並回傳 store.dispatch

export function useDispatch() {
const store = useReduxContext();
return store.dispatch;
}


實現 useSelector

react-redux 的重頭戲,實現 useSelector 過程,主要是利用

  1. useReduxContext 取得 store,因為 store 是固定的 reference,故不會造成 re-render
  2. 利用 selector 選取 store 中監聽的 state 部分
  3. 利用 useRef 記住上次監聽的 state,並用當作是否要 re-render 的基準

且接收兩個參數

  1. selector:定義如何從 store state 取值,例如 (state) => state.count
  2. equalityFn:定義 selector 選取的 store state 部分是否有改變


詳細流程如下:



程式碼如下:

const defaultEqualityFn = (prevState, curState) => prevState === curState;

export function useSelector(selector, equalityFn = defaultEqualityFn) {
// 1. 先取出 Redux store
// 2. 用 selector 選取 initialState 監聽的部分,放到 ref 裡作為初始值
// 3. 看看 store 中的 state 有沒有變更,用 shallow compare 跟 prevState 比較是否一樣
// 4. 有變更的話就利用 setState 強制 re-render
// 5. 將 step 4, 5 的步驟放到 updateIfChange function 中,註冊到 redux

const store = useReduxContext();

const selectedState = selector(store.getState());
const prevSelectedState = useRef(selectedState);

const [reRenderTimes, setReRenderTimes] = useState(0);

const forceReRender = () => {
setReRenderTimes((prev) => prev + 1);
}

const updateIfChange = () => {
const newSelectedState = selector(store.getState());

if (equalityFn(prevSelectedState.current, newSelectedState)) {
return;
}

prevSelectedState.current = newSelectedState;
forceReRender();
}

useEffect(() => {
const unsubscribe = store.subscribe(updateIfChange);

return unsubscribe;
}, []);

return selectedState;
}


觸發 re-render 過程




simple react-redux 測試

最終結果如下,可以看到我們真的成功優化了 Context 🥳🥳🥳






結論

  1. Context API 有效能上的問題,主要是一直產生新的 context object 造成的
  2. react-redux 的 useSelector 利用了固定的 redux store reference,和比較 useRef 紀錄的 prevSelectedState,成功避免了不必要的 re-render



參考資源