Redux with Hooks

騰訊IVWEB團隊發表於2019-08-01

作者:Alex Xu
閱讀時間大約15~20min

前言

React在16.8版本為我們正式帶來了Hooks API。什麼是Hooks?簡而言之,就是對函式式元件的一些輔助,讓我們不必寫class形式的元件也能使用state和其他一些React特性。按照官網的介紹,Hooks帶來的好處有很多,其中讓我感受最深的主要有這幾點:

  • 函式式元件相比class元件通常可以精簡不少程式碼。
  • 沒有生命週期的束縛後,一些相互關聯的邏輯不用被強行分割。比如在componentDidMount中設定了定時器,需要在componentWillUnmount中清除;又或者在componentDidMount中獲取了初始資料,但要記得在componentDidUpdate中進行更新。這些邏輯由於useEffect hook的引入而得以寫在同一個地方,能有效避免一些常見的bug。
  • 有效減少與善變的this打交道。

既然Hooks大法這麼好,不趕緊上車試試怎麼行呢?於是本人把技術專案的reactreact-dom升級到了16.8.6版本,並按官方建議,漸進式地在新元件中嘗試Hooks。不得不說,感覺還是很不錯的,確實敲少了不少程式碼,然而有個值得注意的地方,那就是結合React-Redux的使用。

本文並不是Hooks的基礎教程,所以建議讀者先大致掃過官方文件的34節,對Hooks API有一定了解。

問題

我們先來看一段使用了Hooks的函式式元件結合React-Redux connect的用法:

import React, { useEffect } from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
// action creators
import { queryFormData } from "@/data/queryFormData/action";
import { submitFormData } from "@/data/submitFormData/action";

function Form(props) {
    const {
        formId
        formData,
        queryFormData,
        submitFormData,
    } = props;

    useEffect(() => {
        // 請求表單資料
        queryFormData(formId);
    },
        // 指定依賴,防止元件重新渲染時重複請求
        [queryFormData, formId]
    );
  
    // 處理提交
    const handleSubmit = usefieldValues => {
        submitFormData(fieldValues);
    }

    return (
        <FormUI
            data={formData}
            onSubmit={handleSubmit}
        />
    )
}

function mapStateToProps(state) {
    return {
        formData: state.formData
    };
}

function mapDispatchToProps(dispatch, ownProps) {
    // withRouter傳入的prop,用於程式設計式導航
    const { history } = ownProps;

    return {
        queryFormData(formId) {
            return dispatch(queryFormData(formId));
        },
        submitFormData(fieldValues) {
            return dispatch(submitFormData(fieldValues))
            .then(res) => {
                // 提交成功則重定向到主頁
                history.push('/home');
            };
        }
    }
}

export default withRouter(connect(mapStateToProps, mapDispatchToProps)(React.memo(Form));
複製程式碼

上面程式碼描述了一個簡單的表單元件,通過mapDispatchToProps生成的queryFormData prop請求表單資料,並在useEffect中誠實地記錄了依賴,防止元件re-render時重複請求後臺;通過mapDispatchToProps生成的submitFormData prop提交表單資料,並在提交成功後使用React-Router提供的history prop程式設計式導航回首頁;通過mapStateToProps生成的formData prop拿到後臺返回的資料。看起來似乎妹啥毛病?

其實有毛病。

問題就在於mapDispatchToProps的第二個引數——ownProps

function mapDispatchToProps(dispatch, ownProps) { // **問題在於這個ownProps!!!**
    const { history } = ownProps;
    ...
}
複製程式碼

在上面的例子中我們需要使用React-Router的withRouter傳入的history prop來進行程式設計式導航,所以使用了mapDispatchToProps的第二個引數ownProps。然而關於這個引數,React-Redux官網上的這句話也許不是那麼的引人注意:

image-20190728144128356

如果我們在宣告mapDispatchToProps時使用了第二個引數(即便宣告後沒有真的用過這個ownProps),那麼每當connected的元件接收到新的props時,mapDispatchTopProps都會被呼叫。這意味著什麼呢?由於mapDispatchToProps被呼叫時會返回一個全新的物件(上面的queryFormDatasubmitFormData也將會是全新的函式),所以這會導致上面傳入到<Form/>中的queryFormDatasubmitFormData prop被隱式地更新,造成useEffect的依賴檢查失效,元件re-render時會重複請求後臺資料

對應的React-Redux原始碼是這段:

// selectorFactory.js
...
// 此函式在connected元件接收到new props時會被呼叫
function handleNewProps() {
  if (mapStateToProps.dependsOnOwnProps)
    stateProps = mapStateToProps(state, ownProps)
  
  // 宣告mapDispatchToProps時如果使用了第二個引數(ownProps)這裡會標記為true
  if (mapDispatchToProps.dependsOnOwnProps)
    // 重新呼叫mapDispatchToProps,更新dispatchProps
    dispatchProps = mapDispatchToProps(dispatch, ownProps)
  
  // mergeProps的做法其實是:mergedProps = { ...ownProps, ...stateProps, ...dispatchProps }
  // 最後傳入被connect包裹的元件
  mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
  return mergedProps
}
...
複製程式碼

解決方案

1. 最省事

給useEffect的第二個引數傳一個空陣列:

function Form(props) {
    const {
        formId,
        queryFormData,
        ...
    } = props;

    useEffect(() => {
        // 請求表單資料
        queryFormData(formId);
    },
        // 傳入空陣列,起到類似componentDidMount的效果
        []
    );
  
    ...
}
複製程式碼

這種方式相當於告訴useEffect,裡面要呼叫的方法沒有任何外部依賴——換句話說就是不需要(在依賴更新時)重複執行,所以useEffect就只會在元件第一次渲染後呼叫傳入的方法,起到類似componentDidMount的效果。然而,這種方法雖然可行,但卻是一種欺騙React的行為(我們明明依賴了來自props的queryFormDataformId),很容易埋坑(見React官方的Hooks FAQ)。實際上,如果我們有遵循React官方的建議,給專案裝上eslint-plugin-react-hooks的話,這種寫法就會收到eslint的告警。所以從程式碼質量的角度考慮,儘量不要偷懶採用這種寫法

2. 不使用ownProps引數

把需要用到ownProps的邏輯放在元件內部:

function Form(props) {
    const {
        formId
        queryFormData,
        submitFormData,
        history
        ...
    } = props;

    useEffect(() => {
        queryFormData(formId);
    },
        // 由於宣告mapDispatchToProps時沒使用ownProps,所以queryFormData是穩定的
        [queryFormData, formId]
    );
  
    const handleSubmit = fieldValues => {
        submitFormData(fieldValues)
          // 把需要用到ownProps的邏輯遷移到元件內定義(使用了redux-thunk中介軟體,返回Promise)
          .then(res => {
            history.push('/home');
          });
    }

    ...
}

...

function mapDispatchToProps(dispatch) { // 不再宣告ownProps引數
    return {
        queryFormData(formId) {
            return dispatch(queryFormData(formId));
        },
        submitFormData(fieldValues) {
            return dispatch(submitFormData(fieldValues));
        }
    }
}

...
複製程式碼

同樣是改動較少的做法,但缺點是把相關聯的邏輯強行分割到了兩個地方(mapDispatchToProps和元件裡)。同時我們還必須加上註釋,提醒以後維護的人不要在mapDispatchToProps裡使用ownProps引數(實際上如果有瞄過上面的原始碼,就會發現mapStateToProps也有類似的顧忌),並不太靠譜。

3. 不使用mapDispatchToProps

如果不給connect傳入mapDispatchToProps,那麼被包裹的元件就會接收到dispatch prop,從而可以把需要使用dispatch的邏輯寫在元件內部:

...
// action creators
import { queryFormData } from "@/data/queryFormData/action";
import { submitFormData } from "@/data/submitFormData/action";

function Form(props) {
    const {
        formId
        history,
        dispatch
        ...
    } = props;

    useEffect(() => {
        // 在元件內使用dispatch
        // 注意這裡的queryFormData來自import,而非props,不會變,所以不用寫進依賴陣列
        dispatch(queryFormData(formId))
    },
        [dispatch, formId]
    );
  
    const handleSubmit = fieldValues => {
        // 在元件內使用dispatch
        dispatch(submitFormData(fieldValues))
          .then(res => {
            history.push('/home');
          });
    }

    ...
}

...
// 不傳入mapDispatchToProps
export default withRouter(connect(mapStateToProps, null)(React.memo(Form));
複製程式碼

這是個人比較推薦的做法,不必分割相關聯的邏輯(這也是hooks的初衷之一),同時把dispatch的相關邏輯寫在useEffect裡也可以讓eslint自動檢查依賴,避免出bug。當然帶來的另一個問題是,如果需要請求很多條cgi,那把相關邏輯都寫在useEffect裡好像會很臃腫?此時我們可以使用useCallback

import { actionCreator1 } from "@/data/actionCreator1/action";
import { actionCreator2 } from "@/data/actionCreator2/action";
import { actionCreator3 } from "@/data/actionCreator3/action";

...
function Form(props) {
    const {
        dep1,
        dep2,
        dep3,
        dispatch
        ...
    } = props;
  
    // 利用useCallback把useEffect要使用的函式抽離到外部
    const fetchUrl1() = useCallback(() => {
      dispatch(actionCreator1(dep1));
        .then(res => {...})
        .catch(err => {...});
    }, [dispatch, dep1]); // useCallback的第二個引數跟useEffect一樣,是依賴項

    const fetchUrl2() = useCallback(() => {
      dispatch(actionCreator2(dep2));
        .then(res => {...})
        .catch(err => {...});
    }, [dispatch, dep2]);

    const fetchUrl3() = useCallback(() => {
      dispatch(actionCreator3(dep3));
        .then(res => {...})
        .catch(err => {...});
    }, [dispatch, dep3]);

    useEffect(() => {
      fetchUrl1();
      fetchUrl2();
      fetchUrl3();
    },
      // useEffect的直接依賴變成了useCallback包裹的函式
      [fetchUrl1, fetchUrl2, fetchUrl3]
    );

    // 為了避免子元件發生不必要的re-render,handleSubmit其實也應該用useCallback包裹
    const handleSubmit = useCallback(fieldValues => {
        // 在元件內使用dispatch
        dispatch(submitFormData(fieldValues))
          .then(res => {
            history.push('/home');
          });
    });

    return (
        <FormUI
            data={formData}
            onSubmit={handleSubmit}
        />
    )
}
...
複製程式碼

useCallback會返回被它包裹的函式的memorized版本,只要依賴項不變,memorized的函式就不會更新。利用這一特點我們可以把useEffect中要呼叫的邏輯使用useCallback封裝到外部,然後只需要在useEffect的依賴項裡新增memorized的函式,就可以正常運作了。

然而正如前文提到的,mapStateToProps中的ownProps引數同樣會引起mapStateToProps的重新呼叫,產生新的state props:

// 此函式在connected元件接收到new props時會被呼叫
function handleNewProps() {
  // 宣告mapStateToProps時如果使用了ownProps引數同樣會產生新的stateProps!
  if (mapStateToProps.dependsOnOwnProps)
    stateProps = mapStateToProps(state, ownProps)
  
  if (mapDispatchToProps.dependsOnOwnProps)
    dispatchProps = mapDispatchToProps(dispatch, ownProps)

  mergedProps = mergeProps(stateProps, dispatchProps, ownProps)
  return mergedProps
}
複製程式碼

因此在這種方案中如果useEffect有依賴這些state props的話還是有可能造成依賴檢查失效(比如說state props是引用型別)。

4. 使用React-Redux的hooks APIs(推薦)

既然前面幾種方案或多或少都有些坑點,那麼不妨嘗試一下React Redux在v7.1.0版本為我們帶來的官方hooks APIs,下面就展示下基本用法。

主要用到的API:

import { useSelector, useDispatch } from 'react-redux'

// selector函式的用法和mapStateToProps相似,其返回值會作為useSelector的返回值,但與mapStateToProps不同的是,前者可以返回任何型別的值(而不止是一個物件),此外沒有第二個引數ownProps(因為可以在元件內通過閉包拿到)
const result : any = useSelector(selector : Function, equalityFn? : Function)
const dispatch = useDispatch()
複製程式碼

使用:

...
import { useSelector, useDispatch } from "react-redux";
// action creators
import { queryFormData } from "@/data/queryFormData/action";
import { submitFormData } from "@/data/submitFormData/action";

function Form(props) {
    const {
        formId
        history,
        dispatch
        ...
    } = props;
  
    const dispatch = useDispatch();

    useEffect(() => {
        dispatch(queryFormData(formId))
    },
        [dispatch, formId]
    );
  
    const handleSubmit = useCallback(fieldValues => {
        dispatch(submitFormData(fieldValues))
          .then(res => {
            history.push('/home');
          });
    }, [dispatch, history]);

    const formData = useSelector(state => state.formData;);
  
    ...

    return (
        <FormUI
            data={formData}
            onSubmit={handleSubmit}
        />
    );
}

...

// 無需使用connect
export default withRouter(React.memo(Form));
複製程式碼

可以看到和上面介紹的"不使用mapDispatchToProps"的方式很相似,都是通過傳入dispatch,然後把需要使用dispatch的邏輯定義在元件內部,最大差異是把提取state的地方從mapStateToProps變成useSelector。兩者的用法相近,但如果你想後者像前者一樣返回一個物件的話要特別注意:

由於useSelector內部預設是使用===來判斷前後兩次selector函式的計算結果是否相同的(如果不相同就會觸發元件re-render),那麼如果selector函式返回的是物件,那實際上每次useSelector執行時呼叫它都會產生一個新物件,這就會造成元件無意義的re-render。要解決這個問題,可以使用reselect等庫建立帶memoized效果的selector ,或者給useSelector的第二個引數(比較函式)傳入react-redux內建的shallowEqual

import { useSelector, shallowEqual } from 'react-redux'

const selector = state => ({
  a: state.a,
  b: state.b
});

const data = useSelector(selector, shallowEqual);
複製程式碼

用Hooks代替Redux?

自從Hooks出現後,社群上一個比較熱門的話題就是用Hooks手擼一套全域性狀態管理,一種常見的方式如下:

  • 相關HooksuseContextuseReducer

  • 實現:

    import { createContext, useContext, useReducer, memo } from 'react';
    
    function reducer(state, action) {
        switch (action.type) {
            case 'UPDATE_HEADER_COLOR':
              return {
                  ...state,
                  headerColor: 'yellow'
              };
            case 'UPDATE_CONTENT_COLOR':
              return {
                  ...state,
                  contentColor: 'green'
              };
            default:
              break;
        }
    }
    
    // 建立一個context
    const Store = createContext(null);
    // 作為全域性state
    const initState = {
        headerColor: 'red',
        contentColor: 'blue'
    };
    
    const App = () => {
        const [state, dispatch] = useReducer(reducer, initState);
    		// 在根結點注入全域性state和dispatch方法
        return (
          <Store.Provider value={{ state, dispatch }}>
            <Header/>
            <Content/>
          </Store.Provider>
        );
    };
    
    const Header = memo(() => {
      	// 拿到注入的全域性state和dispatch
        const { state, dispatch } = useContext(Store);
        return (
        	<header
          	style={{backgroundColor: state.headerColor}}
            onClick={() => dispatch('UPDATE_HEADER_COLOR')}
          />
        );
    });
    
    const Content = memo(() => {
        const { state, dispatch } = useContext(Store);
        return (
        	<div
            style={{backgroundColor: state.contentColor}}
            onClick={() => dispatch('UPDATE_CONTENT_COLOR')}
          />
        );
    });
    複製程式碼

上述程式碼通過context,把一個全域性的state和派發actionsdispatch函式注入到被Provider包裹的所有子元素中,再配合useReducer,看起來確實是個窮人版的Redux。

然而,上述程式碼其實有效能隱患:無論我們點選<Header/>還是<Content/>去派發一個action,最終結果是:

<Header/><Content/>都會被重新渲染!

因為很顯然,它們倆都消費了同一個state(儘管都只消費了state的一部分),所以當這個全域性的state被更新後,所有的Consumer自然也會被更新。

但我們不是已經用memo包裹元件了嗎?

是的,memo能為我們守住來自props的更新,然而state是在元件內部通過useContext這個hook注入的,這麼一來就會繞過最外層的memo

那麼有辦法可以避免這種強制更新嗎? Dan Abramov大神給我們指了幾條明路

  • 拆分Context(推薦)。把全域性的State按需求拆分到不同的context,那麼自然不會相互影響導致無謂的重渲染;

  • 把元件拆成兩個,裡層的用memo包裹

    const Header = () => {
        const { state, dispatch } = useContext(Store);
        return memo(<ThemedHeader theme={state.headerColor} dispatch={dispatch} />);
    };
    
    const ThemedHeader = memo(({theme, dispatch}) => {
        return (
            <header
                style={{backgroundColor: theme}}
                onClick={() => dispatch('UPDATE_HEADER_COLOR')}
            />
        );
    });
    複製程式碼
  • 使用useMemo hook。思路其實跟上面的一樣,但不用拆成兩個元件:

    const Header = () => {
        const { state, dispatch } = useContext(Store);
        return useMemo(
            () => (
                <header
                    style={{backgroundColor: state.headerColor}}
                    onClick={() => dispatch('UPDATE_HEADER_COLOR')}
            		/>
            ),
            [state.headerColor, dispatch]
        );
    };
    複製程式碼

可見,如果使用Context + Hooks來代替Redux等狀態管理工具,那麼我們必須花費額外的心思去避免效能問題,然而這些dirty works其實React-Redux等工具已經默默替我們解決了。除此之外,我們還會面臨以下問題:

  • 需要自行實現combineReducers等輔助功能(如果發現要用到)
  • 失去Redux生態的中介軟體支援
  • 失去Redux DevTools等除錯工具
  • 出了坑不利於求助……

所以,除非是在對狀態管理需求很簡單的個人或技術專案裡,或者純粹想造輪子練練手,否則個人是不建議放棄Redux等成熟的狀態管理方案的,因為價效比不高。

總結

React Hooks給開發者帶來了清爽的使用體驗,一定程度上提升了鍵盤的壽命【並不,然而與原有的React-Redux connect相關APIs結合使用時,需要特別小心ownProps引數,很容易踩坑,建議儘快升級到v7.1.0版本,使用官方提供的Hooks API。

此外,使用Hooks自建全域性狀態管理的方式在小專案中固然可行,然而想用在較大型的、正式的業務中,至少還要花費心思解決效能問題,而這個問題正是React-Redux等工具已經花費不少功夫幫我們解決了的,似乎並沒有什麼充分的理由要拋棄它們。

參考

推薦閱讀


Redux with Hooks

關注【IVWEB社群】公眾號獲取每週最新文章,通往人生之巔!

相關文章