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
的使用姿势并不简单,且行且珍惜吧!