Redux with Hooks

做者:Alex Xu
閱讀時間大約15~20minjavascript

前言

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

  • 函數式組件相比class組件一般能夠精簡很多代碼。
  • 沒有生命週期的束縛後,一些相互關聯的邏輯不用被強行分割。好比在componentDidMount中設置了定時器,須要在componentWillUnmount中清除;又或者在componentDidMount中獲取了初始數據,但要記得在componentDidUpdate中進行更新。這些邏輯因爲useEffect hook的引入而得以寫在同一個地方,能有效避免一些常見的bug。
  • 有效減小與善變的this打交道。

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

本文並非Hooks的基礎教程,因此建議讀者先大體掃過官方文檔的34節,對Hooks API有必定了解。react

問題

咱們先來看一段使用了Hooks的函數式組件結合React-Redux connect的用法:git

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拿到後臺返回的數據。看起來彷佛妹啥毛病?github

其實有毛病。npm

問題就在於mapDispatchToProps的第二個參數——ownProps編程

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

在上面的例子中咱們須要使用React-Router的withRouter傳入的history prop來進行編程式導航,因此使用了mapDispatchToProps的第二個參數ownProps。然而關於這個參數,React-Redux官網上的這句話也許不是那麼的引人注意: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等工具已經花費很多功夫幫咱們解決了的,彷佛並無什麼充分的理由要拋棄它們。

參考

推薦閱讀


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

相關文章
相關標籤/搜索