原文发布于个人 GitHub blog,有全部文章的归档,欢迎 starhtml
react-router 目前做为 react 最流行的路由管理库,已经成为了某种意义上的官方路由库(不过下一代的路由库 reach-router 已经蓄势待发了),而且更新到了 v4 版本,完成了一切皆组件的升级。本文将对 react-router v4(如下简称 rr4) 的源码进行分析,来理解 rr4 是如何帮助咱们管理路由状态的。前端
在分析源码以前,先来对路由有一个认识。在 SPA 盛行以前,还不存在前端层面的路由概念,每一个 URL 对应一个页面,全部的跳转或者连接都经过 <a>
标签来完成,随着 SPA 的逐渐兴盛及 HTML5 的普及,hash 路由及基于 history 的路由库愈来愈多。react
路由库最大的做用就是同步 URL 与其对应的回调函数。对于基于 history 的路由,它经过 history.pushState
来修改 URL,经过 window.addEventListener('popstate', callback)
来监听前进/后退事件;对于 hash 路由,经过操做 window.location
的字符串来更改 hash,经过 window.addEventListener('hashchange', callback)
来监听 URL 的变化。git
class Router {
constructor() {
// 储存 hash 与 callback 键值对
this.routes = {};
// 当前 hash
this.currentUrl = '';
// 记录出现过的 hash
this.history = [];
// 做为指针,默认指向 this.history 的末尾,根据后退前进指向 history 中不一样的 hash
this.currentIndex = this.history.length - 1;
this.backIndex = this.history.length - 1
this.refresh = this.refresh.bind(this);
this.backOff = this.backOff.bind(this);
// 默认不是后退操做
this.isBack = false;
window.addEventListener('load', this.refresh, false);
window.addEventListener('hashchange', this.refresh, false);
}
route(path, callback) {
this.routes[path] = callback || function() {};
}
refresh() {
console.log('refresh')
this.currentUrl = location.hash.slice(1) || '/';
this.history.push(this.currentUrl);
this.currentIndex++;
if (!this.isBack) {
this.backIndex = this.currentIndex
}
this.routes[this.currentUrl]();
console.log('指针:', this.currentIndex, 'history:', this.history);
this.isBack = false;
}
// 后退功能
backOff() {
// 后退操做设置为true
console.log(this.currentIndex)
console.log(this.backIndex)
this.isBack = true;
this.backIndex <= 0 ?
(this.backIndex = 0) :
(this.backIndex = this.backIndex - 1);
location.hash = `#${this.history[this.backIndex]}`;
}
}
复制代码
完整实现 hash-router,参考 hash router 。github
其实知道了路由的原理,想要实现一个 hash 路由并不困难,比较须要注意的是 backOff 的实现,包括 hash router 中对 backOff 的实现也是有 bug 的,浏览器的回退会触发 hashChange
因此会在 history
中 push 一个新的路径,也就是每一步都将被记录。因此须要一个 backIndex
来做为返回的 index 的标识,在点击新的 URL 的时候再将 backIndex 回归为 this.currentIndex
。正则表达式
class Routers {
constructor() {
this.routes = {};
// 在初始化时监听popstate事件
this._bindPopState();
}
// 初始化路由
init(path) {
history.replaceState({path: path}, null, path);
this.routes[path] && this.routes[path]();
}
// 将路径和对应回调函数加入hashMap储存
route(path, callback) {
this.routes[path] = callback || function() {};
}
// 触发路由对应回调
go(path) {
history.pushState({path: path}, null, path);
this.routes[path] && this.routes[path]();
}
// 后退
backOff(){
history.back()
}
// 监听popstate事件
_bindPopState() {
window.addEventListener('popstate', e => {
const path = e.state && e.state.path;
this.routes[path] && this.routes[path]();
});
}
}
复制代码
参考 H5 Routerreact-native
相比 hash 路由,h5 路由再也不须要有些丑陋去的去修改 window.location
了,取而代之使用 history.pushState
来完成对 window.location
的操做,使用 window.addEventListener('popstate', callback)
来对前进/后退进行监听,至于后退则能够直接使用 window.history.back()
或者 window.history.go(-1)
来直接实现,因为浏览器的 history 控制了前进/后退的逻辑,因此实现简单了不少。api
react 做为一个前端视图框架,自己是不具备除了 view (数据与界面之间的抽象)以外的任何功能的,为 react 引入一个路由库的目的与上面的普通 SPA 目的一致,只不过上面路由更改触发的回调函数是咱们本身写的操做 DOM 的函数;在 react 中咱们不直接操做 DOM,而是管理抽象出来的 VDOM 或者说 JSX,对 react 的来讲路由须要管理组件的生命周期,对不一样的路由渲染不一样的组件。浏览器
在前面咱们了解了建立路由的目的,普通 SPA 路由的实现及 react 路由的目的,先来认识一下 rr4 的周边知识,而后就开始对 react-router 的源码分析。缓存
history 库,是 rr4 依赖的一个对 window.history
增强版的 history 库。
源自 history 库,表示当前的 URL 与 path 的匹配的结果
match: {
path: "/", // 用来匹配的 path
url: "/", // 当前的 URL
params: {}, // 路径中的参数
isExact: pathname === "/" // 是否为严格匹配
}
复制代码
仍是源自 history 库,是 history 库基于 window.location 的一个衍生。
hash: "" // hash
key: "nyi4ea" // 一个 uuid
pathname: "/explore" // URL 中路径部分
search: "" // URL 参数
state: undefined // 路由跳转时传递的 state
复制代码
咱们带着问题去分析源码,先逐个分析每一个组件的做用,在最后会有回答,在这里先举一个 rr4 的小 DEMO
rr4 将路由拆成了几个包 —— react-router 负责通用的路由逻辑,react-router-dom 负责浏览器的路由管理,react-router-native 负责 react-native 的路由管理,通用的部分直接从 react-router 中导入,用户只需引入 react-router-dom 或 react-router-native 便可,react-router 做为依赖存在再也不须要单独引入。
import React from 'react'
import { render } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import App from './components/App';
render(){
return(
<BrowserRouter> <App /> </BrowserRouter>
)
)}
复制代码
这是咱们调用 Router 的方式,这里拿 BrowserRouter 来举例。
BrowserRouter 的源码在 react-router-dom 中,它是一个高阶组件,在内部建立一个全局的 history 对象(能够监听整个路由的变化),并将 history 做为 props 传递给 react-router 的 Router 组件(Router 组件再会将这个 history 的属性做为 context 传递给子组件)
render() {
return <Router history={this.history} children={this.props.children} />; } 复制代码
其实整个 Router 的核心是在 react-router 的 Router 组件中,以下,借助 context 向 Route 传递组件,这也解释了为何 Router 要在全部 Route 的外面。
getChildContext() {
return {
router: {
...this.context.router,
history: this.props.history,
route: {
location: this.props.history.location,
match: this.state.match
}
}
};
}
复制代码
这是 Router 传递给子组件的 context,事实上 Route 也会将 router 做为 context 向下传递,若是咱们在 Route 渲染的组件中加入
static contextTypes = {
router: PropTypes.shape({
history: PropTypes.object.isRequired,
route: PropTypes.object.isRequired,
staticContext: PropTypes.object
})
};
复制代码
来经过 context 访问 router,不过 rr4 通常经过 props 传递,将 history, location, match 做为三个独立的 props 传递给要渲染的组件,这样访问起来方便一点(实际上已经彻底将 router 对象的属性彻底传递了)。
在 Router 的 componentWillMount 中, 添加了
componentWillMount() {
const { children, history } = this.props;
invariant(
children == null || React.Children.count(children) === 1,
"A <Router> may have only one child element"
);
// Do this here so we can setState when a <Redirect> changes the
// location in componentWillMount. This happens e.g. when doing
// server rendering using a <sStaticRouter>.
this.unlisten = history.listen(() => {
this.setState({
match: this.computeMatch(history.location.pathname)
});
});
}
复制代码
history.listen
可以监听路由的变化并执行回调事件。
在这里每次路由的变化执行的回调事件为
this.setState({
match: this.computeMatch(history.location.pathname)
});
复制代码
相比于在 setState 里作的操做,setState 自己的意义更大 —— 每次路由变化 -> 触发顶层 Router 的回调事件 -> Router 进行 setState -> 向下传递 nextContext(context 中含有最新的 location)-> 下面的 Route 获取新的 nextContext 判断是否进行渲染。
之因此把这个 subscribe 的函数写在 componentWillMount 里,就像源码中给出的注释:是为了 SSR 的时候,可以使用 Redirect。
Route 的做用是匹配路由,并传递给要渲染的组件 props。
在 Route 的 componentWillReceiveProps 中
componentWillReceiveProps(nextProps, nextContext) {
...
this.setState({
match: this.computeMatch(nextProps, nextContext.router)
});
}
复制代码
Route 接受上层的 Router 传入的 context,Router 中的 history 监听着整个页面的路由变化,当页面发生跳转时,history 触发监听事件,Router 向下传递 nextContext,就会更新 Route 的 props 和 context 来判断当前 Route 的 path 是否匹配 location,若是匹配则渲染,不然不渲染。
是否匹配的依据就是 computeMatch 这个函数,在下文会有分析,这里只须要知道匹配失败则 match 为 null
,若是匹配成功则将 match 的结果做为 props 的一部分,在 render 中传递给传进来的要渲染的组件。
接下来看一下 Route 的 render 部分。
render() {
const { match } = this.state; // 布尔值,表示 location 是否匹配当前 Route 的 path
const { children, component, render } = this.props; // Route 提供的三种可选的渲染方式
const { history, route, staticContext } = this.context.router; // Router 传入的 context
const location = this.props.location || route.location;
const props = { match, location, history, staticContext };
if (component) return match ? React.createElement(component, props) : null; // Component 建立
if (render) return match ? render(props) : null; // render 建立
if (typeof children === "function") return children(props); // 回调 children 建立
if (children && !isEmptyChildren(children)) // 普通 children 建立
return React.Children.only(children);
return null;
}
复制代码
rr4 提供了三种渲染组件的方法:component props,render props 和 children props,渲染的优先级也是依次按照顺序,若是前面的已经渲染后了,将会直接 return。
这里解释一下官网的 tips,component 是使用 React.createElement 来建立新的元素,因此若是传入一个内联函数,好比
<Route path='/' component={()=>(<div>hello world</div>)}
复制代码
的话,因为每次的 props.component 都是新建立的,因此 React 在 diff 的时候会认为进来了一个全新的组件,因此会将旧的组件 unmount,再 re-mount。这时候就要使用 render,少了一层包裹的 component 元素,render 展开后的元素类型每次都是同样的,就不会发生 re-mount 了(children 也不会发生 re-mount)。
咱们紧接着 Route 来看 Switch,Switch 是用来嵌套在 Route 的外面,当 Switch 中的第一个 Route 匹配以后就不会再渲染其余的 Route 了。
render() {
const { route } = this.context.router;
const { children } = this.props;
const location = this.props.location || route.location;
let match, child;
React.Children.forEach(children, element => {
if (match == null && React.isValidElement(element)) {
const {
path: pathProp,
exact,
strict,
sensitive,
from
} = element.props;
const path = pathProp || from;
child = element;
match = matchPath(
location.pathname,
{ path, exact, strict, sensitive },
route.match
);
}
});
return match
? React.cloneElement(child, { location, computedMatch: match })
: null;
}
复制代码
Switch 也是经过 matchPath 这个函数来判断是否匹配成功,一直按照 Switch 中 children 的顺序依次遍历子元素,若是匹配失败则 match 为 null,若是匹配成功则标记这个子元素和它对应的 location、computedMatch。在最后的时候使用 React.cloneElement 渲染,若是没有匹配到的子元素则返回 null
。
接下来咱们看下 matchPath 是如何判断 location 是否符合 path 的。
matchPath 返回的是一个以下结构的对象
{
path, // 用来进行匹配的路径,实际上是直接导出的传入 matchPath 的 options 中的 path
url: path === "/" && url === "" ? "/" : url, // 整个的 URL
isExact, // url 与 path 是不是 exact 的匹配
// 返回的是一个键值对的映射
// 好比你的 path 是 /users/:id,而后匹配的 pathname 是 /user/123
// 那么 params 的返回值就是 {id: '123'}
params: keys.reduce((memo, key, index) => {
memo[key.name] = values[index];
return memo;
}, {})
}
复制代码
这些信息将做为匹配的参数传递给 Route 和 Switch(Switch 只是一个代理,它的做用仍是渲染 Route,Switch 计算获得的 computedMatch 会传递给要渲染的 Route,此时 Route 将直接使用这个 computedMatch 而不须要再本身来计算)。
在 matchPath 内部 compilePath 时,有个
const patternCache = {};
const cacheLimit = 10000;
let cacheCount = 0;
复制代码
做为 pathToRegexp 的缓存,由于 ES6 的 import 模块导出的是值的引用,因此将 patternCache 能够理解为一个全局变量缓存,缓存以 {option:{pattern: }}
的形式存储,以后若是须要匹配相同 pattern 和 option 的 path,则能够直接从缓存中得到正则表达式和 keys。
加缓存的缘由是路由页面大部分状况下都是类似的,好比要访问 /user/123
或 /users/234
,都会使用 /user/:id
这个 path 去匹配,没有必要每次都生成一个新的正则表达式。SPA 在页面整个访问的过程当中都维护着这份缓存。
实际上咱们可能写的最多的就是 Link 这个标签了,咱们从它的 render 函数开始看
render() {
const { replace, to, innerRef, ...props } = this.props; // eslint-disable-line no-unused-vars
invariant(
this.context.router,
"You should not use <Link> outside a <Router>"
);
invariant(to !== undefined, 'You must specify the "to" property');
const { history } = this.context.router;
const location =
typeof to === "string"
? createLocation(to, null, null, history.location)
: to;
const href = history.createHref(location);
// 最终建立的是一个 a 标签
return (
<a {...props} onClick={this.handleClick} href={href} ref={innerRef} /> ); } 复制代码
能够看到 Link 最终仍是建立一个 a 标签来包裹住要跳转的元素,可是若是只是一个普通的带 href 的 a 标签,那么就会直接跳转到一个新的页面而不是 SPA 了,因此在这个 a 标签的 handleClick 中会 preventDefault 禁止默认的跳转,因此这里的 href 并无实际的做用,但仍然能够标示出要跳转到的页面的 URL 而且有更好的 html 语义。
在 handleClick 中,对没有被 “preventDefault的 && 鼠标左键点击的 && 非 _blank
跳转 的&& 没有按住其余功能键的“ 单击进行 preventDefault,而后 push 进 history 中,这也是前面讲过的 —— 路由的变化 与 页面的跳转 是不互相关联的,rr4 在 Link 中经过 history 库的 push 调用了 HTML5 history 的 pushState
,可是这仅仅会让路由变化,其余什么都没有改变。还记不记得 Router 中的 listen,它会监听路由的变化,而后经过 context 更新 props 和 nextContext 让下层的 Route 去从新匹配,完成须要渲染部分的更新。
handleClick = event => {
if (this.props.onClick) this.props.onClick(event);
if (
!event.defaultPrevented && // onClick prevented default
event.button === 0 && // ignore everything but left clicks
!this.props.target && // let browser handle "target=_blank" etc.
!isModifiedEvent(event) // ignore clicks with modifier keys
) {
event.preventDefault();
const { history } = this.context.router;
const { replace, to } = this.props;
if (replace) {
history.replace(to);
} else {
history.push(to);
}
}
};
复制代码
withRouter 的做用是让咱们在普通的非直接嵌套在 Route 中的组件也能得到路由的信息,这时候咱们就要 WithRouter(wrappedComponent)
来建立一个 HOC 传递 props,WithRouter 的其实就是用 Route 包裹了 SomeComponent 的一个 HOC。
建立 Route 有三种方法,这里直接采用了传递 children
props 的方法,由于这个 HOC 要原封不动的渲染 wrappedComponent(children
props 比较少用获得,某种程度上是一个内部方法)。
在最后返回 HOC 时,使用了 hoistStatics 这个方法,这个方法的做用是保留 SomeComponent 类的静态方法,由于 HOC 是在 wrappedComponent 的外层又包了一层 Route,因此要将 wrappedComponent 类的静态方法转移给新的 Route,具体参见 Static Methods Must Be Copied Over。
如今回到一开始的问题,从新理解一下点击一个 Link 跳转的过程。
有两件事须要完成:
过程以下:
hitsory.push(to)
,这个函数实际上就是包装了一下 window.history.pushState()
,是 HTML5 history 的 API,可是 pushState 以后除了地址栏有变化其余没有任何影响,到这一步已经完成了目标1:路由的改变。看到这里相信你已经可以理解前端路由的实现及 react-router 的实现,可是 react-router 有不少的不足,这也是为何 reach-router 的出现的缘由。
在下篇文章,我会介绍如何作一个能够缓存的 Route —— 好比在列表页跳转到详情页再后退的时候,恢复列表页的模样,包括状态及滚动位置等。
仓库的地址: react-live-route,喜欢能够 star,欢迎提出 issue。
原文发布于个人 GitHub blog,有全部文章的归档,欢迎 star。