React hooks 狀態管理方案解析

雲音樂技術團隊發表於2022-02-18
本文作者:EllieSummer

React v16.8 之後,Function Component 成為主流,React 狀態管理的方案也發生巨大的轉變。Redux 一直作為主流的 React 狀態管理方案,雖然提供了一套規範的狀態管理流程,但卻有著讓人飽受詬病的問題:概念太多、上手成本高、重複的樣板程式碼、需要結合中介軟體使用等。

一個真正易用的狀態管理工具往往不需要過多複雜的概念。Hooks 誕生之後,程式碼優雅簡潔變成一種趨勢。開發者也傾向於用一種小而美、學習成本低的方案來實現狀態管理。因此,除了 React local state hooks 之外,社群還孵化了很多狀態管理庫,如 unstated-next、hox、zustand、jotai 等。

關於狀態管理,有個非常經典的場景:實現一個計數器,點選 + 號的時候將數字加一,點選 - 號的時候將數值減一。這幾乎是所有狀態管理庫標配的入門案例。

本文將從實現「計數器」這個經典場景出發,逐步分析 Hooks 時代下,React 狀態管理方案的演進過程和背後的實現原理。

React local state hooks

React 提供了一些管理狀態的原生 hooks API,簡潔易懂,非常好上手。用原生的 hooks 方法就可以很輕鬆地實現計數器功能,只要通過 useState 方法在根元件定義計數器的狀態和改變狀態的方法,並層層傳遞給子元件就可以了。

原始碼

// timer.js
const Timer = (props) => {
  const { increment, count, decrement } = props;
  return (
    <>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </>
  );
};

// app.js
const App = () => {
    const [count, setCount] = React.useState(0);
    const increment = () => setCount(count + 1);
    const decrement = () => setCount(count - 1);

    return <Timer count={count} increment={increment} decrement={decrement} />
}

但是這種方法存在很嚴重的缺陷。

首先,計數器的業務邏輯和元件耦合嚴重,需要將邏輯進行抽象分離,保持邏輯與元件的純粹性。

其次,多元件內共享狀態是通過層層傳遞的方式實現的,帶來冗餘程式碼的同時,根元件的狀態將會逐漸變成 “龐然大物”。

unstated-next

React 開發者在設計之初,也考慮到上面提到的兩個問題,本身也提供了對應的解決方案。

React Hooks 就是打著「邏輯複用」的口號而誕生的,自定義 hook 可以解決以前在 Class Component 元件內無法靈活共享邏輯的問題。

因此,針對業務邏輯耦合的問題,可以提取一個自定義計數器 hook useCount

function useCount() {
    const [count, setCount] = React.useState(0);
    const increment = () => setCount(count + 1);
    const decrement = () => setCount(count - 1);
    return { count, increment, decrement };
}

為了避免元件間層層傳遞狀態,可以使用 Context 解決方案。Context 提供了在元件之間共享狀態的方法,而不必在樹的每個層級顯式傳遞一個 prop 。

因此,只需要將狀態儲存在 StoreContext 中,Provider 下的任意子元件都可以通過useContext獲取到上下文中的狀態。

// timer.js
import StoreContext from './StoreContext';

const Timer = () => {
    const store = React.useContext(StoreContext);
    // 元件內 render 部分先省略
}

// app.js
const App = () => {
    const StoreContext = React.createContext();
    const store = useCount();

    return <StoreContext.Provider value={store}><Timer /></StoreContext.Provider>
}

這樣程式碼看起來清爽了一些。

但是在使用的時候還是免不了要先定義很多 Context,並且在子元件中進行引用,略微有點繁瑣。

因此,可以對程式碼進行進一步的封裝,將 Context 定義和引用的步驟抽象成公共的方法 createContainer

function createContainer(useHook) {
    // 定義 context
    const StoreContext = React.createContext();
    
    function useContainer() {
        // 子元件引用 context
        const store = React.useContext(StoreContext);
        return store;
    }

    function Provider(props) {
        const store = useHook();

        return <StoreContext.Provider value={store}>{props.children}</StoreContext.Provider>
    }

    return { Provider, useContainer }
}

createContainer 封裝後會返回兩個物件 Provider 和 useContainer。 Provider 元件可以傳遞狀態給子元件,子元件可以通過 useContainer 方法獲取全域性的狀態。經過改造,元件內的程式碼就會變得非常精簡。

const Store = createContainer(useCount);

// timer.js
const Timer = () => {
    const store = Store.useContainer();
    // 元件內 render 部分先省略
}

// app.js
const App = () => {
    return <Store.Provider><Timer /></Store.Provider>
}

這樣,一個基本的狀態管理方案成型了!體積小,API 簡單,可以說是 React 狀態管理庫的最小集。原始碼可以見這裡

這種方案也是狀態管理庫 unstated-next 的實現原理。

hox

先不要高興得太早。unstated-next 的方案雖好,但也有缺陷的,這也是 React context 廣為人詬病的兩個問題:

  • Context 需要巢狀 Provider 元件,一旦程式碼中使用多個 context,將會造成巢狀地獄,元件的可讀性和純粹性會直線降低,從而導致元件重用更加困難。
  • Context 可能會造成不必要的渲染。一旦 context 裡的 value 發生改變,任何引用 context 的子元件都會被更新。

那有沒有什麼方法可以解決上面兩個問題呢?答案是肯定的,目前已經有一些自定義狀態管理庫解決這兩個問題了。

從 context 的解決方案裡,其實可以得到一些啟發。狀態管理的流程可以簡化成三個模型: Store(儲存所有狀態)、Hook (抽象公共邏輯,更改狀態)、Component(使用狀態的元件)。

如果要自定義狀態管理庫,在腦海中可以先構思下, 這三者之前的關係應該是怎麼樣的?

https://p6.music.126.net/obj/wonDlsKUwrLClGjCm8Kx/13025055875/731a/c9f6/165e/1109e9e6858bdad5025bfb206584f0b7.png

  • 訂閱更新: 初始化執行 Hook 的時候,需要收集哪些 Component 使用了 Store
  • 感知變更: Hook 中的行為能夠改變 Store 的狀態,也要能被 Store 所感知到
  • 釋出更新: Store 一旦變更,需要驅動所有訂閱更新的 Component 更新

只要完成這三步,狀態管理基本上就完成了。大致思路有了,下面就可以具體實現了。

狀態初始化

首先需要初始化 Store 的狀態,也就是 Hook 方法執行返回的結果。同時定義一個 API 方法,供子元件獲取 Store 的狀態。這樣狀態管理庫的模型就搭出來了。

從業務程式碼使用方法上可以看出,API 簡潔的同時,也避免了 Provider 元件巢狀。

// 狀態管理庫的框架
function createContainer(hook) {
    const store = hook();
    // 提供給子元件的 API 方法
    function useContainer() {
        const storeRef = useRef(store);
        return storeRef.current;
    }
    return useContainer;
}

// 業務程式碼使用:API簡潔
const useContainer = createContainer(useCount);

const Timer = () => {
    const store = useContainer();
    // 元件內 render 部分先省略
}

訂閱更新

為了實現 Store 狀態更新的時候,能夠驅動元件更新。需要定義一個 listeners 集合,在元件初始化的時候往陣列新增 listener 回撥,訂閱狀態的更新。

function createContainer(hook){
    const store = hook();

    const listeners = new Set();    // 定義回撥集合
    
    function useContainer() {
        const storeRef = useRef(store);
    
        useEffect(() => {
            listeners.add(listener);  // 初始化的時候新增回撥,訂閱更新
            
            return () =>  listeners.delete(listener) // 元件銷燬的時候移除回撥
        },[])
        return storeRef.current;
    }

    return useContainer;
}

那麼當狀態更新後,如何驅動元件更新呢? 這裡可以利用 useReducer hook 定義一個自增函式,使用 forceUpdate 方法即可讓元件重刷。

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

function listener(newStore) {
    forceUpdate();
    storeRef.current = newStore;
}

感知狀態變更

狀態變更驅動元件更新的部分已經完成。現在比較重要的問題是,如何感知到狀態發生變更了呢?

狀態變更是在 useCount Hook 函式內實現的,用的是 React 原生的 setState 方法,也只能在 React 元件內執行。因此,很容易想到,如果使用一個函式元件 Executor 引用這個 Hook,那麼在這個元件內就可以初始化狀態,並感知狀態變更了。

考慮到狀態管理庫的通用性,可以通過 react-reconciler 構造一個 react 渲染器來掛載 Executor 元件,這樣就可以分別支援 React、ReactNative 等不同框架。

// 構造 react 渲染器
function render(reactElement: ReactElement) {
  const container = reconciler.createContainer(null, 0, false, null);
  return reconciler.updateContainer(reactElement, container);
}

// react 元件,感知 hook 內狀態的變更
const Executor = (props) => {
    const store = props.hook();
    const mountRef = useRef(false);
    
    // 狀態初始化
    if (!mountRef.current) {
        props.onMount(store);
        mountRef.current = true;
    }

    // store 一旦變更,就會執行 useEffect 回撥
    useEffect(() => {
        props.onUpdate(store); // 一旦狀態變更,通知依賴的元件更新
    });

    return null;
};
function createContainer(hook) {
    let store;
    const onUpdate = () => {};

    // 傳遞hook和更新的回撥函式        
    render(<Executor hook={hook} onMount={val => store = val}  onUpdate={onUpdate} />);

    function useContainer() {}
    return useContainer;
}

精確更新

一旦感知到狀態變更後,在 onUpdate 回撥裡可以通知之前訂閱過更新的元件重新渲染,也就是遍歷 listeners 集合,執行之前新增的更新回撥。

const onUpdate = (store) => {
    for (const listener of listeners) {
      listener(store);
    }
}

但是,元件往往可能只依賴了 Store 裡的某一個狀態,所有元件都更新的操作太粗暴,會帶來不必要的更新,需要進行精確的更新渲染。因此,可以在元件的更新回撥裡判斷當前依賴的狀態是否變化,從而決定是否觸發更新。

// useContainer API 擴充套件增加依賴屬性
const store = useContainer('count'); // 元件僅依賴store.count值

// 更新回撥裡判斷
function listener(newStore) {
    const newValue = newStore[dep];          
    const oldValue = storeRef.current[dep];

    // 僅僅在依賴發生變更,才會元件進行更新
    if (compare(newValue, oldValue)) {
        forceUpdate();
    }
    storeRef.current = newStore;
}

完成以上的步驟,一個簡單又好用的狀態管理庫就實現啦!原始碼可以看這裡
狀態更新的流程如下圖所示。

API 簡潔,邏輯和 UI 分離,能跨元件傳輸狀態,沒有冗餘的巢狀元件,並且能實現精確更新。

這也是狀態管理庫 hox 背後的實現原理。

zustand

關於如何感知狀態變更這一節中,因為 useCount 函式中是通過操作 react 原生 hook 方法實現狀態變更的,所以我們需要用 Executor 作為中間橋樑來感知狀態變更。

但是,這其實是一種委屈求全的方案,不得已將方案複雜化了。試想下,如果變更狀態的方法 setState 是由狀態管理庫自身提供的,那麼一旦執行該方法,就可以感知狀態變更,並觸發後續的比較更新操作,整體流程會簡單很多!

// 將改變狀態的 setState 方法傳遞給 hook
// hook內一旦執行該方法,即可感知狀態變更,拿到最新的狀態
function useCount(setState) {
  const increment = () => setState((state) => ({ ...state, count: state.count + 1 }));
  const decrement = () => setState((state) => ({ ...state, count: state.count - 1 }));
  return { count: 0, increment, decrement };
}
function createContainer(hook) {
    let store;
    
    const setState = (partial) => {
        const nexStore = partial(store);
        // hook中一旦執行 setState 的操作,且狀態變更後,將觸發 onUpdate 更新
        if(nexStore !== store){
            store = Object.assign({}, store, nexStore);
            onUpdate(store);
        }
    };
    // 將改變狀態的方法 setState 傳遞給hook函式
    store = hook(setState);
}

const useContainer = createContainer(useCount);

這種方案更加高明,讓狀態管理庫的實現更加簡潔明瞭,庫的體積也會小不少。原始碼可見這裡

這種方案是 zustand 背後的大致原理。雖然需要開發者先熟悉下對應的寫法,但是 API 與 Hooks 類似,學習成本很低,上手容易。

總結

本文從實現一個計數器場景出發,闡述了多種狀態管理的方案和具體實現。不同狀態管理方案產生都有著各自的背景,也有著各自的優劣。

但是自定義狀態管理庫的設計思想都是差不多的。目前開源社群比較活躍的狀態管理庫大多是如此,不同點主要是在如何感知狀態變更這塊做些文章。

看完本文,想必你已經知道如何進行 React Hooks 下的狀態管理了,那就趕緊行動吧!

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術團隊,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe (at) corp.netease.com!

相關文章