由React Router引起的元件重複渲染談Route的使用姿勢

艾特老幹部發表於2017-11-20

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

由React Router引起的元件重複渲染談Route的使用姿勢

相關文章