前言
上篇文章介绍了前端路由的两种实现原理,今天我想从react-router
源码分析下他们是如何管理前端路由的。因为之前一直都是使用V4的版本,所以接下来分析的也是基于react-router v4.4.0版本
的(以下简称 V4),欢迎大家提出评论交流。Let's get started。
学前知识
在分析源码前先回顾以下相关知识,有利于更好的理解源码设计。
- 在
react
中如何实现不同的路由渲染不同的组件?
react 作为一个前端视图框架,本身是不具有除了
view
(数据与界面之间的抽象)之外的任何功能的;上篇文章中我们是通过触发的回调函数来操作DOM
,而在 react 中我们不直接操作DOM
,而是管理抽象出来的VDOM
或者说JSX
,对 react 的来说路由需要管理组件的生命周期
,对不同的路由渲染不同的组件。
history
(第三方库)的使用
因为React-Router 是基于
history
这个库来实现对路由变化的监听,所以我们下面会先对这个库进行简单的分析。当然我们主要分析它的监听模式listen
是如何实现的,这对实现路由是至关重要的,想了解更多其他的API,请移步history学习更多。
history
history
基本用法是这样的:
import { createBrowserHistory } from 'history';
const history = createBrowserHistory();
// 获取当前location。
const location = history.location;
// 监听当前location的更改。
const unlisten = history.listen((location, action) => {
// location是一个类似window.location的对象
console.log(action, location.pathname, location.state);
});
// 使用push、replace和go来导航。
history.push('/home', { some: 'state' });
// 若要停止监听,请调用listen()返回的函数.
unlisten();
我们查看源码modules下面的index.js,可以看出history 暴露出了七个方法:
export { default as createBrowserHistory } from './createBrowserHistory';
export { default as createHashHistory } from './createHashHistory';
export { default as createMemoryHistory } from './createMemoryHistory';
export { createLocation, locationsAreEqual } from './LocationUtils';
export { parsePath, createPath } from './PathUtils';
通过上面的例子我们来简单比较createBrowserHistory
和createHashHistory
:
createHashHistory
使用 URL 中的hash(#)
部分去创建形如example.com/#/some/path
的路由。createHashHistory源码简析:
function getHashPath() { // 我们不能使用window.location.hash,因为它不是跨浏览器一致- Firefox将预解码它! const href = window.location.href; const hashIndex = href.indexOf('#'); return hashIndex === -1 ? '' : href.substring(hashIndex + 1);//函数返回hush值 }
createBrowserHistory
使用浏览器中的 History API 用于处理 URL,创建一个像example.com/some/path
这样真实的 URL 。//createBrowserHistory.js const PopStateEvent = 'popstate';//变量,下面window监听事件popstate用到 const HashChangeEvent = 'hashchange';//变量,下面window监听事件hashchange用到 function createBrowserHistory(props = {}) { invariant(canUseDOM, 'Browser history needs a DOM'); const globalHistory = window.history; // 创建一个使用HTML5 history API(包括)的history对象 const canUseHistory = supportsHistory(); const needsHashChangeListener = !supportsPopStateOnHashChange(); //... //push方法 function push(path, state) { if (canUseHistory) { globalHistory.pushState({ key, state }, null, href);//在push方法内使用pushState ... } //replace方法 function replace(path, state) { if (canUseHistory) { globalHistory.replaceState({ key, state }, null, href);//在replaceState方法内使用replaceState } } let listenerCount = 0; //注册路由监听事件 function checkDOMListeners(delta) { listenerCount += delta; if (listenerCount === 1 && delta === 1) { window.addEventListener(PopStateEvent, handlePopState);//popstate监听前进/后退事件 if (needsHashChangeListener) window.addEventListener(HashChangeEvent, handleHashChange);//hashchange监听 URL 的变化 } else if (listenerCount === 0) { window.removeEventListener(PopStateEvent, handlePopState); if (needsHashChangeListener) window.removeEventListener(HashChangeEvent, handleHashChange); } } //路由监听 function listen(listener) { const unlisten = transitionManager.appendListener(listener); checkDOMListeners(1); return () => { checkDOMListeners(-1); unlisten(); }; }
listen如何触发监听:
上面
createBrowserHistory.js
中也有介绍如何注册路由监听,我们再看下如何触发路由监听者。 分析事件监听的回调函数handlePopState
,其最终是通过setState
来触发路由监听者,其中notifyListeners
会调用所有的listen
的回调函数,从而达到通知监听路由变化的监听者。
//createBrowserHistory.js
const transitionManager = createTransitionManager();
function setState(nextState) {
Object.assign(history, nextState);
history.length = globalHistory.length;
// 调用所有的listen 的回调函数,从而达到通知监听路由变化的监听者
transitionManager.notifyListeners(history.location, history.action);
}
//事件监听的回调函数handlePopState
function handlePopState(event) {
// 忽略WebKit中无关的popstate事件。
if (isExtraneousPopstateEvent(event)) return;
handlePop(getDOMLocation(event.state));
}
let forceNextPop = false;
//回调执行函数
function handlePop(location) {
if (forceNextPop) {
forceNextPop = false;
setState();
} else {
const action = 'POP';
transitionManager.confirmTransitionTo(
location,
action,
getUserConfirmation,
ok => {
if (ok) {
setState({ action, location });
} else {
revertPop(location);
}
}
);
}
}
// createTransitionManager.js
function notifyListeners(...args) {
//调用所有的listen 的回调函数
listeners.forEach(listener => listener(...args));
}
在react-router
的Router
组件的componentWillMount 生命周期中就调用了history.listen调用,从而达到当路由变化, 会去调用setState 方法, 从而去Render 对应的路由组件。
// react-router/Router.js
constructor(props) {
super(props);
this.state = {
location: props.history.location
};
//调用history.lesten方法监听,setState渲染组件
this.unlisten = props.history.listen(location => {
this.setState({ location });
});
}
componentWillUnmount() {
//路由监听
this.unlisten();
}
小结:以上分析了react-router
如何使用第三方库history
监听路由变化的过程,下面将介绍react-router
是如何结合history
做到SPA
路由变化达到渲染不同组件的效果的,我们先看下react-router
的基本使用,梳理解析思路。
react-router基本使用
//引用官方例子
import React from "react";
import { BrowserRouter as Router, Route, Link } from "react-router-dom";
function BasicExample() {
return (
<Router>
<div>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/about">About</Link>
</li>
<li>
<Link to="/topics">Topics</Link>
</li>
</ul>
<hr />
<Route exact path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/topics" component={Topics} />
</div>
</Router>
);
}
function Home() {
return (
<div>
<h2>Home</h2>
</div>
);
}
function About() {
return (
<div>
<h2>About</h2>
</div>
);
}
function Topics({ match }) {
return (
<div>
<h2>Topics</h2>
<ul>
<li>
<Link to={`${match.url}/rendering`}>Rendering with React</Link>
</li>
<li>
<Link to={`${match.url}/components`}>Components</Link>
</li>
<li>
<Link to={`${match.url}/props-v-state`}>Props v. State</Link>
</li>
</ul>
<Route path={`${match.path}/:topicId`} component={Topic} />
<Route
exact
path={match.path}
render={() => <h3>Please select a topic.</h3>}
/>
</div>
);
}
function Topic({ match }) {
return (
<div>
<h3>{match.params.topicId}</h3>
</div>
);
}
export default BasicExample;
V4
将路由拆成了以下几个包:
react-router
负责通用的路由逻辑react-router-dom
负责浏览器的路由管理react-router-native
负责 react-native 的路由管理
用户只需引入 react-router-dom
或 react-router-native
即可,react-router
作为依赖存在不再需要单独引入。
React Router
中有三种类型的组件:
- 路由器组件(<BrowserRouter>、<HashRouter>)
- 路由匹配组件(<Route>、<Switch>)
- 导航组件(<Link>、<NavLink>、<Redirect>)。
上面Demo中我们也正使用了这三类组件,为了方便分析源码,我们可以梳理一个基本流程出来:
- 使用
<BrowserRouter>
创建一个专门的history对象,并注册监听事件。 - 使用
<Route>
匹配的path,并渲染匹配的组件。 - 使用
<Link>
创建一个链接跳转到你想要渲染的组件。
下面我们就根据上述流程步骤,一步一步解析react-router
代码实现。
BrowserRouter
/* react-router-dom/BrowserRouter.js */
//从history引入createBrowserHistory
import { createBrowserHistory as createHistory } from "history";
import warning from "warning";
//导入react-router 的 Router 组件
import Router from "./Router";
class BrowserRouter extends React.Component {
//创建全局的history对象,这里用的是HTML5 history
history = createHistory(this.props);
render() {
//将 history 作为 props 传递给 react-router 的 Router 组件
return <Router history={this.history} children={this.props.children} />;
}
}
BrowserRouter
的源码在 react-router-dom
中,它是一个高阶组件,在内部创建一个全局的 history
对象(可以监听整个路由的变化),并将 history
作为 props 传递给 react-router
的 Router
组件(Router 组件再会将这个 history 的属性作为 context 传递给子组件)。如下,借助 context 向 Route 传递组件,这也解释了为什么 Router 要在所有 Route 的外面。
//react-router/Router.js
import React from "react";
import RouterContext from "./RouterContext";
//获取history、location、match...
function getContext(props, state) {
return {
history: props.history,
location: state.location,
match: Router.computeRootMatch(state.location.pathname),
staticContext: props.staticContext
};
}
class Router extends React.Component {
//定义Router组件的match属性字段
static computeRootMatch(pathname) {
return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
/*
* path: "/", // 用来匹配的 path
* url: "/", // 当前的 URL
* params: {}, // 路径中的参数
* isExact: pathname === "/" // 是否为严格匹配
*/
}
constructor(props) {
super(props);
this.state = {
location: props.history.location
};
//监听路由的变化并执行回调事件,回调内setState
this.unlisten = props.history.listen(location => {
this.setState({ location });
/*
*hash: "" // hash
*key: "nyi4ea" // 一个 uuid
*pathname: "/explore" // URL 中路径部分
*search: "" // URL 参数
*state: undefined // 路由跳转时传递的 state
*/
});
}
componentWillUnmount() {
//组件卸载时停止监听
this.unlisten();
}
render() {
const context = getContext(this.props, this.state);
return (
<RouterContext.Provider
children={this.props.children || null}
value={context}<!--借助 context 向 Route 传递组件-->
/>
);
}
}
export default Router;
相比于在监听的回调里setState
做的操作,setState
本身的意义更大 —— 每次路由变化 -> 触发顶层 Router
的回调事件 -> Router
进行 setState
-> 向下传递 nextContext
(context
中含有最新的 location
)-> 下面的 Route
获取新的 nextContext
判断是否进行渲染。
Route
源码中有这样介绍:"用于匹配单个路径和呈现的公共API"。简单理解为找到
location
和<router>
的path
匹配的组件并渲染。
//react-router/Route.js
//判断Route的子组件是否为空
function isEmptyChildren(children) {
return React.Children.count(children) === 0;
}
//获取history、location、match...
//父组件没传的话使用context中的
function getContext(props, context) {
const location = props.location || context.location;
const match = props.computedMatch
? props.computedMatch // <Switch> already computed the match for us
: props.path
? matchPath(location.pathname, props)//在./matchPath.js中匹配location.pathname与path
: context.match;
return { ...context, location, match };
}
class Route extends React.Component {
render() {
return (
<RouterContext.Consumer>
{context => {
invariant(context, "You should not use <Route> outside a <Router>");
const props = getContext(this.props, context);
//context 更新 props 和 nextContext会重新匹配
let { children, component, render } = this.props;
// 提前使用一个空数组作为children默认值,如果是这样,就使用null。
if (Array.isArray(children) && children.length === 0) {
children = null;
}
if (typeof children === "function") {
children = children(props);
if (children === undefined) {
children = null;
}
}
return (
<RouterContext.Provider value={props}>
{children && !isEmptyChildren(children)
? children
: props.match//对应三种渲染方式children、component、render,只能使用一种
? component
? React.createElement(component, props)
: render
? render(props)
: null
: null}
</RouterContext.Provider>
);
}}
</RouterContext.Consumer>
);
}
}
Route
接受上层的 Router
传入的 context
,Router
中的 history 监听着整个页面的路由变化,当页面发生跳转时,history
触发监听事件,Router
向下传递 nextContext
,就会更新 Route
的 props
和 context
来判断当前 Route
的 path
是否匹配 location
,如果匹配则渲染,否则不渲染。
是否匹配的依据就是 matchPath
这个函数,在下文会有分析,这里只需要知道匹配失败则 match
为 null
,如果匹配成功则将 match
的结果作为 props
的一部分,在 render 中传递给传进来的要渲染的组件。
从render 方法可以知道有三种渲染组件的方法(children
、component
、render
)渲染的优先级也是依次按照顺序,如果前面的已经渲染后了,将会直接 return。
children
(如果children
是一个方法, 则执行这个方法, 如果只是一个子元素,则直接render
这个元素)component
(直接传递一个组件, 然后去render
组件)render
(render 是一个方法, 通过方法去render
这个组件)
接下来我们看下 matchPath
是如何判断 location
是否符合 path 的。
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, // 用来进行匹配的路径,其实是直接导出的传入 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;
}, {})
};
}
Link
class Link extends React.Component {
static defaultProps = {
replace: false
};
handleClick(event, context) {
if (this.props.onClick) this.props.onClick(event);
if (
!event.defaultPrevented && // 阻止默认事件
event.button === 0 && // 忽略除左击之外的所有内容
!this.props.target && // 让浏览器处理“target=_blank”等。
!isModifiedEvent(event) // 忽略带有修饰符键的单击
) {
event.preventDefault();
const method = this.props.replace
? context.history.replace
: context.history.push;
method(this.props.to);
}
}
render() {
const { innerRef, replace, to, ...props } = this.props;
// eslint-disable-line no-unused-vars
return (
<RouterContext.Consumer>
{context => {
invariant(context, "You should not use <Link> outside a <Router>");
const location =
typeof to === "string"
? createLocation(to, null, null, context.location)
: to;
const href = location ? context.history.createHref(location) : "";
return (
<a
{...props}
onClick={event => this.handleClick(event, context)}
href={href}
ref={innerRef}
/>
);
}}
</RouterContext.Consumer>
);
}
}
从render来看,
Link
其实就是一个<a>标签,在handleClick
中,对没有被preventDefault
的 && 鼠标左键点击的 && 非 _blank 跳转 的&& 没有按住其他功能键的单击进行preventDefault
,然后push
进history
中,这也是前面讲过的 —— 路由的变化 与 页面的跳转 是不互相关联的,V4
在Link
中通过history
库的push
调用了HTML5 history
的pushState
,但是这仅仅会让路由变化,其他什么都没有改变。还记不记得 Router 中的 listen,它会监听路由的变化,然后通过context
更新props
和nextContext
让下层的Route
去重新匹配,完成需要渲染部分的更新。
总结
让我们回想下我们看完基础用法梳理的流程:
- 使用
<BrowserRouter>
创建一个专门的history对象,并注册监听事件。 - 使用
<Route>
匹配的path,并渲染匹配的组件。 - 使用
<Link>
创建一个链接跳转到你想要渲染的组件。
结合源码我们再分析下具体实现
- 使用
BrowserRouter
render一个Router
时创建了一个全局的history
对象,并通过props
传递给了Router
,而在Router
中设置了一个监听函数,使用的是history库的listen,触发的回调里面进行了setState
向下传递 nextContext。 - 当点击页面的
Link
是,其实是点击的a
标签,只不过使用了preventDefault
阻止a
标签的页面跳转;通过给a
标签添加点击事件去执行hitsory.push(to)
。 - 路由改变是会触发
Router
的setState
的,在Router
那章有写道:每次路由变化 -> 触发顶层Router
的监听事件 ->Router
触发setState
-> 向下传递新的nextContext
(nextContext
中含有最新的location
)。 - Route 接受新的 nextContext 通过 matchPath 函数来判断 path 是否与 location 匹配,如果匹配则渲染,不匹配则不渲染。