由React Router引发的组件重复渲染谈Route的使用姿式

React Router 4 把Route看成普通的React组件,能够在任意组件内使用Route,而再也不像以前的版本那样,必须在一个地方集中定义全部的Route。所以,使用React Router 4 的项目中,常常会有Route和其余组件出如今同一个组件内的状况。例以下面这段代码:前端

class App extends Component {
  render() {
    const { isRequesting } = this.props;
    return (
      <div>
        <Router>
          <Switch>
            <Route exact path="/" component={Home} />
            <Route path="/login" component={Login} />
            <Route path="/home" component={Home} />
          </Switch>
        </Router>
        {isRequesting  && <Loading />}
      </div>
    );
  }
}
复制代码

页面加载效果组件LoadingRoute处于同一层级,这样,HomeLogin等页面组件都共用外层的Loading组件。当和Redux一块儿使用时,isRequesting会存储到Redux的store中,App会做为Redux中的容器组件(container components),从store中获取isRequesting。HomeLogin等页面根组件通常也会做为容器组件,从store中获取所需的state,进行组件的渲染。代码演化成这样:react

class App extends Component {
  render() {
    const { isRequesting } = this.props;
    return (
      <div>
        <Router>
          <Switch>
            <Route exact path="/" component={Home} />
            <Route path="/login" component={Login} />
            <Route path="/home" component={Home} />
          </Switch>
        </Router>
        {isRequesting  && <Loading />}
      </div>
    );
  }
}

const mapStateToProps = (state, props) => {
  return {
    isRequesting: getRequestingState(state)
  };
};

export default connect(mapStateToProps)(App);
复制代码
class Home extends Component {
  componentDidMount() {
    this.props.fetchHomeDataFromServer();
  }
  
  render() {
    return (
      <div>
       {homeData}
      </div>
    );
  }
}

const mapStateToProps = (state, props) => {
  return {
    homeData: getHomeData(state)
  };
};

const mapDispatchToProps = dispatch => {
  return {
    ...bindActionCreators(homeActions, dispatch)
  };
};

export default connect(mapStateToProps, mapDispatchToProps)(Home);
复制代码

Home组件挂载后,调用this.props.fetchHomeDataFromServer()这个异步action从服务器中获取页面所需数据。fetchHomeDataFromServer通常的结构会是这样:git

const fetchHomeDataFromServer = () => {
  return (dispatch, getState) => {  
    dispatch(REQUEST_BEGIN);
    return fetchHomeData().then(data => {
      dispatch(REQUEST_END);   
      dispatch(setHomeData(data));
    });    
}
复制代码

这样,在dispatch setHomeData(data)前,会dispatch另外两个action改变isRequesting,进而控制AppLoading的显示和隐藏。正常来讲,isRequesting的改变应该只会致使App组件从新render,而不会影响Home组件。由于通过Redux connect后的Home组件,在更新阶段,会使用浅比较(shallow comparison)判断接收到的props是否发生改变,若是没有改变,组件是不会从新render的。Home组件并不依赖isRequesting,render方法理应不被触发。github

但实际的结果是,每一次App的从新render,都伴随着Home的从新render。Redux浅比较作的优化都被浪费掉了!bash

到底是什么缘由致使的呢?最后,我在React Router Route的源码中找到了罪魁祸首:服务器

componentWillReceiveProps(nextProps, nextContext) {
    warning(
      !(nextProps.location && !this.props.location),
      '<Route> elements should not change from uncontrolled to controlled (or vice versa). You initially used no "location" prop and then provided one on a subsequent render.'
    )

    warning(
      !(!nextProps.location && this.props.location),
      '<Route> elements should not change from controlled to uncontrolled (or vice versa). You provided a "location" prop initially but omitted it on a subsequent render.'
    )

    // 注意这里,computeMatch每次返回的都是一个新对象,如此一来,每次Route更新,setState都会从新设置一个新的match对象
    this.setState({
      match: this.computeMatch(nextProps, nextContext.router)
    })
  }

  render() {
    const { match } = this.state
    const { children, component, render } = this.props
    const { history, route, staticContext } = this.context.router
    const location = this.props.location || route.location
    // 注意这里,这是传递给Route中的组件的属性
    const props = { match, location, history, staticContext }

    if (component)
      return match ? React.createElement(component, props) : null

    if (render)
      return match ? render(props) : null

    if (typeof children === 'function')
      return children(props)

    if (children && !isEmptyChildren(children))
      return React.Children.only(children)

    return null
  }
复制代码

RoutecomponentWillReceiveProps中,会调用setState设置match,match由computeMatch计算而来,computeMatch每次都会返回一个新的对象。这样,每次Route更新(componentWillReceiveProps被调用),都将建立一个新的match,而这个match由会做为props传递给Route中定义的组件(这个例子中,也就是Home)。因而,Home组件在更新阶段,总会收到一个新的match属性,致使Redux的浅比较失败,进而触发组件的从新渲染。事实上,上面的状况中,Route传递给Home的其余属性location、history、staticContext都没有改变,match虽然是一个新对象,但对象的内容并无改变(一直处在同一页面,URL并无发生变化,match的计算结果天然也没有变)。react-router

若是你认为这个问题只是和Redux一块儿使用时才会遇到,那就大错特错了。再举两个不使用Redux的场景:app

  1. App结构基本不变,只是再也不经过Redux获取isRequesting,而是做为组件自身的state维护。Home继承自React.PureComponentHome经过App传递的回调函数,改变isRequesting,App从新render,因为一样的缘由,Home也会从新render。React.PureComponent的功效也浪费了。
  2. 与Mobx结合使用,AppHome组件经过@observer修饰,App监听到isRequesting改变从新render,因为一样的缘由,Home组件也会从新render。

一个Route的问题,居然致使全部的状态管理库的优化工做都大打折扣!痛心!异步

我已经在github上向React Router官方提了这个issue,但愿能在componentWillReceiveProps中先作一些简单的判断,再决定是否要从新setState。但使人失望的是,这个issue很快就被一个Collaborator给close掉了。分布式

好吧,求人不如求己,本身找解决方案。

几个思路:

  1. 既然Loading放在和Route同一层级的组件中会有这个问题,那么就把Loading放到更低层级的组件内,HomeLogin中,大不了多引几回Loading组件。但这个方法治标不治本,Home组件内依然可能会定义其余RouteHome依赖状态的更新,一样又会致使这些Route内组件的从新渲染。也就是说,只要在container components中使用了Route,这个问题就绕不开。但在React Router 4 Route的分布式使用方式下,container components中是不可能彻底避免使用Route的。

  2. 重写container components的shouldComponentUpdate方法,方法可行,但每一个组件重写一遍,心累。

  3. 接着2的思路,经过建立一个高阶组件,在高阶组件内重写shouldComponentUpdate,若是Route传递的location属性没有发生变化(表示处于同一页面),那么就返回false。而后使用这个高阶组件包裹每个要在Route中使用的组件。

    新建一个高阶组件connectRoute:

    import React from "react";
    
    export default function connectRoute(WrappedComponent) {
      return class extends React.Component {
        shouldComponentUpdate(nextProps) {
          return nextProps.location !== this.props.location;
        }
    
        render() {
          return <WrappedComponent {...this.props} />;
        }
      };
    }
    
    复制代码

    connectRoute包裹HomeLogin

    const HomeWrapper = connectRoute(Home);
    const LoginWrapper = connectRoute(Login);
    
    class App extends Component {
      render() {
        const { isRequesting } = this.props;
        return (
          <div>
            <Router>
              <Switch>
                <Route exact path="/" component={HomeWrapper} />
                <Route path="/login" component={LoginWrapper} />
                <Route path="/home" component={HomeWrapper} />
              </Switch>
            </Router>
            {isRequesting  && <Loading />}
          </div>
        );
      }
    }
    复制代码

这样就一劳永逸的解决问题了。

咱们再来思考一种场景,若是App使用的状态一样会影响到Route的属性,好比isRequesting为true时,第三个Route的path也会改变,假设变成<Route path="/home/fetching" component={HomeWrapper} />,而Home内部会用到Route传递的path(其实是经过match.path获取), 这时候就须要Home组件从新render。 但由于高阶组件的shouldComponentUpdate中咱们只是根据location作判断,此时的location依然没有发生变化,致使Home并不会从新渲染。这是一种很特殊的场景,可是想经过这种场景告诉你们,高阶组件shouldComponentUpdate的判断条件须要根据实际业务场景作决策。绝大部分场景下,上面的高阶组件是足够使用。

Route的使用姿式并不简单,且行且珍惜吧!


欢迎关注个人公众号:老干部的大前端,领取21本大前端精选书籍!

相关文章
相关标签/搜索