🤔 遇到了两个问题
目前保险业务用的是react技术栈,在开发过程中遇到了两个路由相关的奇怪问题: 【示例代码】
1⃣️.【问题1】在使用React-Router的basename功能的时候发现,无论是使用 HashRouter 还是 BrowserRouter ,路由中有没有basename都能够匹配到对应的组件,哪怕我们已经给路由加上了 exact 即严格匹配规则.
2⃣️.【问题2】 在排查 【问题1👆】 的时候发现在浏览器的 Console 中对hash进行修改能正常切换页面,但是用history修改url却不会. 然而在代码中使用history API(history.push)修改路由却又是可以的😵 (猜测是React-Router重写了history的API).
更改hash能正确切换路由
使用history.pushState不能正确切换路由 上面2个问题涉及到了【React-Router是如何匹配对应路径组件】,【React如何重写window.history API】 以及 【window.history API内部逻辑】等等问题. 所以我就借着解决上面两个问题的想法去了解了一下React-Router的源码。在下面讲解文章的过程中,顺带解释一下业务中遇到的问题以及原因.
😺 React-Router
React-Router的源码同学们可以在 github.com/ReactTraini... 下载.
项目结构
打开下载好的React-Router项目,如下图所示,可以看到最最核心部分是packages文件下的四个文件夹,分别是 react-router ,react-router-config ,react-router-dom ,react-router-native ,这个四个文件夹每个都可以作为单独的包进行发布,但是React把它们都放在一个仓库中,是一种典型的 monorepo 代码组织方式. 👉 什么是monorepo

- 那这四个文件夹分别有什么用呢?
- react-router :是
React-Router
的核心库,处理一些共用的逻辑.- react-router-config :是
React-Router
的配置处理,我们一般不需要使用- react-router-dom :浏览器上使用的库,会引用
react-router
核心库.- react-router-native :支持
React-Native
的路由库,也会引用react-router
核心库. 从上面的解释来看对于我们前端H5开发的同学来说最重要的就是 react-router 和 react-router-dom 这两个文件了.
- 打开 react-router-dom 根文件index.js 可以看到除了BrowserRouter,HashRouter,Link以及NavLink这四个组件是react-router-dom 自己独立实现的,其他的(比如最基础的Router,Route,Switch组件等等)都来自react-router里的公用组件.
javascript
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";
HashRouter & BrowserRouter
让我们先从下面一个简单的例子开始吧:
javascript
//或者在这里 import {HashRouter as Router } from 'react-router-dom'
import { Switch, BrowserRouter as Router, HashRouter, Route, Link } from 'react-router-dom'
<Router basename="h5">
<div>
<h2>React-Router</h2>
<ul>
<li>
<Link to="/router1">Router-1 </Link>
</li>
<li>
<Link to="/router2">Router-2</Link>
</li>
</ul>
<Switch>
<Route path="/:pathname" children={<Child />} />
</Switch>
</div>
</Router>
可以看到,HashRouter/BrowserRouter 是所有组件Switch、Route、Link等组件的父组件,也是一个根组件,所以我们以它作为入口看下它实现了一些什么功能:
- HashRouter
scala
import { createHashHistory as createHistory } from "history";
import { Router } from "react-router";
class HashRouter extends React.Component {
history = createHistory(this.props);
render() {
return <Router history={this.history} children={this.props.children} />;
}
}
export default HashRouter;
- BrowserRouter
scala
// 与HashRouter唯一的区别在这里,引入的是createBrowserHistory
// 而不是createHashHistory
import { createBrowserHistory as createHistory } from "history";
import { Router } from "react-router";
class BrowserRouter extends React.Component {
history = createHistory(this.props);
render() {
return <Router history={this.history} children={this.props.children} />;
}
}
export default BrowserRouter;
显然,HashRouter 和 BrowserRouter 只是一个壳子(HOC),它们作用只是把通过构造函数 createHistory
构造出的的 history实例 以及children(可能是React Component ,也可能是Route组件或者switch/Link组件)作为props的属性传给 Router 这个路由器。
所以我们的重点自然而然就放在了 Router组件 和 history实例对象 上了.
✨ Router
先整体看下Router组件源码里面长什么样:
kotlin
class Router extends React.Component {
1.默认命中路由
static computeRootMatch(pathname) {
return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
}
2. 构造函数部分,创建对history的监听
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 });
}
}
3. 卸载组件之前,取消监听对history的监听
componentWillUnmount() {
if (this.unlisten) {
this.unlisten();
this._isMounted = false;
this._pendingLocation = null;
}
}
render() {
return (
4.创建context,传入必要信息,供子组件使用
<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>
);
}
}
export default Router;
- 比较重要部分的是构造函数这块:
kotlin
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;
}
});
}
}
- Router有一个内部的location对象 ,初始值为
props.history.location
,即history实例中的location属性 - 通过history对象上的listen方法传入回调函数,这个回调函数会在路由发生变化之后更新组件内部的location对象 ------
this.setState({ location })
. 提前剧透一下,React就是通过这样的方式来更新路由并重新渲染路由匹配组件的。 - 具体是怎么注册的监听函数,怎么触发的回调,在什么时机触发,在下一部分 History 会详细分析.
🍁 History
上文提到了HashRouter 和 BrowserRouter的唯一区别在于引入的history实例对象的构造函数不一样,而history是React封装的三方库,具体见这里👉 History.
history在整个React-Router中有着举足轻重的地位,也是实现React路由的核心所在,简单来说它主要有俩作用:
- 监听路由变化,触发对应的回调函数
- 对push,replace,go等路由方法进行重写
为了搞明白文章开头的那几个问题,不扒开这个库的内部看个清楚是怎么个实现法是不行的,😭路漫漫其修远兮,吾将上下而求索.
项目结构
打开这个库的index.js文件可以发现里面的内容很简单,按功能主要分为四大模块:
☑️ 各种公用函数
- 其中最重要的是一个叫做
createTransitionManager
的构造函数,它是一个针对路由切换时的相同操作抽离的一个公共方法/过度管理工具,路由切换的操作器,拦截器和订阅者都存在于此 ☑️ createMemoryHistory(主要用于ssr,本文不考虑)
☑️ createBrowserHistory
☑️ createHashHistory
因为hash路由和browser路由实现方式大差不差,所以之后将以createHashHistory 为例进行讲解,具体到两者本身的差别的时候再具体分析.
createHashHistory
大概浏览一下构造函数createHashHistory内部,主要有:
- 相关的工具函数
- getDOMLocation --- 获取location对象:【问题1】的答案就在该方法里
- handlePop - 使用go方法来回退或者前进时,对页面进行的更新
- revertPop --- 使用prompt时候用户点击取消时候的hack
- creatHref - 获取完整的location.href
- 监听路由变化的函数
- listen - 注册Router里的回调函数
- checkDOMListeners - 注册/注销监听事件
- 原生方法重写&封装
- push, replace, go, replace, goBack, goForward
- history对象 即上文中提到的用于传递给子组件的history实例对象
这个history实例对象返回的东西很多,我们挑几个比较重要的有代表性的来说:
1. listen
前文中说到,在Router组件里使用了history的listen方法来进行注册监听事件,传入回调。那我们就来看看listen方法到底做了些什么:
scss
function listen(listener) {
var unlisten = transitionManager.appendListener(listener);
checkDOMListeners(1);
return function() {
checkDOMListeners(-1);
unlisten();
};
}
//注册hash变化监听函数
function checkDOMListeners(delta) {
listenerCount += delta;
//这样写的目的是为了防止重复监听 只运行一个且只有一个的hashchange事件监听函数
if (listenerCount === 1 && delta === 1) {
window.addEventListener(HashChangeEvent$1, handleHashChange);
//只有listenerCount为0的时候才会取消监听
} else if (listenerCount === 0) {
window.removeEventListener(HashChangeEvent$1, handleHashChange);
}
}
从代码可以看出,它又是由 transitionManager 内部 appendListener 方法实现的,前文提到了 transitionManager 是一个抽离的一个公共方法,用于过度管理的工具,它里面主要维护了一个listener队列,以及四个方法分别是 setPrompt , confirmTransitionTo , appendListener 和 notifyListeners .
前两个函数主要跟block,prompt弹窗有关,和本文内容没有太多的联系,有兴趣的同学可以自己看看,不过作者貌似打算移除这个令人头秃的功能了,见此 🧭.
所以我们主要看 appendListener 和 notifyListeners 这两个方法干了些什么:
- appendListener & notifyListeners
javascript
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);
});
}
这两个方法的代码量都很少,目的也非常清晰。appendListener就是将注册的事件回调函数添加进内部维护的listener队列中,notifyListeners的任务则是遍历该队列并执行这些回调们.
- ❓那什么时候才会调用notifyListeners触发回调函数呢? 我在history.js文件中全局搜索了一下notifyListeners,发现只有一个叫做setState的方法调用了它(注意此setState非React的setState),具体见👇
ini
function setState(nextState) {
_extends(history, nextState);
history.length = globalHistory.length;
transitionManager.notifyListeners(history.location, history.action);
}
而setState函数在很多地方都会被调用,比如push,replace,go等等操作改变Route的方法中以及 hashchange事件的回调函数里.
所以到这里已经很清晰整个React-Router路由回调触发逻辑了:
📖 总结一下:
- HashRouter/BrowserRouter创建了History实例对象通过props传递给根组件Router
- Router通过props.history.listen(callback)传入路由变化的回调函数callback
- 该callback会通过history中实例化的transitionManager的appendListener方法推入内部维护的listeners队列中
- 在路由变化的时候(比如push,replace等操作)会调用history的setState函数,该函数内部会触发transitionManager.notifyListener,遍历执行listeners队列中所有任务
2. push
之前我们猜测很有可能是React-Router重写了History API才导致了【问题2】------ 【代码中使用history API(history.push)对url进行修改能切换路由,但在console中使用window.history.pushState却不能】
在探究这个问题之前,我们首先需要知道为什么在console中window.history.pushState不能如预料的那样切换页面: MDN中对history.pushState API中第三个参数URL解释道:
请注意,浏览器不会在调用pushState() 之后尝试加载此URL,但可能会稍后尝试加载URL,例如在用户重新启动浏览器之后。
也就是说我们在console中使用pushState添加一个新的路由,并不会导致浏览器加载该新路由 ,浏览器甚至不会检查该路由是否存在。React-Router也正是利用这一点实现前端SPA路由的.
现在咱们再来看看 createBrowserHistory 里的push方法是怎么实现的:
ini
function push(path, state) {
var action = "PUSH";
//1. 根据path返回一个新的location对象
var location = createLocation(path, state, createKey(), history.location);
//2. 执行跳转
transitionManager.confirmTransitionTo(
location,
action,
getUserConfirmation,
function(ok) {
if (!ok) return;
var href = createHref(location);
var key = location.key,
state = location.state;
if (canUseHistory) {
globalHistory.pushState(
{
key: key,
state: state
},
null,
href
);
if (forceRefresh) {
window.location.href = href;
} else {
var prevIndex = allKeys.indexOf(history.location.key);
var nextKeys = allKeys.slice(0, prevIndex + 1);
nextKeys.push(location.key);
allKeys = nextKeys;
3. 新的location对象会传入setState,setState内部会进行更新操作
setState({
action: action,
location: location
});
}
} else {
window.location.href = href;
}
}
);
}
- 首先,函数第一个入参path,是准备跳转的新url. 通过createLocation方法将这个path与当前的location做一个合并,返回一个更新的location.
- 然后,通过transitionManager.confirmTransitionTo方法进行页面跳转,该方法内部的回调函数里面还有一些小细节,比如
forceRefresh
, 如果前端路由使用的是BrowserRouter,并且传入该字段, 那在路由发生变化的时候,React就会通过强制修改location.href进行页面跳转了(页面重新刷新)
ini
if (forceRefresh) {
window.location.href = href;
}
- 最后,如果 forceRefresh为false, 代码会走到
setState({action, location})
这个分支, 然后同样的 -> 触发notifyListener -> 遍历listeners队列执行回调. 所以也这也是我们在【问题2】中所说的,在代码里面使用history.push API的时候能顺利执行路由切换的原因.
❓最后还有一个小问题,那为什么在console中更改location.hash可以进行页面更换呢?我们来看一下两种方式注册的监听函数是怎么样的吧,我把相关代码都放在下面了:
ini
var PopStateEvent = "popstate";
var HashChangeEvent = "hashchange";
//createBrowserHistory
function checkDOMListeners(delta) {
listenerCount += delta;
if (listenerCount === 1 && delta === 1) {
window.addEventListener(PopStateEvent, handlePopState);
if (needsHashChangeListener)
window.addEventListener(HashChangeEvent, handleHashChange);
} else if (listenerCount === 0) {
window.removeEventListener(PopStateEvent, handlePopState);
if (needsHashChangeListener)
window.removeEventListener(HashChangeEvent, handleHashChange);
}
}
//createHashHistory
function checkDOMListeners(delta) {
listenerCount += delta;
if (listenerCount === 1 && delta === 1) {
window.addEventListener(HashChangeEvent$1, handleHashChange);
} else if (listenerCount === 0) {
window.removeEventListener(HashChangeEvent$1, handleHashChange);
}
}
可以看到,Hash路由注册的是 hashchange 事件,而Browser路由注册的是 popstate 事件,到这里,聪明如你应该明白怎么肥事了吧:我们在 console 中更改hash会触发 hashchange 事件 -> setState更新路由 -> 页面切换,但是使用history.pushState却不会触发popstate事件,所以无法切换到对应路径的页面了。
3.getDOMLocation
特意讲一下这个方法主要是为了解答一下【问题1】
lua
function getDOMLocation() {
var path = decodePath(getHashPath());
warning(
!basename || hasBasename(path, basename),
"You are attempting to use a basename on a page whose URL path does not begin " +
'with the basename. Expected path "' +
path +
'" to begin with "' +
basename +
'".'
);
if (basename) path = stripBasename(path, basename);
return createLocation(path);
}
function stripBasename(path, prefix) {
return hasBasename(path, prefix) ? path.substr(prefix.length) : path;
}
function hasBasename(path, prefix) {
return (
path.toLowerCase().indexOf(prefix.toLowerCase()) === 0 &&
"/?#".indexOf(path.charAt(prefix.length)) !== -1
);
}
在使用basename的时候,React在获取location信息时会特意查看path中是否带有basename,如果有就会去掉. 比如,basename为h5,当我们的path为/h5/index,React就会自动把basename过滤为/index,在存入location里面.
这也就是为什么我们在去掉basename之后仍然能给匹配到相应的组件的原因. 我猜测React之所以这么做也许是为了兼容吧(实在想不出其他的原因了😂 orz)
Two Tips
⭕️ history.pushState() 和 history.replaceState() 都不会触发 popstate 事件. popstate事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮(或者在JavaScript中调用history.back()、history.forward()、history.go()方法)。
⭕️ 注意到一个有趣的地方,在BrowserHistory中如果 needsHashChangeListener
为true,那React会使用hash路由的方式,我们再看看什么时候 needsHashChangeListener === true
呢:
csharp
var needsHashChangeListener = !supportsPopStateOnHashChange();
......
/**
* Returns true if browser fires popstate on hash change.
* IE10 and IE11 do not.
*/
function supportsPopStateOnHashChange() {
return window.navigator.userAgent.indexOf("Trident") === -1;
}
使用Trident内核的浏览器,果然又是IE(除了IE10/11) 是不能使用history API的.
❄️ Route
🤔 到这里,对于前端路由我们还剩一个小问题, 路由发生变化之后,React是如何匹配到对应组件的呢?
老样子,show guys the code:
kotlin
class Route extends React.Component {
render() {
return (
<RouterContext.Consumer>
{context => {
invariant(context, "You should not use <Route> outside a <Router>");
const location = this.props.location || context.location;
//👇 组件通过该match变量判断是否匹配
const match = this.props.computedMatch
? this.props.computedMatch // <Switch> already computed the match for us
: this.props.path
? matchPath(location.pathname, this.props)
: context.match;
const props = { ...context, location, match };
let { children, component, render } = this.props;
// Preact uses an empty array as children by
// default, so use null if that's the case.
if (Array.isArray(children) && isEmptyChildren(children)) {
children = null;
}
return (
<RouterContext.Provider value={props}>
//通过match来判断Route的path是否匹配location
{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>
);
}
}
可见,React是通过props.match来判断Route的path是否匹配location的,如果没有匹配上(props.match 为 null)则不渲染,如果匹配上了就渲染.
忽略开发模式_dev_下的情况,Route 渲染的内容有三种类型,分别是:
- children --- children为函数直接执行,如果只是一个子元素就直接渲染这个子元素
- component --- 直接传递一个组件, 然后去render组件
- render --- render 是一个方法, 通过方法去render这个组件
所以重点还是在于这个match是如何计算出来的:
ini
//Route.js
const location = this.props.location || context.location;
const match = this.props.computedMatch
// <Switch> already computed the match for us
? this.props.computedMatch
: this.props.path
? matchPath(location.pathname, this.props)
: context.match;
//macthPath.js
function matchPath(pathname, options = {}) {
if (typeof options === "string") options = { path: options };
const { path, exact = false, strict = false, sensitive = false } = options;
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,
url: path === "/" && url === "" ? "/" : url,
isExact,
params: keys.reduce((memo, key, index) => {
memo[key.name] = values[index];
return memo;
}, {})
};
}
this.props.computedMatch
是当我们在Route外层使用了Switch组件的时候,Switch组件会自动帮我们计算是否匹配上了路径,并把match作为props属性传递给Route.- 否则在不使用Switch的情况下,我们直接取Route上的path
this.props.path
和location进行对比匹配. - 如果Route上没有path属性,React会直接判断是否是匹配上了根路径
/
,这种时候就直接使用context.match
, 也就是根组件Router中的computeRootMatch
. 如果this.props.computedMatch
、matchPath(location.pathname, this.props)
、context.match
都为null则代表与该路由不匹配,不进行渲染.
📖 总结

文章最后,我们来梳理一下React-Router的整个路由流程:
- 在
HashRouter/BrowserRouter
中通过history第三方库提供的createHashHistory/createBrowserHistory
创建了一个history实例对象,并通过props传递给组件Router. - Router内部维护了一个location对象,初始值为
props.history.location
. Router给props.history.listen
方法中传入了一个监听回调函数,在该回调函数里会更新location对象. props.history.listen
是通过transitionManager.appendListener
方法把回调推入history实例内部维护的listeners队列中.- 当路由改变的时候(push,replace,点击Link)会触发history对象的setState方法 -> 调用
transitionManager.notifyListeners
方法 -> 遍历执行listeners中所有回调任务. - listeners的回调任务中有在Router中注册的回调函数,也会被执行,所以会更新Router内部维护的location对象,因为location对象是通过context传递给比如Switch,Route等消费组件的,所以此时所有的消费组件也会更新,进行重新渲染.