Redux with Hooks

avatar
@腾讯科技(深圳)有限公司

作者: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等工具已经花费不少功夫帮我们解决了的,似乎并没有什么充分的理由要抛弃它们。

参考

推荐阅读


关注【IVWEB社区】公众号获取每周最新文章,通往人生之巅!