[譯]開發類 redux 庫來理解狀態管理

xiaoweiy發表於2019-05-13

原文地址

對於想要跳過文章直接看結果的人,我已經把我寫的內容製作成了一個庫:use-simple-state,無任何依賴(除了依賴 react ),只有3kb,相當輕量。

  • 近幾年,應我們的 app 增長的需要,web 應用數量增長迅速,隨之而來的還有複雜性。為了使增加的複雜性易於處理,應用某些新增的技巧和模式使得開發者可以更簡單的處理以及幫助我們建立更加健壯的應用。
  • 其中一個複雜性增長的主要領域是管理我們應用的狀態,因此為了避免這種複雜性,開發者使用了包含更新和獲取 app 狀態的庫。最著名的例子是 redux,它是 flux 模式的一種應用。
  • 一旦開發者開始學習使用像 redux 的庫,他們可能不太瞭解庫的內部執行機制,因為一開始這並不明顯,即使很容易瞭解到這是更新一個全域性可用的物件這樣的一個概念。
  • 在這篇文章中,我們將會從零開始為 react 應用建立一個我們自己的狀態管理解決方案。我們的解決方案最初只有幾行程式碼,逐漸增加更高階的特性,最終將類似於 redux。

基本概念

  • 任何狀態管理工具只需要兩件東西:對整個應用都可用的全域性狀態,和讀取以及更新它的能力。只有這些,真的。
  • 這裡展示一個狀態管理的簡單例子:
const state = {};

export const getState = () => state;

export const setState = nextState => {
  state = nextState;
};
複製程式碼
  • 上面的例子已經儘可能的簡單,但它仍然包含了所有的要素:

一個全域性可用的用於展現我們應用狀態的值:state;

讀取狀態的能力:getState;

更新狀態的能力:setState。

  • 上面的例子對於我們真實應用來說太過簡單,因此接下來我們將要構建一個能讓 react 可用的解決方案。首先我們來重構我們的例子,以讓它在 react 中可用。

react 狀態管理

  • 為了製作一個我們之前解決方案的 react 版本,我們需要應用兩個 react 功能。第一個功能是普通經典類元件,也就是眾所周知的有狀態元件。
  • 第二個功能是 context API,它可以讓資料在整個 react 應用可用。context 有兩部分:provider (生產者) 和 consumer (消費者),provider 就像它的名字所說的那樣,給應用提供 context (data 資料),消費者意指當我們讀取 context 時,我們就是消費者。
  • 可以這樣理解 context:如果說 props 是顯式的傳送資料,那麼 context 就是隱式的傳送資料。

建造我們自己的狀態管理器

  • 現在我們知道了需要哪些工具,現在只要把它們合在一起就可以了。我們準備建立一個上下文環境來存放全域性狀態,然後把它的 provider 包裹在一個有狀態元件中,然後用 provider 來管理狀態。

  • 首先,我們使用 React.createContext 來建立上下文,它可以給我們提供 provider 和 consumer。

  • import { createContext } from 'react';
    
    const { Provider, Consumer } = createContext();
    複製程式碼
  • 接下來我們需要用有狀態元件包裹我們的 provider,利用它進行應用狀態的管理。我們也應該把 consumer 匯出為一個更加準確的名稱。

  • import React, { Component, createContext } from 'react';
    
    const { Provider, Consumer } = createContext();
    
    export const StateConsumer = Consumer;
    
    export class StateProvider extends Component {
      static defaultProps = {
        state: {}
      };
    
      state = this.props.state;
    
      render () {
        return (
          <Provider value={this.state}>
            {this.props.children}
          </Provider>
        );
      }
    }
    複製程式碼
  • 在上面的例子中,StateProvider 是接收一個 state 來作為初始狀態的元件,並且使元件樹中當前元件下面的任何元件都可以訪問到這個屬性。如果沒有提供 state,預設會有一個空物件代替。

  • 用我們的 StateProvider 包裹住根元件:

  • import { StateProvider } from './stateContext';
    import MyApp from './MyApp';
    
    const initialState = {
      count: 0
    };
    
    export default function Root () {
      return (
        <StateProvider state={initialState}>
          <MyApp />
        </StateProvider>
      );
    }
    複製程式碼
  • 在我們完成上述程式碼之後,就可以作為一個消費者從 MyApp 的任何地方獲得應用的狀態。在這裡我們會初始化我們的狀態為一個有一個 count 屬性的物件,所以無論什麼時候我們想要立即獲取應用的狀態,我們就可以從這裡獲得。

  • 消費者使用 render 屬性 來傳遞上下文資料,我們可以通過下面的一個函式作為 StateConsumer 的子元件的例子來檢視。state 引數傳遞給函式用以展現我們應用的當前狀態,作為我們的初始狀態,state.count 為 0.

  • import { StateConsumer } from './stateContext';
    
    export default function SomeCount () {
      return (
        <StateConsumer>
          {state => (
            <p>
              Count: {state.count}
            </p>
          )}
        </StateConsumer>
      );
    }
    複製程式碼
  • 關於 StateConsumer 我們需要知道的很重要一點是在上下文中它會自動訂閱狀態的改變,因此當我們的狀態改變後會重新渲染以顯示更新。這就是消費者的預設行為,我們暫時還沒做能夠用到這個特性的功能。

更新狀態

  • 目前為止我們已經可以讀取應用的狀態,以及在狀態改變時自動更新。現在我們需要一種更新狀態的方法,為了做到這一點我們僅僅只需要在 StateProvider 裡面更新狀態。

  • 你之前可能已經注意到了,我們給 StateProvider 傳遞了一個 state 屬性,也就是之後會傳遞給元件的 state 屬性。我們將使用 react 內建的 this.setState 來更新:

  • export class StateProvider extends Component {
      static defaultProps = {
        state: {}
      };
    
      state = this.props.state;
    
      render () {
        return (
          <Provider value={{ state: this.state, setState: this.setState.bind(this) }}>
            {this.props.children}
          </Provider>
        );
      }
    複製程式碼
  • 繼續保持簡單的風格,我們只給上下文傳遞 this.setState,這意味著我們需要稍微改變我們的上下文傳值,不只是傳遞 this.state,我們現在同時傳遞 statesetState

  • 當我們用 StateConsumer 時可以用解構賦值獲取 statesetState,然後我們就可以讀寫我們的狀態物件了:

  • export default function SomeCount () {
      return (
        <StateConsumer>
          {({ state, setState }) => (
            <>
              <p>
                Count: {state.count}
              </p>
              <button onClick={() => setState({ count: state.count + 1 })}>
                + 1
              </button>
              <button onClick={() => setState({ count: state.count - 1 })}>
                - 1
              </button>
            </>
          )}
        </StateConsumer>
      );
    }
    複製程式碼
  • 有一點要注意的是由於我們傳遞了 react 內建的 this.setState 作為我們的 setState 方法,新增的屬性將會和已有的狀態合併。這意味著如果我們有 count 以外的第二個屬性,它也會被自動儲存。

  • 現在我們的作品已經可以用在真實專案中了(儘管還不是很有效率)。對 react 開發者來說應該會覺得 API 很熟悉,由於使用了內建的工具因此我們沒有引用任何新的依賴項。假如之前覺得狀態管理有點神奇,希望現在我們多少能夠了解它內部的結構。

華麗的點綴

  • 熟悉 redux 的人可能會注意到我們的解決方案缺少一些特性:

    • 沒有內建的處理副作用的方法,你需要通過 redux 中介軟體來做這件事
    • 我們的 setState 依賴 react 預設的 this.setState 來處理我們的狀態更新邏輯,當使用內聯方式更新複雜狀態時將可能引發混亂,同時也沒有內建的方法來複用狀態更新邏輯,也就是 redux reducer 提供的功能。
    • 也沒有辦法處理非同步的操作,通常由 redux thunk 或者 redux saga等庫來提供解決辦法。
    • 最關鍵的是,我們沒辦法讓消費者只訂閱部分狀態,這意味著只要狀態的任何部分更新都會讓每個消費者更新。
  • 為了解決這些問題,我們模仿 redux 來應用我們自己的 actionsreducers,和 middleware。我們也會為非同步 actions 增加內在支援。之後我們將會讓消費者只監聽狀態內的子狀態的改變。最後我們來看看如何重構我們的程式碼以使用新的 hooks api

redux 簡介

免責宣告:接下來的內容只是為了讓你更容易理解文章,我強烈推薦你閱讀 redux 官方完整的介紹。

如果你已經非常瞭解 redux,那你可以跳過這部分。

  • 下面是一個 redux 應用的資料流簡化流程圖:

redux-data-flow

  • 如你所見,這就是單向資料流,從我們的 reducers 接收到狀態改變之後,觸發 actions,資料不會回傳,也不會在應用的不同部分來回流動。

  • 說的更詳細一點:

  • 首先,我們觸發一個描述改變狀態的 action,例如 dispatch({ type: INCREMENT_BY_ONE }) 來加1,同我們之前不同,之前我們是通過 setState({ count: count + 1 })來直接改變狀態。

  • action 隨後進入我們的中介軟體,redux 中介軟體是可選的,用於處理 action 副作用,並將結果返回給 action,例如,假如在 action 到達 reducer 之前觸發一個 SIGN_OUT 的 action 用於從本地儲存裡刪除所有使用者資料。如果你熟悉的話,這有些類似於 express 中介軟體的概念。

  • 最後,我們的 action 到達了接收它的 reducer,伴隨而來的還有資料,然後利用它和已有的狀態合併生成一個新的狀態。讓我們觸發一個叫做 ADD 的 action,同時把我們想傳送過去增加到狀態的值也傳送過去(叫做 payload )。我們的 reducer 會查詢叫做 ADD 的 action,當它發現後就會將 payload 裡面的值和我們現有的狀態裡的值加到一起並返回新的狀態。

  • reducer 的函式如下所示:

  • (state, action) => nextState
    複製程式碼
  • reducer 應當只是處理 state 和 action ,雖然簡單卻很強大。關鍵是要知道 reducer 應當永遠是純函式,這樣它們的結果就永遠是確定的。

actions + dispatch

  • 現在我們已經過了幾個 redux app 的關鍵部分,我們需要修改 app 來模仿一些類似的行為。首先,我們需要一些 actions 和觸發它們的方法。

  • 我們的 action 會使用 action 建立器來建立,它們其實就是能生成 action 的簡單函式,action 建立器使得測試,複用,傳遞 payload 資料更加簡單,我們也會建立一些 action type,其實就是字串常量,為了讓他們可以被 reducer 複用,因此我們把它儲存到變數裡:

  • // Action types
    const ADD_ONE = 'ADD_ONE';
    const ADD_N = 'ADD_N';
    
    // Actions
    export const addOne = () => ({ type: ADD_ONE });
    export const addN = amount => ({ type: ADD_N, payload: amount });
    複製程式碼
  • 現在我們來做一個 dispatch 的佔位符函式,我們的佔位符只是一個空函式,將會被用於替換上下文中的 setState 函式,我們一會再回到這兒,因為我們還沒做接收 action 的 reducer 呢。

  • export class Provider extends React.PureComponent {
      static defaultProps = {
        state: {}
      };
    
      state = this.props.state;
    
      _dispatch = action => {};
    
      render () {
        return (
          <StateContext.Provider value={{ state: this.state, dispatch: this._dispatch }}>
            {this.props.children}
          </StateContext.Provider>
        );
      }
    }
    複製程式碼

reducers

  • 現在我們已經有了一些 action,只需要一些 reducer 來接收就好了。回到之前的 reducer 函式標記,它只是一個關於 action 和 state 的純函式:

  • (state, action) => nextState
    複製程式碼
  • 知道了這個,我們只需要傳遞元件的狀態,然後在 reducer 裡觸發 action。對 reducer 來說,我們只想要一個對應上面標記的函式陣列。我們之所以使用一個陣列是因為可以使用陣列的 Array.reduce 方法來迭代陣列,最終生成我們的新狀態:

  • export class Provider extends React.PureComponent {
      static defaultProps = {
        state: {},
        reducers: []
      };
    
      state = this.props.state;
    
      _dispatch = action => {
        const { reducers } = this.props;
        const nextState = reducers.reduce((state, reducer) => {
          return reducer(state, action) || state;
        }, this.state);
    
        this.setState(nextState);
      };
    
      render () {
        return (
          <StateContext.Provider value={{ state: this.state, dispatch: this._dispatch }}>
            {this.props.children}
          </StateContext.Provider>
        );
      }
    }
    複製程式碼
  • 如你所見,我們所做的就是使用 reducer 來計算並獲得新狀態,然後就像之前所做的,我們呼叫 this.setState 來更新 StateProvider 元件的狀態。

  • 現在我們只需要一個實際的 reducer:

  • function countReducer ({ count, ...state }, { type, payload }) {
      switch (type) {
        case ADD_N:
          return { ...state, count: count + payload };
        case ADD_ONE:
          return { ...state, count: count + 1 };
      }
    }
    複製程式碼
  • 我們的 reducer 只是檢查傳入的 action.type,然後假如匹配到之後將會更新相對應的狀態,否則就會在經過 switch 判斷語句之後返回函式預設的 undefined。我們的 reducer 和 redux 的 reducer 的一個重要的區別在當我們不想更新狀態時,一般情況下我們會因為未匹配到 action type 而返回一個falsy 值,而 redux 則會返回未變化的狀態。

  • 然後把我們的 reducer 傳進 StateProvider:

  • export default function Root () {
      return (
        <StateProvider state={initialState} reducers={[countReducer]}>
          <MyApp />
        </StateProvider>
      );
    }
    複製程式碼
  • 現在我們終於可以觸發一些 action,然後就會觀察到相對應的狀態更新:

  • export default function SomeCount () {
      return (
        <StateConsumer>
          {({ state, dispatch }) => (
            <>
              <p>
                Count: {state.count}
              </p>
              <button onClick={() => dispatch(addOne())}>
                + 1
              </button>
              <button onClick={() => dispatch(addN(5))}>
                + 5
              </button>
              <button onClick={() => dispatch(addN(10))}>
                + 10
              </button>
            </>
          )}
        </StateConsumer>
      );
    
    複製程式碼

中介軟體

  • 現在我們的作品已經跟 redux 比較像了,只需要再增加一個處理副作用的方法就可以。為了達到這個目的,我們需要允許使用者傳遞中介軟體函式,這樣當 action 被觸發時就會被呼叫了。

  • 我們也想讓中介軟體函式幫助我們處理狀態更新,因此假如返回的 null 就不會被 action 傳遞給 reducer。redux 的處理稍微不同,在 redux 中介軟體你需要手動傳遞 action 到下一個緊鄰的中介軟體,假如沒有使用 redux 的 next 函式來傳遞,action 將不會到達 reducer,而且狀態也不會更新。

  • 現在讓我們寫一個簡單的中介軟體,我們想通過它來尋找 ADD_N action,如果它找到了那就應當把 payload 和當前狀態裡面的 count 加和並輸出,但是阻止實際狀態的更新。

function countMiddleware ({ type, payload }, { count }) {
  if (type === ADD_N) {
    console.log(`${payload} + ${count} = ${payload + count}`);
    return null;
  }
}
複製程式碼
  • 跟我們的 reducer 類似,我們會將中介軟體用陣列傳進我們的 StateProvider

  • export default function Root () {
      return (
        <StateProvider
          state={initialState}
          reducers={[countReducer]}
          middleware={[countMiddleware]}
        >
          <MyApp />
        </StateProvider>
      );
    }
    複製程式碼
  • 最終我們會呼叫所有所有中介軟體,然後根據返回的結果決定是否應當阻止更新。由於我們傳進了一個陣列,然而我們需要的是一個單個值,因此我們準備使用 Array.reduce 來獲得我們的值。跟 reducer 類似,我們也會迭代陣列依次呼叫每個函式,然後將結果賦值給一個變數 continueUpdate

  • 由於中介軟體被認為是一個高階特性,因此我們不想它變成強制性的,因此如果沒有在StateProvider 裡面找到 middleware 屬性,我們會將 continueUpdate 置為預設的 undefined。我們也會增加一個 middleware 陣列來作預設屬性,這樣的話 middleware.reduce 就不會因為沒傳東西而丟擲錯誤。

  • export class StateProvider extends React.PureComponent {
      static defaultProps = {
        state: {},
        reducers: [],
        middleware: []
      };
    
      state = this.props.state;
    
      _dispatch = action => {
        const { reducers, middleware } = this.props;
        const continueUpdate = middleware.reduce((result, middleware) => {
          return result !== null ? middleware(action, this.state) : result;
        }, undefined);
    
        if (continueUpdate !== null) {
          const nextState = reducers.reduce((state, reducer) => {
            return reducer(state, action) || state;
          }, this.state);
    
          this.setState(nextState);
        }
      };
    
      render () {
        return (
          <StateContext.Provider value={{ state: this.state, dispatch: this._dispatch }}>
            {this.props.children}
          </StateContext.Provider>
        );
      }
    }
    複製程式碼
  • 如你所見在第13行,我們會檢視中介軟體函式的返回值。如果返回 null 我們就會跳過剩下的中介軟體函式,continueUpdate 將為 null,意味著我們會中斷更新。

非同步 action

  • 因為我們想讓我們的狀態管理器對真實生產環境有用,所以我們需要增加對非同步 action 的支援,這意味著我們將可以處理像網路請求類似案例的通用任務。我們借鑑下 Redux Thunk ,因為它的 API 很簡單,直觀而且有效。

  • 我們所要做的就是檢查是否有為被呼叫的函式被傳遞到 dispatch,如果找到的話我們就會在傳遞 dispatchstate 時呼叫函式,這樣就可以給使用者所寫的非同步 action 執行的機會。拿這個授權 action 作為例子來看下:

  • const logIn = (email, password) => async dispatch => {
      dispatch({ type: 'LOG_IN_REQUEST' });
    
      try {
        const user = api.authenticateUser(email, password);
        dispatch({ type: 'LOG_IN_SUCCESS', payload: user });
      catch (error) {
        dispatch({ type: 'LOG_IN_ERROR', payload: error });
      }
    };
    複製程式碼
  • 在上面的例子中我們寫了一個叫做 logIn 的 action 建立器,不是返回一個物件,它返回一個接收 dispatch 的函式,這可以讓使用者在一個非同步 API 請求的前面和後面觸發非同步 action,根據 API 不同的返回結果觸發不同的 action,這裡我們在發生錯誤時傳送一個錯誤 action。

  • 做到這一點只需要在 StateProvider 裡的 _dispatch 方法裡檢查 action 的型別是不是 function:

  • export class StateProvider extends React.PureComponent {
      static defaultProps = {
        state: {},
        reducers: [],
        middleware: []
      };
    
      state = this.props.state;
    
      _dispatch = action => {
        if (typeof action === 'function') {
          return action(this._dispatch, this.state);
        }
        
        const { reducers, middleware } = this.props;
        const continueUpdate = middleware.reduce((result, middleware) => {
          return result !== null ? middleware(action, this.state) : result;
        }, undefined);
    
        if (continueUpdate !== null) {
          const nextState = reducers.reduce((state, reducer) => {
            return reducer(state, action) || state;
          }, this.state);
    
          this.setState(nextState);
        }
      };
    
      render () {
        return (
          <StateContext.Provider value={{ state: this.state, dispatch: this._dispatch }}>
            {this.props.children}
          </StateContext.Provider>
        );
      }
    }
    複製程式碼
  • 這裡需要注意兩點:我們呼叫 action 為函式,傳入 this.state,這樣使用者可以訪問非同步 action 中已有的狀態,我們也會返回函式呼叫的結果,允許開發者從他們的非同步 action 中獲得一個返回值從而開啟更多的可能性,例如從 dispatch 觸發的 promise 鏈。

避免不必要的重新渲染

  • Redux 的一個經常被忽視的必要特性是它能在必須時才會對元件重新渲染(或者更準確的說是 React-Redux  — react 跟 redux 的繫結)。為了做到這一點,它使用了 connect 高階元件,它提供了一個對映函式 — mapStateToProps — 僅僅只在關聯的 mapStateToProps 的輸出改變時(只對映從現在開始的狀態)才會觸發元件的重新渲染。如果不這樣的話,那麼每次狀態更新都會讓元件使用 connect 來訂閱儲存改變然後重新渲染。

  • 想想我們需要做的,我們需要一種方法來儲存 mapState 前面的輸出,這樣我們就可以比較兩者看看有沒有差異來決定我們是否需要繼續向前和重新渲染我們的元件。為了做到這一點我們需要使用一種叫做記憶化的程式,跟我們這行的許多事情一樣,對於一個想到簡單的程式來說,這是一個重要的詞,尤其是我們可以使用 React.Component 來儲存我們狀態的子狀態,然後僅在我們檢測到 mapState 的輸出改變之後再更新。

  • 接下來我們需要一種能夠跳過不必要的元件更新的方法。react 提供了一個生命週期方法 shouldComponentUpdate 可以讓我們達到目的。它將接收到的 props 和 state 當做引數來讓我們同現有的 props 和 state 進行比較,如果我們返回 true 那麼更新繼續,如果返回 false 那麼 react 將會跳過渲染。

  • class ConnectState extends React.Component {
      state = this.props.mapState(this.props.state);
    
      static getDerivedStateFromProps (nextProps, nextState) {}
    
      shouldComponentUpdate (nextProps) {
        if (!Object.keys(this.state).length) {
          this.setState(this.props.mapDispatch(this.props.state));
          return true;
        }
    
        console.log({
          s: this.state,
          nextState: nextProps.mapState(nextProps.state),
          state: this.props.mapState(this.state)
        });
        
        return false;
      }
    
      render () {
        return this.props.children({ state: this.props.state, dispatch: this.props.dispatch });
      }
    }
    
    export function StateConsumer ({ mapState, mapDispatch, children }) {
      return (
        <StateContext.Consumer>
          {({ state, dispatch }) => (
            <ConnectState state={state} dispatch={dispatch} mapState={mapState} mapDispatch={mapDispatch}>
              {children}
            </ConnectState>
          )}
        </StateContext.Consumer>
      );
    }
    複製程式碼
  • 上面只是對我們接下來要做的事情的概述。它有了所以主要的部分:從我們的 context 接收更新,它使用了 getDerivedStateFromPropsshouldComponentUpdate,它也接收一個 render 屬性來作為子元件,就像預設的消費者一樣。我們也會通過使用傳遞的 mapState 函式來初始化我們的消費者初始狀態。

  • 現在這樣的話,shouldComponentUpdate 將只會在接收到第一次狀態更新之後渲染一次。之後它會記錄傳進的狀態和現有的狀態,然後返回 false,阻止任何更新。

  • 上面的解決方案中在 shouldComponentUpdate 內部也呼叫了 this.setState,而我們都知道 this.setState 總是會觸發重新渲染。由於我們也會從 shouldComponentUpdate 裡返回 true,這會產生一次額外的重新渲染,所以為了解決這個問題,我們將使用生命週期 getDerivedStateFromProps 來獲取我們的狀態,然後我們再使用 shouldComponentUpdate 來決定基於我們獲取的狀態是否繼續更新程式。

  • 如果我們檢查控制檯也可以看到全域性的狀態更新,同時我們的元件阻止任何更新到 this.state 物件以至元件跳過更新:

  • redux-like-state-update

  • 現在我們知道了如何阻止不必要的更新,我們還需要一個可以智慧的決定我們的消費者何時應當更新的方法。如果我們想要遞迴迴圈傳進來的 state 物件來檢視每個屬性來看狀態是否有改變,但是這對於幫助我們理解有幫助卻對效能不利。我們沒辦法知道傳進來的 state 物件層級有多深或者多複雜,如果條件永遠不滿足,那麼遞迴函式將會無限期的執行下去,因此我們準備限制我們比較的作用域。

  • 跟 redux 類似,我們準備使用一個比較函式。在這裡意味著我們在比較我們的物件是否相等的屬性的層級,意味著我們只會比較一層。因此我們將會檢查我們每個新狀態的頂層屬性是否等於我們現有狀態的同名屬性,如果同名屬性不存在,或者它們的值不同,我們將會繼續渲染,否則我們就認為我們的狀態時相同的,然後阻止渲染。

  • function shallowCompare (state, nextState) {
      if ((typeof state !== 'object' || state === null || typeof nextState !== 'object' || nextState === null)) return false;
    
      return Object.entries(nextState).reduce((shouldUpdate, [key, value]) => state[key] !== value ? true : shouldUpdate, false);
    }
    複製程式碼
  • 首先我們從檢查是否兩個 state 都是物件開始,如果不是那麼我們就跳過渲染。在初始檢查之後我們把現有的狀態轉化為一個鍵值對的陣列,並通過將陣列減少為單個布林值來檢查每個屬性的值與傳進來的 state 物件的值。

  • 這是困難的部分,現在我們想用我們的 shallowCompare 函式,實際上只是呼叫並檢查結果。如果它返回 true,我們就返回 true 來允許元件重新渲染,否則我們就返回 false 來跳過更新(然後我們獲得的狀態被放棄掉)。我們也想在 mapDispatch 存在的時候呼叫它。

  • class ConnectState extends React.Component {
      state = {};
    
      static getDerivedStateFromProps ({ state, mapState = s => s }) {
        return mapState(state);
      }
    
      shouldComponentUpdate (nextProps, nextState) {
        return shallowCompare(this.state, nextState);
      }
    
      render () {
        return this.props.children({
          state: this.state,
          dispatch: this.props.mapDispatch ? this.props.mapDispatch(this.props.dispatch) : this.props.dispatch
        });
      }
    }
    複製程式碼
  • 最後我們需要傳遞一個 mapState 函式讓我們消費者以只匹配我們的部分狀態,這樣我們就會將它作為一個屬性傳給我們的 StateConsumer

  • return (
      <StateConsumer mapState={state => ({ greeting: state.greeting })} mapDispatch={dispatch => dispatch}>
        // ...
    複製程式碼
  • 現在我們只訂閱 greeting 裡面的改變,因此假如我們更新了元件裡的 count 將會被我們的全域性狀態改變所忽略並且避免了一次重新渲染。

快速回顧

  • 如果你已經做到了這一步,你就已經見到了如何開發一個帶有 reducer 和 action 的 類 redux 的狀態管理庫。我們也覆蓋了更高階的特性,例如非同步 action,中介軟體,以及如何讓我們只接收我們想要的狀態更新,從而避免我們的消費者每次全域性狀態更新進而引起的重新渲染。
  • 儘管 redux 其實做的比我們的解決方案要多得多,希望這個方案有助於澄清一些核心概念,而 redux 通常被認為是一個更加高階的特性,但它的實現其實相對簡單。
  • 想要對 redux 的內部瞭解更加徹底,我強烈推薦你閱讀它在 github 的原始碼
  • 我們目前為止的解決方案已經有了真實專案所必須的工具和特性了。我們可以在一個 react 專案中使用它,不需要使用 redux,除非我們想要接入一些真正高階的功能。

Hooks

  • 如果你還沒聽過它,它正在快速變成 react 的下一個大特性。這裡有一段來自官方描述的簡單解釋:

    Hooks 是一個讓你不必寫 class 就可以使用 state 和 react 其他特性的新功能。

  • hooks 提供給我們高階元件的所有能力,以及更加清晰和直觀的 API 來渲染屬性。

  • 我們來看一個使用基本的 useState 鉤子的例子來看看它們是如何工作的:

    import React, { useState } from 'react';
    
    function Counter () {
      const [count, setCount] = useState(0);
      return (
        <>
          <span>
            Count: {count}
          </span>
          <button onClick={() => setCount(count + 1)}>
            Increment
          </button>
        </>
      );
    }
    複製程式碼
  • 在上面的例子中,我們通過給 useState 傳遞一個 0 來初始化新狀態,它會返回我們的狀態:count,以及一個更新函式 setCount。如果你以前沒見過的話,可能會奇怪 useState 是如何不在每次渲染時初始化,這是因為 react 在內部處理了,因此我們無需擔心這一點。

  • 讓我們暫時先忘掉中介軟體和非同步 action,用 useReducer 鉤子來重新應用我們的 provider,就像我們正在做的一樣,除了將 action 觸發到一個獲得新狀態的 reducer,它就像 useState 一樣工作。

  • 知道了這個,我們只需要將 reducer 的邏輯從老的 StateProvider 拷貝到新的函式元件 StateProvider裡就可以了:

    export function StateProvider ({ state: initialState, reducers, middleware, children }) {
      const [state, dispatch] = useReducer((state, action) => {
        return reducers.reduce((state, reducer) => reducer(state, action) || state, state);
      }, initialState);
    
      return (
        <StateContext.Provider value={{ state, dispatch }}>
          {children}
        </StateContext.Provider>
      );
    }
    複製程式碼
  • 可以如此的簡單,但是當我們想要保持簡單時,我們仍然沒有完全掌握 hooks 的所有能力。我們也可以使用 hooks 來把我們的 StateConsumer 換為我們自己的鉤子,我們可以通過包裹 useContext 鉤子來做到:

    const StateContent = createContext();
    
    export function useStore () {
      const { state, dispatch } = useContext(StateContext);
      return [state, dispatch];
    }
    複製程式碼
  • 儘管之前當我們建立我們的上下文時使用瞭解構的 ProviderConsumer,但是這次我們會將它存到我們傳遞到 useContext 單個變數從而讓我們可不用 Consumer 就可以接入上下文。我們也將它命名為我們自己的 useStore 鉤子,因為 useState 是一個預設的鉤子。

  • 接下來我們來簡單地重構下我們消費上下文資料的方法:

    export default function SomeCount () {
      const [state, dispatch] = useStore();
      return (
        <>
          <p>
            Count: {state.count}
          </p>
          <button onClick={() => dispatch(addOne())}>
            + 1
          </button>
          <button onClick={() => dispatch(addN(5))}>
            + 5
          </button>
          <button onClick={() => dispatch(addN(10))}>
            + 10
          </button>
        </>
      );
    }
    複製程式碼
  • 希望這些例子能讓你感受到 hooks 是如何的直觀、簡單和有效。我們減少了所需的程式碼數量,並且給了我們一個友好、簡單的 API 供使用。

  • 我們也想讓我們的中介軟體和內建的非同步 action 重新開始工作。為了做到這一點,我們將我們的 useReducer 包裹進一個自定義鉤子,在我們的 StateProvider 中被特殊的使用,然後簡單地重用我們老的狀態元件的邏輯就好了。

  • export function useStateProvider ({ initialState, reducers, middleware = [] }) {
      const [state, _dispatch] = useReducer((state, action) => {
        return reducers.reduce((state, reducer) => reducer(state, action) || state, state);
      }, initialState);
    
      function dispatch (action) {
        if (typeof action === 'function') {
          return action(dispatch, state);
        }
    
        const continueUpdate = middleware.reduce((result, middleware) => {
          return result !== null ? middleware(action, state) : result;
        }, undefined);
    
        if (continueUpdate !== null) {
          _dispatch(action);
        }
      }
    
      return { state, dispatch };
    }
    複製程式碼
  • 在我們的老的解決方案中,我們想讓中介軟體是可選的,所以我們新增了一個空陣列作為預設值,同樣地這次我們也使用一個預設的引數來替換預設屬性。類似於我們老的 dispatch 函式,我們呼叫了中介軟體,然後如果 continueUpdate !== null 我們就繼續更新狀態。我們也不會改變處理非同步 action 的方式。

  • 最終,我們將 useStateProvider 的結果和它的引數到我們的 provider,這我們之前沒怎麼考慮:

  • export function StateProvider ({ state: initialState, reducers, middleware, children }) {
      return (
        <StateContext.Provider value={useStateProvider({ initialState, reducers, middleware })}>
          {children}
        </StateContext.Provider>
      );
    }
    複製程式碼
  • 完結!

然而...

  • 你可能已經注意到的一件事是我們對 hooks 的應用可能沒有辦法跳過不必要的更新。這是因為 hooks 是在函式元件體內被呼叫的,在這個階段 react 沒法擺脫渲染程式(在不適用一些技巧的前提下)。這沒有必要擔心,react 團隊已經考慮到這一點而且計劃提供一種方法讓我們能夠在函式元件內部終止更新。
  • 在函式元件內一旦我們有官方提供的方法來讓我們擺脫渲染,我就會回到這裡來更新這篇文章。與此同時,我用 hooks 實現的這個庫還有消費者,這樣我們就可以訪問此功能。

總結

  • 綜上所述,我們已經寫完了狀態管理的大部分功能,還逐步在它基礎上增加了一些類似 redux 的功能,包括 action,reducer,中介軟體以及一種比較狀態差異的方法來提升效能。我們也看到了可以使用新的 hooks API 可以如何簡化我們的程式碼。
  • 希望你能從這篇文章中獲得一些有用的東西,能夠對一些高階概念有些許瞭解,同時可以讓我們在使用一些工具時比我們初次見到它時更加簡單。

在開始的時候提到過,我寫了一個庫,Use Simple State,看完這篇文章之後,你可以在我的 github頁面看到,我已經使用 hooks 最終實現了,包括幾個新增的功能。

相關文章