由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,進行組件的渲染。代碼演化成這樣:

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的使用姿勢並不簡單,且行且珍惜吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章