React Router 4 把Route
当作普通的React组件,可以在任意组件内使用Route
,而不再像之前的版本那样,必须在一个地方集中定义所有的Route
。因此,使用React Router 4 的项目中,经常会有Route
和其他组件出现在同一个组件内的情况。例如下面这段代码:
class App extends Component {
render() {
const { isRequesting } = this.props;
return (
"/" component={Home} />
"/login" component={Login} />
"/home" component={Home} />
{isRequesting && }
);
}
}
复制代码
页面加载效果组件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 (
"/" component={Home} />
"/login" component={Login} />
"/home" component={Home} />
{isRequesting && }
);
}
}
const mapStateToProps = (state, props) => {
return {
isRequesting: getRequestingState(state)
};
};
export default connect(mapStateToProps)(App);
复制代码
class Home extends Component {
componentDidMount() {
this.props.fetchHomeDataFromServer();
}
render() {
return (
{homeData}
);
}
}
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),
' 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),
' 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
; } }; } 复制代码 用
connectRoute
包裹Home
、Login
:const HomeWrapper = connectRoute(Home); const LoginWrapper = connectRoute(Login); class App extends Component { render() { const { isRequesting } = this.props; return (
"/" component={HomeWrapper} /> "/login" component={LoginWrapper} /> "/home" component={HomeWrapper} /> }
这样就一劳永逸的解决问题了。
我们再来思考一种场景,如果App
使用的状态同样会影响到Route
的属性,比如isRequesting
为true时,第三个Route
的path也会改变,假设变成
,而Home
内部会用到Route
传递的path(实际上是通过match.path
获取), 这时候就需要Home
组件重新render。 但因为高阶组件的shouldComponentUpdate
中我们只是根据location做判断,此时的location依然没有发生变化,导致Home
并不会重新渲染。这是一种很特殊的场景,但是想通过这种场景告诉大家,高阶组件shouldComponentUpdate
的判断条件需要根据实际业务场景做决策。绝大部分场景下,上面的高阶组件是足够使用。
Route
的使用姿势并不简单,且行且珍惜吧!
欢迎关注我的公众号:老干部的大前端,领取21本大前端精选书籍!