上篇文章介紹了前端路由的兩種實現原理,今天我想從react-router
源碼分析下他們是如何管理前端路由的。由於以前一直都是使用V4的版本,因此接下來分析的也是基於react-router v4.4.0版本
的(如下簡稱 V4),歡迎你們提出評論交流。Let's get started。前端
在分析源碼前先回顧如下相關知識,有利於更好的理解源碼設計。react
react
中如何實現不一樣的路由渲染不一樣的組件?react 做爲一個前端視圖框架,自己是不具備除了
view
(數據與界面之間的抽象)以外的任何功能的;上篇文章中咱們是經過觸發的回調函數來操做DOM
,而在 react 中咱們不直接操做DOM
,而是管理抽象出來的VDOM
或者說JSX
,對 react 的來講路由須要管理組件的生命週期
,對不一樣的路由渲染不一樣的組件。git
history
(第三方庫)的使用由於React-Router 是基於
history
這個庫來實現對路由變化的監聽,因此咱們下面會先對這個庫進行簡單的分析。固然咱們主要分析它的監聽模式listen
是如何實現的,這對實現路由是相當重要的,想了解更多其餘的API,請移步history學習更多。github
history
基本用法是這樣的:react-native
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源碼簡析:react-router
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源碼簡析:app
//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
的基本使用,梳理解析思路。
//引用官方例子
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
中有三種類型的組件:
上面Demo中咱們也正使用了這三類組件,爲了方便分析源碼,咱們能夠梳理一個基本流程出來:
<BrowserRouter>
建立一個專門的history對象,並註冊監聽事件。<Route>
匹配的path,並渲染匹配的組件。<Link>
建立一個連接跳轉到你想要渲染的組件。下面咱們就根據上述流程步驟,一步一步解析react-router
代碼實現。
/* 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
判斷是否進行渲染。
源碼中有這樣介紹:"用於匹配單個路徑和呈現的公共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;
}, {})
};
}
複製代碼
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
)。