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社区】公众号获取每周最新文章,通往人生之巅!

相关文章
相关标签/搜索