前言
上篇文章介绍了前端路由的两种实现原理,今天我想从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>创建一个链接跳转到你想要渲染的组件。
结合源码我们再分析下具体实现
- 使用
BrowserRouterrender一个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 匹配,如果匹配则渲染,不匹配则不渲染。