
起因
目前负责的项目中有一个微信网页,用的是react技术栈。在该项目中增加了一个微信分享功能后,线上ios出现了问题,经排查,定位到了react的路由系统。

这次线上bug,让我决定,先从react-router-dom开始,看看它内部实现了什么。
前端目前用到的就是react-router-dom这个库,它提供了两个高级路由器,分别是BrowserRouter和HashRouter,它两的区别就是一个用的history API ,一个是使用URL的hash部分,接下来我以BrowserRouter为例,做一个解读。
简易过程图

解读(只摘取核心代码进行展示)
首先看看整个react-router-dom提供了点啥?
export {
MemoryRouter,
Prompt,
Redirect,
Route,
Router,
StaticRouter,
Switch,
generatePath,
matchPath,
withRouter,
useHistory,
useLocation,
useParams,
useRouteMatch
} from "react-router";
export { default as BrowserRouter } from "./BrowserRouter.js";
export { default as HashRouter } from "./HashRouter.js";
export { default as Link } from "./Link.js";
export { default as NavLink } from "./NavLink.js";
除了下面它自己实现的四个组件外,其余的都是将react-router提供的组件做了一个引入再导出,那看来底层核心的东西还是在react-router上。
1.先从一个简单demo开始
import { BrowserRouter, Route, Switch, Link } from "react-router-dom"
function App() {
return (
<BrowserRouter>
<div>主菜单</div>
<Link to="/home">home</Link>
<br />
<Link to="/search">search</Link>
<hr />
<Switch>
<Route path="/home" component={Home} />
<Route path="/search" component={Search} />
</Switch>
</BrowserRouter>
)
}
ReactDOM.render(<App />, document.getElementById('root'));
需要通过路由跳转来实现UI变化的组件,要用BrowserRouter作为一个根组件来包裹起来,Route用来盛放页面级的组件。
那按照这种层级关系,我们先来看下BrowersRouter里实现了什么功能。
2. BrowersRouter
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} />;
}
}
非常少量的几行代码,很清晰的看到,核心点是history这个库所提供的函数。组件在render前执行了createHistory这个函数,然后它会返回一个history的对象实例,然后通过props传给Router这个路由器,另外其中包裹的所有子组件,统统传给Router。
这里其实官网上已经说的很清楚,大家用的时候可以多留意下。

那么思路就很清楚,重点放在Router和history库上,看看Router是怎么用这个history对象的,以及这个history对象里又包含了啥,和window.history有什么区别?让我们接着往下走。
3. Router
import HistoryContext from "./HistoryContext.js";
import RouterContext from "./RouterContext.js";
Router是核心的路由器,上面我们已经看到BrowsRouter传递给它了一个history对象。
首先引入了两个context,这里其实就是创建的普通context,只不过拥有特定的名称而已。
它的内部实现是这样
const createNamedContext = name => {
const context = createContext();
context.displayName = name;
return context;
};
// 上述的引用就相当于 HistoryContext = createNamedContext("Router-History")
引入了这两个context后,在来看它的构造函数。
constructor(props) {
super(props);
this.state = {
location: props.history.location
};
this.unlisten = props.history.listen(location => {
this.setState({ location });
});
}
Router组件维护了一个内部状态location对象,初始值为上面提到的在BrowsRouter中创建的history提供的。
之后,执行了history对象提供的listen函数,这个函数需要一个回调函数作为入参,传入的回调函数的功能就是来更新当前Router内部状态中的location的,关于什么时候会执行这个回调,以及listen函数,后面会详细剖析。
componentWillUnmount() {
if (this.unlisten) {
this.unlisten();
}
}
等这个Router组件将要卸载时,就取消对history的监听。
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>
);
}
最后生成的react树,就是由最开始引入的context组成的,然后传入history、location这些值。

总结就是整个Router就是一个传入了history、locaiton和其它一些数据的context的提供者,然后它的子组件作为消费者就可以共享使用这些数据,来完成后面的路由跳转、UI更新等动作。
3. histroy库
在Router组件可以看到已经用到了createBrowserHistory函数返回的history实例了,如:history.location和history.listen,这个库里的封装的函数那是相当多了,细节也很多,我仍然挑最重要的解读。
首先是咱们这个出镜率较高的history提供了哪些属性和方法

看起来都是些熟悉的东西,如push、replace、go这些,都是window对象属性history所提供的。但有些属性其实是重写了的,如push、replace,其它的是做了一个简单封装。
function goBack() {
go(-1);
}
function goForward() {
go(1);
}

Router内部状态location的初始数据,是使用window.location与window.history.state做的重组。
路由系统最为重要的两个切换页面动作,一个是push,一个是replace,我们平时只用Link组件的话,并没有确切的感受,其中Link接受一个props属性,to :string 或者to : object
<link to='/course'>跳转</link>
此时点击它时,调用的就是props.history中重写的push方法。
<Link to='/course' replace>跳转</Link>
如果增加replace属性,则用的就是replace方法
这两个方法主要用的是pushState和replaceState这两个API,它们提供的能力就是可以增加新的window.history中的历史记录和浏览器地址栏上的url,但是又不会发起真正的网络请求。
这是实现单页面应用的关键点。
然后让我们看一下这两个路由跳转方法

精简后,代码还是不少,我解读下。
push中的入参path,是接下来准备要跳转的路由地址。createLocation方法先将这个path,与当前的location做一个合并,返回一个更新的loation。
然后就是重头戏,transitionManager这个对象,让我们先关注下成功回调里面的内容。
通过更新后的location,创建出将要跳转的href,然后调用pushState方法,来更新window.history中的历史记录。
如果你在BrowserRouter中传了forceRefresh这个属性,那么之后就会直接修改window.lcoation.href,来实现页面跳转,但这样就相当于要重新刷新来进行网络请求你的文件资源了。
如果没有传的话,就是调用setState这个函数,注意这个setState并不是react提供的那个,而是history库自己实现的。
function setState(nextState) {
history.length = globalHistory.length;
transitionManager.notifyListeners(history.location, history.action);
}
还是用到了transitionManager对象的一个方法。
另外当我们执行了pushState后,接下来所获取到的window.history都是已经更新的了。
接下来就剩transitionManager这最后的一个点了。
transitionManager是通过createTransitionManager这个函数实例出的一个对象
function createTransitionManager() {
var listeners = [];
function appendListener(fn) {
var isActive = true;
function listener() {
if (isActive) fn.apply(void 0, arguments);
}
listeners.push(listener);
return function () {
isActive = false;
listeners = listeners.filter(function (item) {
return item !== listener;
});
};
}
function notifyListeners() {
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
listeners.forEach(function (listener) {
return listener.apply(void 0, args);
});
}
return { appendListener: appendListener, notifyListeners: notifyListeners };
}
还记的开始时我们在Router组件中已经用过一个history.listen方法,其中内部实现就是用的transitionManager.appendListener方法
function listen(listener) {
var unlisten = transitionManager.appendListener(listener);
checkDOMListeners(1);
return function () {
checkDOMListeners(-1);
unlisten();
};
}
当时我们给listen传入了一个回调函数,这个回调函数是用来通过React的setState来更新组件内部状态的locaton数据,然后又因为这个lcoation传入了Router-context的value中,所以当它发生变化时,所有的消费组件,都会重新render,以此来达到更新UI的目的。
listen的执行细节是,把它的入参函数(这里指更新Rrouter的state.location的函数)会传入到appendListener中。
执行appendListener后,appendListener将这个入参函数推到listeners这个数组中,保存起来。然后返回一个函数用来删除掉推进该数组的那个函数,以此来实现取消监听的功能。
所以当我们使用push,切换路由时,它会执行notifyListeners并传入更新的location。
然后就是遍历listeners,执行我们在listen传入的回调,此时就是最终的去更新Router的location的过程了。
后面的流程,简单说下,Router里面的Route组件通过匹配pathname 和 更新的location ,来决定是否渲染该页面组件,到此整个的路由跳转的过程就结束了。
总结
第一次阅读源码,尽管删减了很多,但还是写了不少。
希望大家可以沿着这个思路,自己也去看看,还是有很多细节值得推敲的。