背景
在使用 React-Router 开发的时候,会遇到以下一些相关的问题:
- React-Router 是如何匹配路径组件的;
- React-Router 是否对 history API 进行了重写,导致代码中 push 可以切换,而在 console 中使用 history.pushState 不能切换页面;
- Window history API 内部的逻辑是怎么的;
带着这些问题和困惑,想了解下 React Router 的源码,看能否解释下上面这些问题的发生原因;
React-Router
项目源码 v5.2.0:github.com/ReactTraini…
项目结构
打开项目,可以看出,React-Router 采用 monorepo 的方式组织 react-router
、react-router-config
、react-router-dom
、react-router-native
;每个文件的包都可以作为单独的包进行发布;
这 4 个包大概的作用是:
- react-router:提供 React Router 核心路由功能,处理公用的路由逻辑;
- react-router-config:React Router 配置文件,主要是暴露 matchRoutes、renderRoutes 这两个 API;
- react-router-dom:浏览器上使用的库,依赖于 react-router 库;
- react-router-native:在 React-Native 上使用的库,同样依赖于 react-router 库;
所以看起来比较核心的就是 react-router-dom、react-router 了;
react-router-dom
进入 react-router-dom 文件夹,进入入口文件 modules/index.js,可以看到除了 BrowserRouter、HashRouter、Link、NavLink 是 react-router-dom 自己实现的外,其他的比如 Router、Route、Switch等方法都是由 react-router 来实现的;
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";
所以我们接下来就从 BrowserRouter、HashRouter 看起;
BrowserRouter/HashRouter
- 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} />;
}
}
- HashRouter
import React from "react";
import { Router } from "react-router";
import { createHashHistory as createHistory } from "history";
class HashRouter extends React.Component {
history = createHistory(this.props);
render() {
return <Router history={this.history} children={this.props.children} />;
}
}
从上可以看出,HashRouter 和 BrowserRouter 只是一个高阶组件,作用是把通过第三方库 history 的函数 createHistory 构造出的的 history 实例,以及 children (可能是React Component/Route/switch/Link组件) 作为 props 的属性传给 Router 这个组件。
所以核心还是在 Router组件 和 history 实例对象上了;
Router 实例
先看下 Router 实例是怎么定义的:
import React from "react";
import PropTypes from "prop-types";
import warning from "tiny-warning";
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);
// 维护location对象,初始值是props中第三方history实例的location
this.state = {
location: props.history.location
};
// This is a bit of a hack. We have to start listening for location
// changes here in the constructor in case there are any <Redirect>s
// on the initial render. If there are, they will replace/push when
// they mount and since cDM fires in children before parents, we may
// get a new location before the <Router> is mounted.
this._isMounted = false;
this._pendingLocation = null;
if (!props.staticContext) {
// 创建对 location 的监听,并传入回调函数
// 通过这个回调函数实现的对于location的变化更新,并重新渲染路由匹配组件
// 注意:history 原生没有 listen 方法,这里的 history 是第三方库 history 的实例
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() {
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>
);
}
}
export default Router;
接下来,我们需要看下 history 这个第三方库具体的实现,是怎么触发的回调函数,以及在什么场景和时机下触发;
history
history v.4.9.0 源码:github.com/ReactTraini…
从 BrowserRouter、HashRouter 的初始化实例可以看出,他们的不同在于传入的不同的 history 实例;因此 history 的实现至关重要;
从总的来看:
- 监听路由变化,触发绑定的回调函数
- 实现 push、replace、go 等路由切换方法
history 项目结构
打开 packages 下面的 history 目录,打开 index.ts 文件,可以看到文件很简单,主要是由 4个部分组成:
- createBrowserHistory
- createHashHistory
- createMemoryHistory
- 各种 utils 方法,提供工具方法
下面就以 createHashHistory 为例子看下去;
createHashHistory
主要是有以下的实现:
-
函数
- getDOMLocation — 获取 location 对象
-
handlePop - 使用 go 方法来回退或者前进时,对页面进行的更新
-
revertPop — 使用 prompt 时候用户点击取消时候的 hack
-
creatHref - 获取完整的 location.href
-
创建事件监听:
- listen:添加监听的回调函数
- checkDOMListeners:绑定/注销 hashChange 事件
-
方法重写:
- push、replace、go、goBack、goForward 方法
-
history 对象:即上文中提到的用于传递给子组件的history实例对象
const history = {
length: globalHistory.length,
action: 'POP',
location: initialLocation,
createHref,
push,
replace,
go,
goBack,
goForward,
block,
listen
};
从返回的 history 来看,可以重点看下 listen 函数、checkDOMListeners 函数:
function listen(listener) {
// 注册回调,append listener
const unlisten = transitionManager.appendListener(listener);
checkDOMListeners(1);
return () => {
checkDOMListeners(-1);
unlisten();
};
}
// 看看 checkDOMListeners 函数
function checkDOMListeners(delta) {
listenerCount += delta;
if (listenerCount === 1 && delta === 1) {
// 为了防止重复监听 只运行一个且只有一个的hashchange事件监听函数
window.addEventListener(HashChangeEvent, handleHashChange);
} else if (listenerCount === 0) {
window.removeEventListener(HashChangeEvent, handleHashChange);
}
}
可以看出 listen 使用了公共工具 transitionManager 来对回调事件进行管理,内部维护了 listener 队列,我们可以来看下其中的主要方法 appendListener、notifyListeners 做了什么:
function appendListener(fn) {
let isActive = true;
function listener(...args) {
if (isActive) fn(...args);
}
listeners.push(listener);
return () => {
isActive = false;
listeners = listeners.filter(item => item !== listener);
};
}
function notifyListeners(...args) {
listeners.forEach(listener => listener(...args));
}
可以看出 appendListener 就是将注册的事件回调函数添加进内部维护的 listener 队列中,notifyListeners 的任务则是遍历该队列并执行这些回调;
那么调用 notifyListeners 的时机在哪里呢?在 createHashHistory.js 文件中搜索一下 notifyListeners,发现是在一个叫 setState 函数中进行调用的:
function setState(nextState) {
Object.assign(history, nextState);
history.length = globalHistory.length;
transitionManager.notifyListeners(history.location, history.action);
}
再来看一下 setState 会在哪些情况下被调用呢?搜索文件发现在 push、replace、handleHashChange 函数中都在调用,所以我们可以梳理出整个 React-Router 路由回调触发逻辑,画了下简图:
为什么调用 history.pushState 不能进行更新页面?
回到问题,之前我猜测,React-Router 是否对 history API 进行了重写,导致代码中 push 可以切换,而在 console 中使用 history.pushState 不能切换页面,在仔细看 push 方法之前,我们可以来先看看在 MDN 中对 pushState 方法的第三个参数 url 定义:
也就是说我们在 console 中使用 pushState 添加一个新的路由,并不会导致浏览器加载该新路由 ,浏览器甚至不会检查该路由是否存在;而在代码中通过调用第三方history的 push 函数,逻辑是会通过上述的 setState 方法,利用 transitionManager 的 notifyListeners 方法,遍历 listeners 队列执行回调,顺利切换路由;
browserRouter、hashRouter 监听的区别
const PopStateEvent = 'popstate';
const HashChangeEvent = 'hashchange';
// BrowserHistory
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);
}
}
// HashHistory
function checkDOMListeners(delta) {
listenerCount += delta;
if (listenerCount === 1 && delta === 1) {
window.addEventListener(HashChangeEvent, handleHashChange);
} else if (listenerCount === 0) {
window.removeEventListener(HashChangeEvent, handleHashChange);
}
}
下面是对 MDN 上 popstate 的解释,需要注意的是调用 history.pushState() 或history.replaceState() 不会触发popstate事件,popstate 事件只会在浏览器某些行为下触发,比如点击后退、前进按钮或者调用 history.back()、history.forward()、history.go() 方法。所以这也是为什么调用 history.pushState 不能进行更新页面的原因;
BrowserRouter 和 HashRouter 的剩下的实现逻辑大同小异,在此就不再赘述了,感兴趣的可以在 modules/createBrowserHistory.js 中查看;
Route 是如何匹配组件的
路由发生变化之后,React是如何匹配到对应组件的呢?
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}>
// 看到这个一串真的是实力劝退,先把 __DEV__ 去掉看
// 通过 match 来判断Route的path是否匹配location
{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>
);
}
}
可见,React Router 是通过 props.match 来判断 Route 的 path 是否匹配 location 的,如果都没有匹配上则不渲染,如果匹配上了就渲染;
Route 渲染的内容有三种类型,分别是:
- children — children如果是函数就直接执行,如果只是一个子元素就直接渲染出来
- component — 组件, 然后去 render 组件
- render — render 方法, 通过方法去 render 这个组件
所以重点还是在于这个 match 是如何计算出来的:
/**
* 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);
}
总结一下就是:
- 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 ( Switch 中计算的 )、matchPath(location.pathname, this.props)(工具方法计算得出)、context.match (根组件 Router 中的 computeRootMatch)都为 null 则代表与该路由不匹配,不进行渲染;
参考链接:
history v.4.9.0 源码:github.com/ReactTraini…
react-router 源码 v5.2.0:github.com/ReactTraini…