共计 13937 个字符,预计需要花费 35 分钟才能阅读完成。
上一篇文章咱们讲了 React-Router
的根本用法,并实现了常见的前端路由鉴权。本文会持续深刻 React-Router
讲讲他的源码,套路还是一样的,咱们先用官网的 API 实现一个简略的例子,而后本人手写这些 API 来替换官网的并且放弃性能不变。
本文全副代码曾经上传 GitHub,大家能够拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-router-code
简略示例
本文用的例子是上篇文章开始那个不带鉴权的简略路由跳转例子,跑起来是这样子的:
咱们再来回顾下代码,在 app.js
外面咱们用 Route
组件渲染了几个路由:
import React from 'react';
import {
BrowserRouter as Router,
Switch,
Route,
} from "react-router-dom";
import Home from './pages/Home';
import Login from './pages/Login';
import Backend from './pages/Backend';
import Admin from './pages/Admin';
function App() {
return (
<Router>
<Switch>
<Route path="/login" component={Login}/>
<Route path="/backend" component={Backend}/>
<Route path="/admin" component={Admin}/>
<Route path="/" component={Home}/>
</Switch>
</Router>
);
}
export default App;
每个页面的代码都很简略,只有一个题目和回首页的链接,比方登录页长这样,其余几个页面相似:
import React from 'react';
import {Link} from 'react-router-dom';
function Login() {
return (
<>
<h1> 登录页 </h1>
<Link to="/"> 回首页 </Link>
</>
);
}
export default Login;
这样咱们就实现了一个最简略的 React-Router
的利用示例,咱们来剖析下咱们用到了他的哪些 API,这些 API 就是咱们明天要手写的指标,认真一看,咱们如同只用到了几个组件,这几个组件都是从 react-router-dom
导出来的:
BrowserRouter: 被咱们重命名为了
Router
,他包裹了整个React-Router
利用,感觉跟以前写过的react-redux
的Provider
相似,我猜是用来注入context
之类的。Route: 这个组件是用来定义具体的路由的,接管路由地址
path
和对应渲染的组件作为参数。Switch:这个组件是用来设置匹配模式的,不加这个的话,如果浏览器地址匹配到了多个路由,这几个路由都会渲染进去,加了这个只会渲染匹配的第一个路由组件。
Link:这个是用来增加跳转链接的,性能相似于原生的
a
标签,我猜他外面也是封装了一个a
标签。
BrowserRouter 源码
咱们代码外面最外层的就是BrowserRouter
,咱们先去看看他的源码干了啥,地址传送门:https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/BrowserRouter.js
看了他的源码,咱们发现 BrowserRouter
代码很简略,只是一个壳:
import React from "react";
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} />;
}
}
在这个壳外面还援用了两个库 react-router
和history
,BrowserRouter
仅仅是调用 history
的createHistory
失去一个 history
对象,而后用这个对象渲染了 react-router
的Router
组件。看起来咱们要搞懂 react-router-dom
的源码还必须得去看 react-router
和history
的源码,当初咱们手上有好几个须要搞懂的库了,为了看懂他们的源码,咱们得先理分明他们的构造关系。
React-Router 的我的项目构造
React-Router
的构造是一个典型的 monorepo
,monorepo
这两年开始风行了,是一种比拟新的多项目管理形式,与之绝对的是传统的 multi-repo
。比方React-Router
的我的项目构造是这样的:
留神这里的 packages
文件夹上面有四个文件夹,这四个文件夹每个都能够作为一个独自的我的项目公布。之所以把他们放在一起,是因为他们之前有很强的依赖关系:
react-router:是
React-Router
的外围库,解决一些共用的逻辑react-router-config:是
React-Router
的配置解决,咱们个别不须要应用react-router-dom:浏览器上应用的库,会援用
react-router
外围库react-router-native:反对
React-Native
的路由库,也会援用react-router
外围库
像这样多个仓库,公布多个包的状况,传统模式是给每个库都建一个 git repo
,这种形式被称为multi-repo
。像React-Router
这样将多个库放在同一个 git repo
外面的就是 monorepo
。这样做的益处是如果出了一个 BUG 或者加一个新性能,须要同时改react-router
和react-router-dom
,monorepo
只须要一个 commit
一次性就改好了,公布也能够一起公布。如果是 multi-repo
则须要批改两个 repo
,而后别离公布两个repo
,公布的时候还要协调两个repo
之间的依赖关系。所以当初很多开源库都应用 monorepo
来将依赖很强的模块放在一个 repo
外面,比方 React 源码也是一个典型的monorepo
。
yarn
有一个 workspaces
能够反对 monorepo
,应用这个性能须要在package.json
外面配置workspaces
,比方这样:
"workspaces": {
"packages": ["packages/*"]
}
扯远了,monorepo
能够前面独自开一篇文章来讲,这里讲这个次要是为了阐明 React-Router
分拆成了多个包,这些包之间是有比拟强的依赖的。
后面咱们还用了一个库是 history
,这个库没在React-Router
的monorepo
外面,而是独自的一个库,因为官网把他写的性能很独立了,不肯定非要联合 React-Router
应用,在其余中央也能够应用。
React-Router 架构思路
我之前另一篇文章讲 Vue-Router 的原理提到过,前端路由实现无非这几个关键点:
- 监听 URL 的扭转
- 扭转 vue-router 外面的 current 变量
- 监督 current 变量
- 获取对应的组件
- render 新组件
其实 React-Router
的思路也是相似的,只是 React-Router
将这些性能拆分得更散,监听 URL 变动独立成了 history 库,vue-router 外面的 current
变量在 React 外面是用 Context API
实现的,而且放到了外围库 react-router
外面,一些跟平台相干的组件则放到了对应的平台库 react-router-dom
或者 react-router-native
外面。依照这个思路,咱们本人写的 React-Router
文件夹上面也建几个对应的文件夹:
手写本人的 React-Router
而后咱们顺着这个思路一步一步的将咱们代码外面用到的 API 替换成本人的。
BrowserRouter 组件
BrowserRouter
这个代码后面看过,间接抄过来就行:
import React from "react";
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} />;
}
}
export default BrowserRouter;
react-router 的 Router 组件
下面的 BrowserRouter
用到了 react-router
的Router
组件,这个组件在浏览器和 React-Native
端都有应用,次要获取以后路由并通过 Context API
将它传递上来:
import React from "react";
import HistoryContext from "./HistoryContext.js";
import RouterContext from "./RouterContext.js";
/**
* The public API for putting history on context.
*/
class Router extends React.Component {
// 静态方法,检测以后路由是否匹配
static computeRootMatch(pathname) {return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
}
constructor(props) {super(props);
this.state = {location: props.history.location // 将 history 的 location 挂载到 state 上};
// 上面两个变量是防御性代码,避免根组件还没渲染 location 就变了
// 如果 location 变动时,以后根组件还没渲染进去,就先记下他,等以后组件 mount 了再设置到 state 上
this._isMounted = false;
this._pendingLocation = null;
// 通过 history 监听路由变动,变动的时候,扭转 state 上的 location
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() {
// render 的内容很简略,就是两个 context
// 一个是路由的相干属性,包含 history 和 location 等
// 一个只蕴含 history 信息,同时将子组件通过 children 渲染进去
return (
<RouterContext.Provider
value={{
history: this.props.history,
location: this.state.location,
match: Router.computeRootMatch(this.state.location.pathname),
}}
>
<HistoryContext.Provider
children={this.props.children || null}
value={this.props.history}
/>
</RouterContext.Provider>
);
}
}
export default Router;
上述代码是我精简过的代码,原版代码能够看这里。这段代码次要是创立了两个 context
,将路由信息和history
信息放到了这两个 context
上,其余也没干啥了。对于 React 的 Context API
我在另外一篇文章具体讲过,这里不再赘述了。
history
后面咱们其实用到了 history 的三个 API:
createBrowserHistory: 这个是用在 BrowserRouter 外面的,用来创立一个 history 对象,前面的 listen 和 unlisten 都是挂载在这个 API 的返回对象下面的。
history.listen:这个是用在 Router 组件外面的,用来监听路由变动。
history.unlisten:这个也是在 Router 组件外面用的,是
listen
办法的返回值,用来在清理的时候勾销监听的。
上面咱们来实现这个 history:
// 创立和治理 listeners 的办法
function createEvents() {let handlers = [];
return {push(fn) {handlers.push(fn);
return function () {handlers = handlers.filter(handler => handler !== fn);
};
},
call(arg) {handlers.forEach(fn => fn && fn(arg));
}
}
}
function createBrowserHistory() {const listeners = createEvents();
let location = {pathname: '/',};
// 路由变动时的回调
const handlePop = function () {
const currentLocation = {pathname: window.location.pathname}
listeners.call(currentLocation); // 路由变动时执行回调
}
// 监听 popstate 事件
// 留神 pushState 和 replaceState 并不会触发 popstate
// 然而浏览器的后退后退会触发 popstate
// 咱们这里监听这个事件是为了解决浏览器的后退后退
window.addEventListener('popstate', handlePop);
// 返回的 history 上有个 listen 办法
const history = {listen(listener) {return listeners.push(listener);
},
location
}
return history;
}
export default createBrowserHistory;
上述 history
代码是超级精简版的代码,官网源码很多,还反对其余性能,咱们这里只拎进去外围性能,对官网源码感兴趣的看这里:https://github.com/ReactTraining/history/blob/28c89f4091ae9e1b0001341ea60c629674e83627/packages/history/index.ts#L397
Route 组件
咱们后面的利用外面还有个很重要的组件是 Route
组件,这个组件是用来匹配路由和具体的组件的。这个组件看似是从 react-router-dom
外面导出来的,其实他只是相当于做了一个转发,一成不变的返回了 react-router
的Route
组件:
这个组件其实只有一个作用,就是将参数上的 path
拿来跟以后的 location
做比照,如果匹配上了就渲染参数上的 component
就行。为了匹配 path
和location
,还须要一个辅助办法 matchPath
,我间接从源码抄这个办法了。大抵思路是将咱们传入的参数path
转成一个正则,而后用这个正则去匹配以后的pathname
:
import pathToRegexp from "path-to-regexp";
const cache = {};
const cacheLimit = 10000;
let cacheCount = 0;
function compilePath(path, options) {const cacheKey = `${options.end}${options.strict}${options.sensitive}`;
const pathCache = cache[cacheKey] || (cache[cacheKey] = {});
if (pathCache[path]) return pathCache[path];
const keys = [];
const regexp = pathToRegexp(path, keys, options);
const result = {regexp, keys};
if (cacheCount < cacheLimit) {pathCache[path] = result;
cacheCount++;
}
return result;
}
/**
* Public API for matching a URL pathname to a path.
*/
function matchPath(pathname, options = {}) {if (typeof options === "string" || Array.isArray(options)) {options = { path: options};
}
const {path, exact = false, strict = false, sensitive = false} = options;
const paths = [].concat(path);
return paths.reduce((matched, path) => {if (!path && path !== "") return null;
if (matched) return matched;
const {regexp, keys} = compilePath(path, {
end: exact,
strict,
sensitive
});
const match = regexp.exec(pathname);
if (!match) return null;
const [url, ...values] = match;
const isExact = pathname === url;
if (exact && !isExact) return null;
return {
path, // the path used to match
url: path === "/" && url === ""?"/" : url, // the matched portion of the URL
isExact, // whether or not we matched exactly
params: keys.reduce((memo, key, index) => {memo[key.name] = values[index];
return memo;
}, {})
};
}, null);
}
export default matchPath;
而后是 Route
组件,调用下 matchPath
来看下以后路由是否匹配就行了,以后路由记得从 RouterContext
外面拿:
import React from "react";
import RouterContext from "./RouterContext.js";
import matchPath from "./matchPath.js";
/**
* The public API for matching a single path and rendering.
*/
class Route extends React.Component {render() {
return (
<RouterContext.Consumer>
{context => {
// 从 RouterContext 获取 location
const location = context.location;
const match = matchPath(location.pathname, this.props); // 调用 matchPath 检测以后路由是否匹配
const props = {...context, location, match};
let {component} = this.props;
// render 对应的 component 之前先用最新的参数 match 更新下 RouterContext
// 这样上层嵌套的 Route 能够拿到对的值
return (<RouterContext.Provider value={props}>
{props.match
? React.createElement(component, props)
: null}
</RouterContext.Provider>
);
}}
</RouterContext.Consumer>
);
}
}
export default Route;
上述代码也是精简过的,官网源码还反对函数组件和 render
办法等,具体代码能够看这里:https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Route.js
其实到这里,React-Router
的外围性能曾经实现了,然而咱们开始的例子中还用到了 Switch
和Link
组件,咱们也一起来把它实现了吧。
Switch 组件
咱们下面的 Route
组件的性能是只有 path
匹配上以后路由就渲染组件,也就意味着如果多个 Route
的path
都匹配上了以后路由,这几个组件都会渲染。所以 Switch
组件的性能只有一个,就是即便多个 Route
的path
都匹配上了以后路由,也只渲染第一个匹配上的组件。要实现这个性能其实也不难,把 Switch
的children
拿进去循环,找出第一个匹配的 child
,给它增加一个标记属性computedMatch
,顺便把其余的child
全副干掉,而后批改下 Route
的渲染逻辑,先检测 computedMatch
,如果没有这个再应用matchPath
本人去匹配:
import React from "react";
import RouterContext from "./RouterContext.js";
import matchPath from "./matchPath.js";
class Switch extends React.Component {render() {
return (
<RouterContext.Consumer>
{context => {
const location = context.location; // 从 RouterContext 获取 location
let element, match; // 两个变量记录第一次匹配上的子元素和 match 属性
// 应用 React.Children.forEach 来遍历子元素,而不能应用 React.Children.toArray().find()
// 因为 toArray 会给每个子元素增加一个 key,这会导致两个有同样 component,然而不同 URL 的 <Route> 反复渲染
React.Children.forEach(this.props.children, child => {
// 先检测下 match 是否曾经匹配到了
// 如果曾经匹配过了,间接跳过
if (!match && React.isValidElement(child)) {
element = child;
const path = child.props.path;
match = matchPath(location.pathname, { ...child.props, path});
}
});
// 最终 <Switch> 组件的返回值只是匹配子元素的一个拷贝,其余子元素被忽略了
// match 属性会被塞给拷贝元素的 computedMatch
// 如果一个都没匹配上,返回 null
return match
? React.cloneElement(element, { location, computedMatch: match})
: null;
}}
</RouterContext.Consumer>
);
}
}
export default Switch;
而后批改下 Route
组件,让他先查看computedMatch
:
// ... 省略其余代码 ...
const match = this.props.computedMatch
? this.props.computedMatch
: matchPath(location.pathname, this.props); // 调用 matchPath 检测以后路由是否匹配
Switch
组件其实也是在 react-router
外面,源码跟咱们下面写的差不多:https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Switch.js
Link 组件
Link
组件性能也很简略,就是一个跳转,浏览器上要实现一个跳转,能够用 a
标签,然而如果间接应用 a
标签可能会导致页面刷新,所以不能间接应用它,而应该应用 history API
,history API
具体文档能够看这里。咱们这里要跳转 URL 能够间接应用 history.pushState
。应用history.pushState
须要留神一下几点:
history.pushState
只会扭转history
状态,不会刷新页面。换句话说就是你用了这个 API,你会看到浏览器地址栏的地址变动了,然而页面并没有变动。- 当你应用
history.pushState
或者history.replaceState
扭转history
状态的时候,popstate
事件并不会触发,所以history
外面的回调不会主动调用,当用户应用history.push
的时候咱们须要手动调用回调函数。history.pushState(state, title[, url])
接管三个参数,第一个参数state
是往新路由传递的信息,能够为空,官网React-Router
会往里面加一个随机的key
和其余信息,咱们这里间接为空吧,第二个参数title
目前大多数浏览器都不反对,能够间接给个空字符串,第三个参数url
是可选的,是咱们这里的要害,这个参数是要跳往的指标地址。- 因为
history
曾经成为了一个独立的库,所以咱们应该将history.pushState
相干解决加到history
库外面。
咱们先在 history
外面新加一个 APIpush
,这个 API 会调用 history.pushState
并手动执行回调:
// ... 省略其余代码 ...
push(url) {
const history = window.history;
// 这里 pushState 并不会触发 popstate
// 然而咱们依然要这样做,是为了放弃 state 栈的一致性
history.pushState(null, '', url);
// 因为 push 并不触发 popstate,咱们须要手动调用回调函数
location = {pathname: url};
listeners.call(location);
}
下面说了咱们间接应用 a
标签会导致页面刷新,然而如果不应用 a
标签,Link
组件应该渲染个什么标签在页面上呢?能够轻易渲染个 span
,div
什么的都行,然而可能会跟大家平时的习惯不一样,还可能导致一些款式生效,所以官网还是抉择了渲染一个 a
标签在这里,只是应用 event.preventDefault
禁止了默认行为,而后用 history api
本人实现了跳转,当然你能够本人传 component
参数进去扭转默认的 a
标签。因为是 a
标签,不能兼容 native
,所以Link
组件其实是在 react-router-dom
这个包外面:
import React from "react";
import RouterContext from "../react-router/RouterContext";
// LinkAnchor 只是渲染了一个没有默认行为的 a 标签
// 跳转行为由传进来的 navigate 实现
function LinkAnchor({navigate, ...rest}) {
let props = {
...rest,
onClick: event => {event.preventDefault();
navigate();}
}
return <a {...props} />;
}
function Link({
component = LinkAnchor, // component 默认是 LinkAnchor
to,
...rest
}) {
return (
<RouterContext.Consumer>
{context => {const { history} = context; // 从 RouterContext 获取 history 对象
const props = {
...rest,
href: to,
navigate() {history.push(to);
}
};
return React.createElement(component, props);
}}
</RouterContext.Consumer>
);
}
export default Link;
上述代码是精简版的Link
,根本逻辑跟官网源码一样:https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/Link.js
到这里结尾示例用到的全副 API 都换成了咱们本人的,其实也实现了 React-Router
的外围性能。然而咱们只实现了 H5 history
模式,hash
模式并没有实现,其实有了这个架子,增加 hash
模式也比较简单了,根本架子不变,在 react-router-dom
外面增加一个 HashRouter
, 他的根本构造跟BrowserRouter
是一样的,只是他会调用 history
的createHashHistory
,createHashHistory
外面不仅仅会去监听 popstate
,某些浏览器在hash
变动的时候不会触发 popstate
,所以还须要监听hashchange
事件。对应的源码如下,大家能够自行浏览:
HashRouter: https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/HashRouter.js
createHashHistory: https://github.com/ReactTraining/history/blob/28c89f4091ae9e1b0001341ea60c629674e83627/packages/history/index.ts#L616
总结
React-Router
的外围源码咱们曾经读完了,上面咱们来总结下:
-
React-Router
因为有跨平台的需要,所以分拆了好几个包,这几个包采纳monorepo
的形式治理:react-router
是外围包,蕴含了大部分逻辑和组件,解决context
和路由匹配都在这里。react-router-dom
是浏览器应用的包,像Link
这样须要渲染具体的a
标签的组件就在这里。react-router-native
是react-native
应用的包,外面蕴含了android
和ios
具体的我的项目。
- 浏览器事件监听也独自独立成了一个包
history
,跟history
相干的解决都放在了这里,比方push
,replace
什么的。 -
React-Router
实现时外围逻辑如下:- 应用不刷新的路由 API,比方
history
或者hash
- 提供一个事件处理机制,让
React
组件能够监听路由变动。 - 提供操作路由的接口,当路由变动时,通过事件回调告诉
React
。 - 当路由事件触发时,将变动的路由写入到
React
的响应式数据上,也就是将这个值写到根router
的state
上,而后通过context
传给子组件。 - 具体渲染时将路由配置的
path
和以后浏览器地址做一个比照,匹配上就渲染对应的组件。
- 应用不刷新的路由 API,比方
-
在应用
popstate
时须要留神:- 原生
history.pushState
和history.replaceState
并不会触发popstate
,要告诉React
须要咱们手动调用回调函数。 - 浏览器的后退后退按钮会触发
popstate
事件,所以咱们还是要监听popstate
,目标是兼容后退后退按钮。
- 原生
本文全副代码曾经上传 GitHub,大家能够拿下来玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-router-code
参考资料
官网文档:https://reactrouter.com/web/guides/quick-start
GitHub 源码地址:https://github.com/ReactTraining/react-router/tree/master/packages
文章的最初,感激你破费贵重的工夫浏览本文,如果本文给了你一点点帮忙或者启发,请不要悭吝你的赞和 GitHub 小星星,你的反对是作者继续创作的能源。
作者博文 GitHub 我的项目地址:https://github.com/dennis-jiang/Front-End-Knowledges