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>
);
}
}
複製程式碼
頁面載入效果元件Loading
和Route
處於同一層級,這樣,Home
、Login
等頁面元件都共用外層的Loading元件。當和Redux一起使用時,isRequesting會儲存到Redux的store中,App
會作為Redux中的容器元件(container components),從store中獲取isRequesting。Home
、Login
等頁面根元件一般也會作為容器元件,從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,進而控制App
中Loading
的顯示和隱藏。正常來說,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
}
複製程式碼
Route
的componentWillReceiveProps
中,會呼叫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的場景:
App
結構基本不變,只是不再通過Redux獲取isRequesting,而是作為元件自身的state維護。Home
繼承自React.PureComponent
,Home
通過App
傳遞的回撥函式,改變isRequesting,App
重新render,由於同樣的原因,Home
也會重新render。React.PureComponent
的功效也浪費了。- 與Mobx結合使用,
App
和Home
元件通過@observer
修飾,App
監聽到isRequesting改變重新render,由於同樣的原因,Home
元件也會重新render。
一個Route
的問題,竟然導致所有的狀態管理庫的優化工作都大打折扣!痛心!
我已經在github上向React Router官方提了這個issue,希望能在componentWillReceiveProps
中先做一些簡單的判斷,再決定是否要重新setState
。但令人失望的是,這個issue很快就被一個Collaborator給close掉了。
好吧,求人不如求己,自己找解決方案。
幾個思路:
-
既然
Loading
放在和Route
同一層級的元件中會有這個問題,那麼就把Loading
放到更低層級的元件內,Home
、Login
中,大不了多引幾次Loading
元件。但這個方法治標不治本,Home
元件內依然可能會定義其他Route
,Home
依賴狀態的更新,同樣又會導致這些Route
內元件的重新渲染。也就是說,只要在container components中使用了Route
,這個問題就繞不開。但在React Router 4Route
的分散式使用方式下,container components中是不可能完全避免使用Route
的。 -
重寫container components的
shouldComponentUpdate
方法,方法可行,但每個元件重寫一遍,心累。 -
接著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
包裹Home
、Login
: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本大前端精選書籍!