前前言
hello各位小伙伴,我是来自推啊前端团队的 jarvis。 今天跟大家简要分享一下React-Router
原理。
1. 原理初探
借助history库实现监听路由,内部支持hash和bowser两种路由变化。
使用react-router-dom时首先选择的就是BrowserRouter或HashRouter,对应hash和browser两种路由规则,两个组件的源码实现基本一致,区别是调用了history库的两个不同方法创建history对象,然后通过React.Context
将history对象共享给消费者使用,在消费者内部会注册一个更新当前组件的函数,每当路由变化时会自动触发组件更新,从而实现路由切换的效果。
在源码中二者区别如下
// BrowserRouter
import { createBrowserHistory as createHistory } from 'history';
// HashRouter
import { createHashHistory as createHistory } from 'history';
这里我们任选其一即可,就选BrowserRouter吧
2. 从源码探究细节
2.1. BrowserRouter
-
作用
- 创建
history
实例, - 将
children
和history
传入组件<Router/>
并返回
- 创建
-
核心源码
import { createBrowserHistory as createHistory } from 'history'; class BrowserRouter extends React.Component { history = createHistory(this.props); render() { return <Router history={this.history} children={this.props.children} />; } }
可以看到
history
对象实际上是通过createBrowserHistory
创建的,核心逻辑都在<Router/>
组件中,<Router/>
组件接收特定的history
对象,history
对象目前包括hash、browser、memory三种类型。
2.2. Router
-
作用
- 监听路由变化,存储到
state
中 - 通过
Context
向下传递history
、location
、match
等
- 监听路由变化,存储到
-
核心源码
class Router extends React.Component { // 默认命中的路由 static computeRootMatch(pathname) { return { path: '/', url: '/', params: {}, isExact: pathname === '/' }; } constructor(props) { super(props); this.state = { location: props.history.location, }; this._isMounted = false; this._pendingLocation = null; // 监听路由变化 if (!props.staticContext) { this.unlisten = props.history.listen(location => { if (this._isMounted) { this.setState({ location }); } else { this._pendingLocation = location; } }); } } componentDidMount() { this._isMounted = true; if (this._pendingLocation) { this.setState({ location: this._pendingLocation }); } } componentWillUnmount() { // 取消监听 if (this.unlisten) { this.unlisten(); this._isMounted = false; this._pendingLocation = null; } } render() { return ( <RouterContext.Provider value={{ history: this.props.history, location: this.state.location, match: Router.computeRootMatch(this.state.location.pathname), staticContext: this.props.staticContext, }} > <HistoryContext.Provider children={this.props.children || null} value={this.props.history} /> </RouterContext.Provider> ); } }
首先
<Router/>
会声明默认命中路由的规则,即静态方法computeRootMatch
,默认是匹配成功,记住这里的isExact
,后续会使用。在构造方法中监听路由变化,使用的
history
是在BrowserRouter
中通过createBrowserHistory
库创建的实例。注意,
_isMounted
和_pendingLocation
是为了处理懒加载组件,当路由匹配时,此时懒加载组件还未挂载,所以放到_pendingLocation
临时存储,然后在componentDidMount
中更新到state
。
2.3. Route
-
作用
- 通过
matchPath
判断当前<Route/>
的path
是否匹配 - 创建
Provider
,带着当前path
是否匹配以及Context
的其他内容一起共享给子组件
- 通过
-
核心源码
class Route extends React.Component { render() { return ( <RouterContext.Consumer> {context => { const location = this.props.location || context.location; // 如果没有传入path,则通过matchPath匹配 const match = this.props.computedMatch ? this.props.computedMatch // 包裹<Switch/>组件时注入的prop : this.props.path ? matchPath(location.pathname, this.props) : context.match; const props = { ...context, location, match }; let { children, component, render } = this.props; // children必须是单根节点 if (Array.isArray(children) && isEmptyChildren(children)) { children = null; } return ( <RouterContext.Provider value={props}> {props.match ? children ? typeof children === 'function' ? __DEV__ ? evalChildrenDev(children, props, this.props.path) : children(props) : children : component ? React.createElement(component, props) : render ? render(props) : null : typeof children === 'function' ? __DEV__ ? evalChildrenDev(children, props, this.props.path) : children(props) : null} </RouterContext.Provider> ); }} </RouterContext.Consumer> ); } }
-
match
:- 优先比较
computedMatch
,这是<Route/>
组件被<Switch/>
组件包裹时注入的prop
,如果有则直接使用, - 没有
computedMatch
比较path
,若存在path
通过matchPath
处理 - 没有
path
则直接使用match
,这是<Router/>
通过provider
传递的默认匹配规则,这里会匹配成功,所以404 组件不写path
- 优先比较
-
组件的返回结果:
match
是否匹配成功- 匹配
- children 是否存在
- 存在
- children 是函数:执行
- 不是函数就是组件:渲染
- 不存在
- component 是否存在
- 存在:createElement(component, props)
- 不存在:
- render 是否存在
- 存在:执行
- 不存在:null
- render 是否存在
- component 是否存在
- 存在
- children 是否存在
- 不匹配
- children 是函数
- 匹配:执行
- 不匹配:null
- children 是函数
- 匹配
由于可见,优先级分别是:
children
>component
>render
,需要注意的一点是,component通过
React.createElement
创建,在React中会根据type
有没有改变选择是否重用组件,所以为了避免不必要的开销,component属性最好不要写内联函数最后返回
Provider
是为了后续使用高阶组件withRouter
。
-
2.4. Switch
也被称为独占路由
-
作用
- 只返回匹配成功的子组件
<Route/>
- 只返回匹配成功的子组件
-
核心源码
class Switch extends React.Component { render() { return ( <RouterContext.Consumer> {context => { invariant(context, 'You should not use <Switch> outside a <Router>'); const location = this.props.location || context.location; let element, match; // We use React.Children.forEach instead of React.Children.toArray().find() // here because toArray adds keys to all child elements and we do not want // to trigger an unmount/remount for two <Route>s that render the same // component at different URLs. React.Children.forEach(this.props.children, child => { if (match == null && React.isValidElement(child)) { element = child; const path = child.props.path || child.props.from; match = path ? matchPath(location.pathname, { ...child.props, path }) : context.match; } }); return match ? React.cloneElement(element, { location, computedMatch: match }) : null; }} </RouterContext.Consumer> ); } }
为什么不使用
React.Children.toArray().find()
,源码中的注释已经写的很明白了,因为toArray
会为所有子元素添加key
,因而可能出现不同 url显示两个相同<Route/>
卸载或重新挂载的情况。首先看一下最终的渲染结果,若
match
为真则使用匹配成功的组件,并且为其添加location
和computedMatch
,这里重点是computedMatch
,刚才已经整理,<Route/>
组件在匹配时会优先使用这个computedMatch
,如果没有computedMatch
才会使用自己的匹配方式匹配。而<Switch/>
通过匹配算法返回最后一个组件,所以最终在页面呈现的<Route/>
只有一个。下面看匹配算法,遍历所有的子节点(也就是
<Route/>
),对每个组件的path
进行matchPath
,如果匹配成功则保存到变量element
中,一直遍历到最后一个子组件为止。在这里不得不提一下404 组件的两个条件,
- 不写
path
, - 放在
<Switch/>
的最后一个子组件
刚才我们已经整理,Route组件没有
path
时会使用Router
组件提供的match
,match
默认是匹配的,所以不写path
可以使用默认匹配的条件;<Switch/>
组件的匹配方式通过循环完成,一直遍历到最后一个为止,如果放在前面,即使404 组件被匹配到,紧接着下次循环也会被覆盖掉,最终渲染一个null
,所以必须是<Switch/>
的最后一个子组件,保证一定是匹配到的组件。 - 不写
2.5. withRouter
-
作用
- 这是一个高阶组件,取出
<Route/>
通过Provider
共享的数据,给目标组件使用
- 这是一个高阶组件,取出
-
核心源码
function withRouter(Component) { const C = props => { const { wrappedComponentRef, ...remainingProps } = props; return ( <RouterContext.Consumer> {context => { return <Component {...remainingProps} {...context} ref={wrappedComponentRef} />; }} </RouterContext.Consumer> ); }; return hoistStatics(C, Component); }
还记得吗,
<Route/>
组件最后返回一个provider
,共享path
的匹配情况,其实就是为了通过withRouter
给组件使用
2.6. Link
Link本质上也是Context
的Consumer
,取出history
,通过执行history.replace
或history.push
来改变路由
2.7. Redirect
-
作用
- 重定向
-
核心源码
function Redirect({ computedMatch, to, push = false }) { return ( <RouterContext.Consumer> {context => { const { history, staticContext } = context; const method = push ? history.push : history.replace; const location = createLocation( computedMatch ? typeof to === 'string' ? generatePath(to, computedMatch.params) : { ...to, pathname: generatePath(to.pathname, computedMatch.params), } : to ); return ( <Lifecycle onMount={() => { method(location); }} onUpdate={(self, prevProps) => { const prevLocation = createLocation(prevProps.to); if ( !locationsAreEqual(prevLocation, { ...location, key: prevLocation.key, }) ) { method(location); } }} to={to} /> ); }} </RouterContext.Consumer> ); }
Redirect和Route都作为Switch的子组件使用,只是Redirect 组件没有渲染实体,只是为了重定向
最后返回了
Lifecycle
组件,这是一个非常值得学习的内容,在我们的项目场景中可以借鉴这种方式来实现比较复杂的逻辑源码如下:
class Lifecycle extends React.Component { componentDidMount() { if (this.props.onMount) this.props.onMount.call(this, this); } componentDidUpdate(prevProps) { if (this.props.onUpdate) this.props.onUpdate.call(this, this, prevProps); } componentWillUnmount() { if (this.props.onUnmount) this.props.onUnmount.call(this, this); } render() { return null; } }
可以看到最后
render
方法返回了null
表明没有渲染真实 dom,只是借助react 组件的生命周期做了一些事情,所以Redirect 组件最终只是在挂载时执行了
history.push
或history.replace
完成重定向操作,重定向在react-router-dom中有两种方式
- 标签式重定向:
<Redirect to="/home">
- 编程式重定向:
this.props.history.push('/home')
- 标签式重定向:
2.8. Prompt
有了刚才的心得,接下来又是Lifecycle 组件的一次应用
-
作用
- 在页面离开前做拦截询问
-
核心源码
function Prompt({ message, when = true }) { return ( <RouterContext.Consumer> {context => { invariant(context, 'You should not use <Prompt> outside a <Router>'); if (!when || context.staticContext) return null; // 通过 history 调用平台的 询问方法,默认是 window.confirm const method = context.history.block; return ( <Lifecycle onMount={self => { self.release = method(message); }} onUpdate={(self, prevProps) => { if (prevProps.message !== message) { self.release(); self.release = method(message); } }} onUnmount={self => { // 发起询问 self.release(); }} message={message} /> ); }} </RouterContext.Consumer> ); }
具体的拦截方法用的是
context.history.block
,这是在创建Router时接收的平台特有的方法,由于当前是web 环境,所以默认是window.confirm
2.9. BrowserRouter 与 HashRouter 对⽐
之前使用vue-router遇到一个问题,都是hash和browser的区别所导致,所以一并整理了吧
- HashRouter 最简单,每次路由变化不需要服务端接入,根据浏览器的#的来区分 path 就可以;BrowserRouter 需要服务端解析 URL 返回页面,因此使用BrowserRouter需要在后端配置地址映射。
- BrowserRouter 触发路由变化的本质是使⽤ HTML5 history API(
pushState
、replaceState
和popstate
事件) - HashRouter 不⽀持
location.key
和location.state
,动态路由需要通过?
传递参数。 - Hash history 只需要服务端配置一个地址就可以上线,但线上的 web 应⽤ 很少使用这种方式。
2.10. MemoryRouter
把 URL 的历史记录保存在内存中的,不读取、不写⼊地址栏。可以用在⾮浏览器环境,如React Native。
3. 思考与总结
-
LifeCycle 组件
关于组件化,在看Vue源码时给我的启发是组件化的本质是产生虚拟 dom,但看了react-router源码后发现组件还可以仅作为生命周期使用,这也确实是一种很好的逻辑方式,在类组件中一些只涉及生命周期的处理或许可以通过这种方式尝试着来实现,比如说websocket数据的接收或特定数据的处理展示。
-
跨平台
作为一名接触前端不久的同学,见到的第一个有跨平台思路的框架就是react,react-router也是如此,首先,源码的核心放在react-router中,目前阶段衍生出react-router-dom和react-router-native两个库,关于路由的核心处理逻辑在另一个库
history
中,react-router只关注路由如何在react中使用,具体的平台方案交给react-router-dom和react-router-native来做,这种代码低耦合的方式也是非常值得我们学习的。ps:React-native 什么时候发 1.0 呢?
-
<Route/>
中的渲染优先级children
>component
>render
三者能接收到同样的
[route props]
,包括match
、location
和history
,但是当不匹配时children 的match
与其他二者的情况不同会是null
。 -
关于
children
有时候,不管当前路由是否匹配我们都需要渲染⼀些内容,这时候可以⽤
children
。参数与render
完全⼀样。 -
关于
component
如果我们写了
component
,react会通过createElement
创建组件,所以为了避免不必要的开销,尽量不要使用内联函数
投稿来自 [【我的React笔记】 01. 战术后仰:什么是 React-Router 呀](juejin.cn/post/695044…)