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

5,529 阅读6分钟

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,进行组件的渲染。代码演化成这样:

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一般的结构会是这样:

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方法理应不被触发。

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

究竟是什么原因导致的呢?最后,我在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的计算结果自然也没有变)。

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

  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本大前端精选书籍!