由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淺比較作的優化都被浪費掉了!服務器

到底是什麼緣由致使的呢?最後,我在React Router Route的源碼中找到了罪魁禍首:react-router

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的計算結果天然也沒有變)。app

若是你認爲這個問題只是和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掉了。ide

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

幾個思路:

  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本大前端精選書籍!

3808299627-5a93ba468b59a

相關文章
相關標籤/搜索