手把手教你寫一個 React 狀態管理庫

語言: CN / TW / HK

theme: juejin

自從 React Hooks 推行後,Redux 作為狀態管理方案就顯得格格不入了。Dan Abramov 很早就提到過 “You might not need Redux”,開發者必須要寫很多“模式程式碼”,繁瑣以及重複是開發者不願意容忍的。除了 actions/reducers/store 等概念對新手不夠友好之外,最大的缺點就是它對 typescript 型別支援太爛,在大型專案中這是不可接受的。

通過對 Redux 的優缺點總結來看,我們可以自己寫一個狀態管理庫,本次需要達到的目的:

  1. typescript 型別要完善
  2. 足夠簡單,概念要少
  3. React Hooks 要搭配

因此,閱讀此文件的前提要對 React Hookstypescript 等有一定的概念。OK, 那我們開始吧。

思路

目前流行的狀態管理庫很多都太複雜了,夾雜著大量的概念及 API,我們需要規劃著如何實現它。狀態管理也就是狀態提升的極致表現。我們的目的是要足夠簡單, API 少。 思考一下,我們是否可以考慮用 Context 去穿透做管理,用最基本的 useStatehooks 做狀態儲存,那麼就嘗試下吧。

這是三個最簡單的函式式元件 Demo,我們用它來試驗:

```jsx function App() { return ; }

function Card() { return ; }

function CardBody() { return

Text
; } ```

實現

我們定義 Context,一個很基本的狀態模型

```jsx // 描述 Context 的型別 interface IStoreContext { count: number; setCount: React.Dispatch>; increment: () => void; decrement: () => void; }

// 建立一個 Context,無需預設值,這裡為了演示方便,用了斷言 export const StoreContext = React.createContext(undefined as unknown as IStoreContext); ```

以及定義基本的 state,並配合 Context

```jsx function App() { // 定義狀態 const [count, setCount] = React.useState(0); const increment = () => setCount(count + 1); const decrement = () => setCount(count - 1);

// 包裹 Provider,所有子元件都能拿到 context 的值 return ( ); } ```

接下來我們在 CardBody 中使用這個 Context,使其穿透取值

```jsx function CardBody() { // 獲取外層容器中的狀態 const store = React.useContext(StoreContext);

return

Text {store.count}
; } ```

這樣,一個最簡單的穿透狀態管理的程式碼寫好了。發現問題了嗎,狀態的業務邏輯寫在了 App 元件裡,這個程式碼耦合度太高了!我們來整理一下,需要將 App 的狀態通過自定義 hook 抽離出去,保持邏輯與元件的純粹性。

```jsx // 將 App 中的狀態用自定義 hook 管理,邏輯和元件的表現抽離 function useStore() { // 定義狀態 const [count, setCount] = React.useState(0); const increment = () => setCount(count + 1); const decrement = () => setCount(count - 1);

return { count, setCount, increment, decrement, }; } ```

App 中使用這個 hook

```jsx function App() { const store = useStore();

return ( ); } ```

現在來看是不是舒心多了,邏輯在單獨的 hook 中控制,具有高內聚的特點。想想也許還不夠,useStoreStoreContext 的邏輯還不夠內聚,繼續:

useStoreStoreContext.Provider 抽離成一個元件

jsx const Provider: React.FC = ({ children }) => { const store = useStore(); return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>; };

再來看 App 元件,是不是很清晰?

jsx function App() { return ( <StoreProvider> <Card /> </StoreProvider> ); }

好了,我們可以將這個模式封裝成一個方法,通過工廠模式來建立 ContextProvider

```jsx // 將自定義 Hook 通過引數傳入 // 定義泛型描述 Context 形狀 export function createContainer(useHook: (initialState?: State) => Value) { const Context = React.createContext(undefined as unknown as Value);

const Provider: React.FC<{ initialState?: State }> = ({ initialState, children }) => { // 使用外部傳入的 hook const value = useHook(initialState); return {children}; };

return { Provider, Context }; } ```

OK,一個簡單的狀態管理算成型了。好不好用我們來試試,將之前定義的 useStore 的程式碼移入 createContainer

```jsx export const BaseStore = createContainer(() => { // 定義狀態 const [count, setCount] = React.useState(0); const increment = () => setCount(count + 1); const decrement = () => setCount(count - 1);

return { count, setCount, increment, decrement, }; }); ```

App 中替換為 BaseStore 匯出的 Provider

jsx function App() { return ( <BaseStore.Provider> <Card /> </BaseStore.Provider> ); }

CardBody 使用 BaseStore 匯出的 Context,因為定義的時候用了泛型,這裡能完美識別當前 store 的形狀,從而具備編輯器智慧提示

```jsx function CardBody() { const store = React.useContext(BaseStore.Context);

return

Text {store.count}
; } ```

那麼恭喜你,你已經建立了一個屬於自己的狀態管理庫,我們給它取個名字叫 unstated-next

調整

但是方便和效能總是有所取捨的,毫無疑問,成也 Context,敗也 Context。因為它的穿透無差別更新特性也就決定了會讓所有的 React.memo 優化失效。一次 setState 幾乎讓整個專案跟著 rerender ,這是極為不可接受的。因為自定義 Hook 每次執行返回的都是一個全新的物件,那麼 Provider 每次都會接受到這個全新的物件。所有用到這個 Context 的子元件都跟著一起更新,造成無意義的損耗呼叫。

有方案嗎?想一想,辦法總比困難多。我們可以優選 Context 上下文的特性,放棄導致重新渲染的特性(即每次傳給他一個固定引用)。這樣的話狀態改變,該更新的子元件不跟著更新了怎麼辦,有什麼辦法觸發 rerender 呢?答案是 setState,我們可以將 setState 方法提升到 Context 裡,讓容器去排程呼叫更新。

```jsx // createContainer 函式中

// 首先我們可以將 Context 設定為不觸發 render // 這裡 createContext 第二個引數的函式返回值為 0 即為不觸發 render // 注意:這個 API 非正規。當然也可以用 useRef 轉發整個 Context 的值,使其不可變 // 用非正規的 API 僅僅只是為了不用 ref 而少點程式碼 😄 const Context = React.createContext(undefined as unknown as Value, () => 0); ```

那現在 Context 已經是不可變了,該如何實現更新邏輯呢?思路可以是這樣:我們在子元件 mount 時新增一個 listenerContext 中,unMount 時將其移除,Context 有更新時,呼叫這個 listener,使其 rerender

宣告一個 Context,用來放這些子元件的 listener

jsx // createContainer 函式中 const ListenerContext = React.createContext<Set<(value: Value) => void>>(new Set());

現在子元件中需要這樣一個 hook,想選擇 store 裡的某些狀態去使用,無相關的 state 改變不用通知我更新。

那麼我們就起個名字叫 useSelector,用來監聽哪些值變化可以讓本元件 rerender

函式型別可以這樣定義:通過傳入一個函式,來手動指定需要監聽的值,並返回這個值

```jsx // createContainer 函式中

function useSelector(selector: (value: Value) => Selected): Selected {} ```

那我們來實現這個 useSelector 。首先是觸發 rerender 的方法,這裡用 reducer 讓其內部自增,呼叫時不用傳引數

jsx const [, forceUpdate] = React.useReducer((c) => c + 1, 0);

這裡我們需要和容器中的 Context 通訊,從而獲取所有狀態傳給 selector 函式

```jsx // 這裡的 Context 已經不具備觸發更新的特性 const value = React.useContext(Context); const listeners = React.useContext(ListenerContext);

// 呼叫方法獲取選擇的值 const selected = selector(value); ```

建立 listener 函式,通過 Ref 轉發,將選擇後的 state 提供給 listener 函式使用, 讓這個函式能拿到最新的 state

```jsx const StoreValue = { selector, value, selected, }; const ref = React.useRef(StoreValue);

ref.current = StoreValue; ```

實現這個 listener 函式

jsx function listener(nextValue: Value) { try { const refValue = ref.current; // 如果前後對比的值一樣,則不觸發 render if (refValue.value === nextValue) { return; } // 將選擇後的值進行淺對比,一樣則不觸發 render const nextSelected = refValue.selector(nextValue); // if (isShadowEqual(refValue.selected, nextSelected)) { return; } } catch (e) { // ignore } // 執行到這裡,說明值已經變了,觸發 render forceUpdate(); }

我們需要在元件 mount/Unmount 的時候新增/移除 listener

jsx React.useLayoutEffect(() => { listeners.add(listener); return () => { listeners.delete(listener); }; }, []);

完整實現如下:

```jsx function useSelector(selector: (value: Value) => Selected): Selected { const [, forceUpdate] = React.useReducer((c) => c + 1, 0);

const value = React.useContext(Context); const listeners = React.useContext(ListenerContext);

const selected = selector(value);

const StoreValue = { selector, value, selected, }; const ref = React.useRef(StoreValue);

ref.current = StoreValue;

React.useLayoutEffect(() => { function listener(nextValue: Value) { try { const refValue = ref.current; if (refValue.value === nextValue) { return; } const nextSelected = refValue.selector(nextValue); if (isShadowEqual(refValue.selected, nextSelected)) { return; } } catch (e) { // ignore } forceUpdate(); }

listeners.add(listener);
return () => {
  listeners.delete(listener);
};

}, []); return selected; } ```

有了 selector。最後一步,我們來改寫 Provider

```jsx // createContainer 函式中

const Provider: React.FC<{ initialState?: State }> = ({ initialState, children }) => { const value = useHook(initialState); // 使用 Ref,讓 listener Context 不具備觸發更新 const listeners = React.useRef<Set<(listener: Value) => void>>(new Set()).current;

// 每次 useHook 裡面 setState 就會讓本元件更新,使 listeners 觸發呼叫,從而使改變狀態的子元件 render listeners.forEach((listener) => { listener(value); }); return ( {children} ); }; ```

大功告成!useSelector 返回的新物件都會如同 React.memo 一樣做淺對比。API 用法也如同 React-Redux,毫無學習成本。我們來看看用法

```jsx function CardBody() { // count 一旦變化後,本元件觸發 rerender // 這裡如果嫌麻煩,可以使用 lodash 中的 pick 函式 const store = BaseStore.useSelector(({ count, increment }) => ({ count, increment }));

return

Text {store.count}
; } ```

值得注意的是,createContainer 函式中返回出去的值不能是每次 render 都重新生成的。我們來修改一下 BaseStore

```jsx export const BaseStore = createContainer(() => { // 定義狀態 const [count, setCount] = React.useState(0);

// 之前定義的兩個 function 替換為 useMethods 包裹,保證 increment 、decrement 函式引用不變 const methods = useMethods({ increment() { setCount(count + 1); }, decrement() { setCount(count - 1); }, });

return { count, setCount, ...methods, }; }); ```

這裡的 useMethods Hook 就是我之前有篇文章分析過的,用來代替 useCallback,原始碼見 Heo。

錦上添花,可以將 useSelector 結合 lodash.picker 封裝一個更常用的 API,取個名字叫 usePicker

```jsx // createContainer 函式中

function usePicker(selected: Selected[]): Pick { return useSelector((state) => pick(state as Required, selected)); } ```

試試效果:

```jsx function CardBody() { const store = BaseStore.usePicker(['count', 'increment']);

return

Text {store.count}
; } ```

總結

好了,這就是我當時寫一個狀態管理的思路,你學會了嗎?原始碼見 Heo,也是我們正在用的狀態管理,它足夠輕量、配合 Hooks、完美支援 TS、改造原有程式碼的難度小。目前已在生產環境中穩定運行了一年多了,最高複雜度的專案為一次性渲染 2000 多個遞迴結構的元件,效能依然保持得很優秀。歡迎大家 Star。

歡迎關注公眾號 前端星辰,後續會為大家送上各種元件的實現思路,或者歡迎 技術交流

傳送門