前言
实际开发时我们并不会直接使用 react-router,而是在网页应用中使用 react-router-dom,在native 中对应使用 react-router-native。由于一直是做网页开发,此处也主要针对 react-router-dom 的源码进行分析,安装该依赖,会自动安装 react-router 及 history ,无需额外安装。
此次分析基于 react-router-dom v5.2.0 (对应 history v4.10.1)。
react-router-dom 简要介绍
一般我们会这么使用:
import { BrowserRouter, Link, Route, Switch, Redirect } from 'react-router-dom'
function App(){
return (
<BrowserRouter>
<Link to="/">home</Link> | <Link to="/about">About</Link>
<Switch>
<Route path="/" exact component={Home} />
<Route path="/about" exact component={About} />
<Redirect to="/" />
</Switch>
</BrowserRouter>
)
}
上述示例使用的组件可以分为三类,主要也就分为三类:
- routers : 如
BrowserRouter或HashRouter - route matchers: 路由匹配组件,如
Route或Switch - navigation(route changers):如
Link、NavLink、Redirect
底层原理
BrowserRouter 组件
在实例化一个 BrowserRouter 组件时,会在构造函数中调用 createBrowserHistory() 方法创建一个 history 对象,并将其传递给 Router 组件。
// react-router-dom/modules/BrowserRouter.js
import { Router } from "react-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} />;
}
}
BrowserHistory 对象
那我们先来看看 history 对象都包含些什么,其接口类似于:
const history = {
length, // 对应于 window.history.length
location, // 常量,仅在调用 createBrowserHistory 函数时根据 window.location 计算得到
createHref, // 函数,返回一个 basename+path,basename 是传递给 createBrowserHistory 的
push,
replace,
go, // 对应于 window.history.go()
goBack, // 对应于 go(-1)
goForward, // 对应于 go(1)
block,
listen
}
history.push()
当我们调用 history.push() 方法时,若允许跳转则会调用 window.location.pushState() 并 setState,history.replace()操作的处理流程类似,只不过调用的是 replaceState 方法。
我们来看一下 history.push 方法的实现。
// history/modules/createBrowserHistory.js
//省略部分判断逻辑的代码
function push (path, state) {
const action = 'PUSH'
const location = createLocation(path,state, createKeys(), history.location)
transitionManager.confirmTransitionTo(
location,
action,
getUserConfirmation,
ok => {
const href = createHref(location)
const { key, state } = location
// 重点1:改变 history 状态栈
globalHistory.pushState({key, state}, null, href)
// 重点2:改变 history 对象及广播变更
setState({action, location})
}
)
}
setState()
上述可以看到,执行 push 操作会改动 history 状态栈并同时调用了 setState 方法,该方法定义于 createBrowserHistory() 函数内部,主要用于更新 history 对象并通知监听器。代码如下:
// history/modules/createBrowserHistory.js
function setState(nextState) {
// 这就是为什么 history 是 mutable,因为使用了 assign 来改变内部字段的值
Object.assign(history, nextState);
history.length = globalHistory.length;
transitionManager.notifyListeners(history.location, history.action);
}
// history/modules/createTransitionManager.js
function notifyListeners(...args){
listeners.forEach(listener => listener(...args))
}
setState 内部会广播更新,此时会遍历执行所有的监听器,那是如何注册监听器的呢?又是何时注册的呢?
注册监听器主要通过history.listen()方法实现。
history.listen()
function listen(listener){
// 注册自定义监听器
const unlisten = transitionManager.appendListener(listener)
// 为popstate 和 hashchange(若使用) 注册监听器,对应也会执行 setState 方法
checkDOMListener(1)
return () => {
// 移除 popstate 和 hashchange 事件的监听器
checkDOMListener(-1)
// 移除自定义监听器
unlisten()
}
}
可以看到,当注册自定义监听器时,还会检查是否已经为 popstate 事件注册了回调,若没有则会执行注册。
Router 组件
在 Router 组件的构造函数中会调用 history.listen()方法,此时会注册一个监听器,Router 组件有一个内部状态 location,注册的回调主要用于更新这个状态。
// react-router/modules/Router.js 省略部分代码
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.unlisten = props.history.listen(location => {
this.setState({ location })
})
}
componentWillUnmount(){
if(this.unlisten){
this.unlisten()
}
}
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 组件主要是提供一个上下文供子组件消费,而 location 作为内部状态。
路由变更此时则有两种方式:
- 调用 history.push 或 history.replace
- 触发 popstate 事件(如点击浏览器的前进、后退按钮,或者调用 go 等方法)
这两种方式都会调用 history 内部的 setState 方法,不同的是前者需要手动更新 history 状态栈以增加或修改一条记录,后者不会对history状态栈有变更,只是会更改当前状态的指针。
我们可以使用 Link 组件或手动调用 history.push 或 history.replace 方法来改变路由,push() 方法的实现方式上面已经讲过了,接下来我们看看 Link 组件的实现。
Link 组件
const LinkAnchor = ({navigate, onClick, ...rest})=> {
const { target } = rest
const props = {
...rest,
onClick: ev => {
try {
if (onClick) onClick(event);
} catch (ex) {
event.preventDefault();
throw ex;
}
if (
!event.defaultPrevented && //表示当前事件是否调用了 preventDefault 方法
event.button === 0 && // 0 为鼠标左键点击触发,1为中键,2为右键
(!target || target === "_self") && // 处理 "target=_blank" 等设置
!isModifiedEvent(event) // ignore clicks with modifier keys
) {
event.preventDefault();
navigate();
}
}
}
return <a {...props} />;
}
const Link = forwardRef(
(
{ to, replace, component = LinkAnchor, innerRef, ...rest},
forwardedRef
) => {
return (
<RouterContext.Consumer>
{context => {
const { history } = context;
const location = normalizeToLocation(
resolveToLocation(to, context.location),
context.location
);
const href = location ? history.createHref(location) : "";
const props = {
...rest,
href,
navigate() {
const location = resolveToLocation(to, context.location);
const method = replace ? history.replace : history.push;
method(location);
}
};
//... 省略计算 ref 相关内容
return React.createElement(component, props);
}}
</RouterContext.Consumer>
)
}
)
Link 组件需要消费 context,所以其外层必须有一个 Router 组件,Link 组件主要是渲染了一个 a 标签,它会根据设置的 to 属性计算出 href 的值,在点击 a 标签时,若不符合页内导航的条件,则会触发默认行为根据 href 的值进行跳转,若符合页内导航的条件,则会阻止默认事件,调用 navigate 函数,该函数会根据 replace 属性的值来选择调用 history.push 或 history.replace 方法。
Route 组件
为了渲染内容,我们需要使用 Route 组件,我们先来看看实现。
class Route extends React.Component {
render() {
return (
<RouterContext.Consumer>
{context => {
const location = this.props.location || context.location
// computedMatch 属性是由 Switch 组件计算传递下来的
const match = this.props.computedMatch
? this.props.computedMatch
: this.props.path
? matchPath(location.pathname, this.props)
: context.match;
const props = {...context, location, match}
let {children, component, render} = this.props
if(Array.isArray(children) && isEmptyChildren(children)){
children = null
}
return (
<RouterContext.Provider value={props}>
{props.match
? children
? typeof children === 'function' ? children(props) : children
: component
? React.createElement(component, props)
: render
? render(props)
: null
: typeof children === 'function'
? children(props)
: null
}
</RouterContext.Provider>
)
}}
</RouterContext.Consumer>
)
}
}
可以看出,Route 组件是一定会渲染的,只不过对应的内容是否渲染,则需要经过一系列条件判断。当 location.pathname 与 Route 的参数(含 path 、exact 等)匹配时,match 是与 path 属性“首次”匹配所得,当不匹配时,match 的值为 null.
Route 组件提供了新的上下文,若渲染的内容中含有上下文的消费者,则会优先使用 Route 组件提供的上下文。
- 这里之所以使用“首次”两个字,是因为 path 属性可以是一个数组,但是 matchPath 方法在计算所得的 match 是一个 truthy 值时,会在 reduce 的新一轮循环中直接返回这个值,不会进行后续计算。
- 还需要注意的是,不管是否匹配,若指定了Route组件的 children 属性,则一定会调用 children 函数,在该函数中可以读取 match 的值来进行一些操作。
Switch 组件
有时候多个 Route 会同时都匹配一个路径,此时我们就可以使用 Switch 组件来优先渲染第一个匹配的组件。
class Switch extends React.Component {
render(){
return (
<RouterContext.Consumer>
{context => {
const location = this.props.location || context.location
let element, match
React.children.forEach(this.props.children, child => {
// 当前没有匹配且子元素是 React 元素
if(match == null && React.isValidElement(child)){
// 存储子元素
element = child;
const path = child.props.path || child.props.from
// 更新 match,若match 非null,则下一轮循环无法进入if语句块
match = path
? matchPath(location.pathname, {...child.props, path})
: context.match
}
})
return match
? React.cloneElement(element, {location, computedMatch: match})
: null
}}
</RouterContext.Consumer>
)
}
}
从上述源码可以看出,Switch 组件会读取当前的 location ,遍历所有子组件,只渲染第一个匹配的子组件,若一个也不匹配,则渲染 null。
一般在 Switch 内部会使用 Route 组件和 Redirect 组件,Route 组件已经介绍了,是为了匹配就会渲染对应的内容。下面来看一下 Redirect 组件。
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 和 to 计算 */)
if(staticContext){
method(location)
return null
}
// Lifecycle 组件主要是为了在生命周期钩子中执行对应的函数,渲染的仍是 null
return (
<Lifecycle
onMount = {()=> method(location)}
onUpdate={()=> {/* ... */}}
to={to}
/>
)
}}
</RouterContext.Consumer>
)
}
可以看出,只要渲染 Redirect 组件,就会在该组件挂载后调用 history.push(或history.replace)导航至指定的 location。
总结
实例化一个 Router 组件后,会监听 location 的变更,一旦 location 变化,则会更新 Router 组件内部状态,从而触发 Router 组件重新渲染。
触发location 的变更则主要通过两种方式:
- 调用
history.push或histroy.replace方法; - 触发
popstate事件:主要通过点击浏览器的前进、后退按钮或调用 go、goBack、goForward 方法
第一种方式会引起 history 状态栈的变化,如新增一条记录(栈会变化,当前状态指针也会变化)或改变了某条记录(栈会变化,当前状态指针不会改变),第二种方式不会改变 history 状态栈,只会移动当前状态指针。
两种方式都会更新 history 对象一些字段(length、location、action)的值,并通知所有的监听器。
当点击一个 Link 组件或者渲染一个 Redirect 组件时,此时会采用第一种方式更改 location,history 状态栈会发生改变,同时改变 history 对象,然后执行所有的监听器,如此就会更新 Router 组件内部状态 location,触发 Router 组件重新渲染。
当组件的 render 遇到 Switch 组件时,会判断子组件中是否有匹配当前 location 的,然后只会渲染第一个匹配的组件,当子组件没有设置 path/from 属性,此时则依赖于 context.match 来决定是否渲染此组件。
当组件的 render 遇到 Route 组件时,会首先计算是否 match,若 match 则会渲染对应的子元素,否则是渲染 null,需要注意的是,若设置了 Route 组件的 children 属性,则不管是否 match,都会调用该函数。