原文發佈於個人 GitHub blog,有全部文章的歸檔,歡迎 starhtml
react-router 目前做爲 react 最流行的路由管理庫,已經成爲了某種意義上的官方路由庫(不過下一代的路由庫 reach-router 已經蓄勢待發了),而且更新到了 v4 版本,完成了一切皆組件的升級。本文將對 react-router v4(如下簡稱 rr4) 的源碼進行分析,來理解 rr4 是如何幫助咱們管理路由狀態的。前端
在分析源碼以前,先來對路由有一個認識。在 SPA 盛行以前,還不存在前端層面的路由概念,每一個 URL 對應一個頁面,全部的跳轉或者連接都經過 <a>
標籤來完成,隨着 SPA 的逐漸興盛及 HTML5 的普及,hash 路由及基於 history 的路由庫愈來愈多。react
路由庫最大的做用就是同步 URL 與其對應的回調函數。對於基於 history 的路由,它經過 history.pushState
來修改 URL,經過 window.addEventListener('popstate', callback)
來監聽前進/後退事件;對於 hash 路由,經過操做 window.location
的字符串來更改 hash,經過 window.addEventListener('hashchange', callback)
來監聽 URL 的變化。git
class Router {
constructor() {
// 儲存 hash 與 callback 鍵值對
this.routes = {};
// 當前 hash
this.currentUrl = '';
// 記錄出現過的 hash
this.history = [];
// 做爲指針,默認指向 this.history 的末尾,根據後退前進指向 history 中不一樣的 hash
this.currentIndex = this.history.length - 1;
this.backIndex = this.history.length - 1
this.refresh = this.refresh.bind(this);
this.backOff = this.backOff.bind(this);
// 默認不是後退操做
this.isBack = false;
window.addEventListener('load', this.refresh, false);
window.addEventListener('hashchange', this.refresh, false);
}
route(path, callback) {
this.routes[path] = callback || function() {};
}
refresh() {
console.log('refresh')
this.currentUrl = location.hash.slice(1) || '/';
this.history.push(this.currentUrl);
this.currentIndex++;
if (!this.isBack) {
this.backIndex = this.currentIndex
}
this.routes[this.currentUrl]();
console.log('指針:', this.currentIndex, 'history:', this.history);
this.isBack = false;
}
// 後退功能
backOff() {
// 後退操做設置爲true
console.log(this.currentIndex)
console.log(this.backIndex)
this.isBack = true;
this.backIndex <= 0 ?
(this.backIndex = 0) :
(this.backIndex = this.backIndex - 1);
location.hash = `#${this.history[this.backIndex]}`;
}
}
複製代碼
完整實現 hash-router,參考 hash router 。github
其實知道了路由的原理,想要實現一個 hash 路由並不困難,比較須要注意的是 backOff 的實現,包括 hash router 中對 backOff 的實現也是有 bug 的,瀏覽器的回退會觸發 hashChange
因此會在 history
中 push 一個新的路徑,也就是每一步都將被記錄。因此須要一個 backIndex
來做爲返回的 index 的標識,在點擊新的 URL 的時候再將 backIndex 迴歸爲 this.currentIndex
。正則表達式
class Routers {
constructor() {
this.routes = {};
// 在初始化時監聽popstate事件
this._bindPopState();
}
// 初始化路由
init(path) {
history.replaceState({path: path}, null, path);
this.routes[path] && this.routes[path]();
}
// 將路徑和對應回調函數加入hashMap儲存
route(path, callback) {
this.routes[path] = callback || function() {};
}
// 觸發路由對應回調
go(path) {
history.pushState({path: path}, null, path);
this.routes[path] && this.routes[path]();
}
// 後退
backOff(){
history.back()
}
// 監聽popstate事件
_bindPopState() {
window.addEventListener('popstate', e => {
const path = e.state && e.state.path;
this.routes[path] && this.routes[path]();
});
}
}
複製代碼
參考 H5 Routerreact-native
相比 hash 路由,h5 路由再也不須要有些醜陋去的去修改 window.location
了,取而代之使用 history.pushState
來完成對 window.location
的操做,使用 window.addEventListener('popstate', callback)
來對前進/後退進行監聽,至於後退則能夠直接使用 window.history.back()
或者 window.history.go(-1)
來直接實現,因爲瀏覽器的 history 控制了前進/後退的邏輯,因此實現簡單了不少。api
react 做爲一個前端視圖框架,自己是不具備除了 view (數據與界面之間的抽象)以外的任何功能的,爲 react 引入一個路由庫的目的與上面的普通 SPA 目的一致,只不過上面路由更改觸發的回調函數是咱們本身寫的操做 DOM 的函數;在 react 中咱們不直接操做 DOM,而是管理抽象出來的 VDOM 或者說 JSX,對 react 的來講路由須要管理組件的生命週期,對不一樣的路由渲染不一樣的組件。瀏覽器
在前面咱們瞭解了建立路由的目的,普通 SPA 路由的實現及 react 路由的目的,先來認識一下 rr4 的周邊知識,而後就開始對 react-router 的源碼分析。緩存
history 庫,是 rr4 依賴的一個對 window.history
增強版的 history 庫。
源自 history 庫,表示當前的 URL 與 path 的匹配的結果
match: {
path: "/", // 用來匹配的 path
url: "/", // 當前的 URL
params: {}, // 路徑中的參數
isExact: pathname === "/" // 是否爲嚴格匹配
}
複製代碼
仍是源自 history 庫,是 history 庫基於 window.location 的一個衍生。
hash: "" // hash
key: "nyi4ea" // 一個 uuid
pathname: "/explore" // URL 中路徑部分
search: "" // URL 參數
state: undefined // 路由跳轉時傳遞的 state
複製代碼
咱們帶着問題去分析源碼,先逐個分析每一個組件的做用,在最後會有回答,在這裏先舉一個 rr4 的小 DEMO
rr4 將路由拆成了幾個包 —— react-router 負責通用的路由邏輯,react-router-dom 負責瀏覽器的路由管理,react-router-native 負責 react-native 的路由管理,通用的部分直接從 react-router 中導入,用戶只需引入 react-router-dom 或 react-router-native 便可,react-router 做爲依賴存在再也不須要單獨引入。
import React from 'react'
import { render } from 'react-dom'
import { BrowserRouter } from 'react-router-dom'
import App from './components/App';
render(){
return(
<BrowserRouter> <App /> </BrowserRouter>
)
)}
複製代碼
這是咱們調用 Router 的方式,這裏拿 BrowserRouter 來舉例。
BrowserRouter 的源碼在 react-router-dom 中,它是一個高階組件,在內部建立一個全局的 history 對象(能夠監聽整個路由的變化),並將 history 做爲 props 傳遞給 react-router 的 Router 組件(Router 組件再會將這個 history 的屬性做爲 context 傳遞給子組件)
render() {
return <Router history={this.history} children={this.props.children} />; } 複製代碼
其實整個 Router 的核心是在 react-router 的 Router 組件中,以下,藉助 context 向 Route 傳遞組件,這也解釋了爲何 Router 要在全部 Route 的外面。
getChildContext() {
return {
router: {
...this.context.router,
history: this.props.history,
route: {
location: this.props.history.location,
match: this.state.match
}
}
};
}
複製代碼
這是 Router 傳遞給子組件的 context,事實上 Route 也會將 router 做爲 context 向下傳遞,若是咱們在 Route 渲染的組件中加入
static contextTypes = {
router: PropTypes.shape({
history: PropTypes.object.isRequired,
route: PropTypes.object.isRequired,
staticContext: PropTypes.object
})
};
複製代碼
來經過 context 訪問 router,不過 rr4 通常經過 props 傳遞,將 history, location, match 做爲三個獨立的 props 傳遞給要渲染的組件,這樣訪問起來方便一點(實際上已經徹底將 router 對象的屬性徹底傳遞了)。
在 Router 的 componentWillMount 中, 添加了
componentWillMount() {
const { children, history } = this.props;
invariant(
children == null || React.Children.count(children) === 1,
"A <Router> may have only one child element"
);
// Do this here so we can setState when a <Redirect> changes the
// location in componentWillMount. This happens e.g. when doing
// server rendering using a <sStaticRouter>.
this.unlisten = history.listen(() => {
this.setState({
match: this.computeMatch(history.location.pathname)
});
});
}
複製代碼
history.listen
可以監聽路由的變化並執行回調事件。
在這裏每次路由的變化執行的回調事件爲
this.setState({
match: this.computeMatch(history.location.pathname)
});
複製代碼
相比於在 setState 裏作的操做,setState 自己的意義更大 —— 每次路由變化 -> 觸發頂層 Router 的回調事件 -> Router 進行 setState -> 向下傳遞 nextContext(context 中含有最新的 location)-> 下面的 Route 獲取新的 nextContext 判斷是否進行渲染。
之因此把這個 subscribe 的函數寫在 componentWillMount 裏,就像源碼中給出的註釋:是爲了 SSR 的時候,可以使用 Redirect。
Route 的做用是匹配路由,並傳遞給要渲染的組件 props。
在 Route 的 componentWillReceiveProps 中
componentWillReceiveProps(nextProps, nextContext) {
...
this.setState({
match: this.computeMatch(nextProps, nextContext.router)
});
}
複製代碼
Route 接受上層的 Router 傳入的 context,Router 中的 history 監聽着整個頁面的路由變化,當頁面發生跳轉時,history 觸發監聽事件,Router 向下傳遞 nextContext,就會更新 Route 的 props 和 context 來判斷當前 Route 的 path 是否匹配 location,若是匹配則渲染,不然不渲染。
是否匹配的依據就是 computeMatch 這個函數,在下文會有分析,這裏只須要知道匹配失敗則 match 爲 null
,若是匹配成功則將 match 的結果做爲 props 的一部分,在 render 中傳遞給傳進來的要渲染的組件。
接下來看一下 Route 的 render 部分。
render() {
const { match } = this.state; // 布爾值,表示 location 是否匹配當前 Route 的 path
const { children, component, render } = this.props; // Route 提供的三種可選的渲染方式
const { history, route, staticContext } = this.context.router; // Router 傳入的 context
const location = this.props.location || route.location;
const props = { match, location, history, staticContext };
if (component) return match ? React.createElement(component, props) : null; // Component 建立
if (render) return match ? render(props) : null; // render 建立
if (typeof children === "function") return children(props); // 回調 children 建立
if (children && !isEmptyChildren(children)) // 普通 children 建立
return React.Children.only(children);
return null;
}
複製代碼
rr4 提供了三種渲染組件的方法:component props,render props 和 children props,渲染的優先級也是依次按照順序,若是前面的已經渲染後了,將會直接 return。
這裏解釋一下官網的 tips,component 是使用 React.createElement 來建立新的元素,因此若是傳入一個內聯函數,好比
<Route path='/' component={()=>(<div>hello world</div>)}
複製代碼
的話,因爲每次的 props.component 都是新建立的,因此 React 在 diff 的時候會認爲進來了一個全新的組件,因此會將舊的組件 unmount,再 re-mount。這時候就要使用 render,少了一層包裹的 component 元素,render 展開後的元素類型每次都是同樣的,就不會發生 re-mount 了(children 也不會發生 re-mount)。
咱們緊接着 Route 來看 Switch,Switch 是用來嵌套在 Route 的外面,當 Switch 中的第一個 Route 匹配以後就不會再渲染其餘的 Route 了。
render() {
const { route } = this.context.router;
const { children } = this.props;
const location = this.props.location || route.location;
let match, child;
React.Children.forEach(children, element => {
if (match == null && React.isValidElement(element)) {
const {
path: pathProp,
exact,
strict,
sensitive,
from
} = element.props;
const path = pathProp || from;
child = element;
match = matchPath(
location.pathname,
{ path, exact, strict, sensitive },
route.match
);
}
});
return match
? React.cloneElement(child, { location, computedMatch: match })
: null;
}
複製代碼
Switch 也是經過 matchPath 這個函數來判斷是否匹配成功,一直按照 Switch 中 children 的順序依次遍歷子元素,若是匹配失敗則 match 爲 null,若是匹配成功則標記這個子元素和它對應的 location、computedMatch。在最後的時候使用 React.cloneElement 渲染,若是沒有匹配到的子元素則返回 null
。
接下來咱們看下 matchPath 是如何判斷 location 是否符合 path 的。
matchPath 返回的是一個以下結構的對象
{
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;
}, {})
}
複製代碼
這些信息將做爲匹配的參數傳遞給 Route 和 Switch(Switch 只是一個代理,它的做用仍是渲染 Route,Switch 計算獲得的 computedMatch 會傳遞給要渲染的 Route,此時 Route 將直接使用這個 computedMatch 而不須要再本身來計算)。
在 matchPath 內部 compilePath 時,有個
const patternCache = {};
const cacheLimit = 10000;
let cacheCount = 0;
複製代碼
做爲 pathToRegexp 的緩存,由於 ES6 的 import 模塊導出的是值的引用,因此將 patternCache 能夠理解爲一個全局變量緩存,緩存以 {option:{pattern: }}
的形式存儲,以後若是須要匹配相同 pattern 和 option 的 path,則能夠直接從緩存中得到正則表達式和 keys。
加緩存的緣由是路由頁面大部分狀況下都是類似的,好比要訪問 /user/123
或 /users/234
,都會使用 /user/:id
這個 path 去匹配,沒有必要每次都生成一個新的正則表達式。SPA 在頁面整個訪問的過程當中都維護着這份緩存。
實際上咱們可能寫的最多的就是 Link 這個標籤了,咱們從它的 render 函數開始看
render() {
const { replace, to, innerRef, ...props } = this.props; // eslint-disable-line no-unused-vars
invariant(
this.context.router,
"You should not use <Link> outside a <Router>"
);
invariant(to !== undefined, 'You must specify the "to" property');
const { history } = this.context.router;
const location =
typeof to === "string"
? createLocation(to, null, null, history.location)
: to;
const href = history.createHref(location);
// 最終建立的是一個 a 標籤
return (
<a {...props} onClick={this.handleClick} href={href} ref={innerRef} /> ); } 複製代碼
能夠看到 Link 最終仍是建立一個 a 標籤來包裹住要跳轉的元素,可是若是隻是一個普通的帶 href 的 a 標籤,那麼就會直接跳轉到一個新的頁面而不是 SPA 了,因此在這個 a 標籤的 handleClick 中會 preventDefault 禁止默認的跳轉,因此這裏的 href 並無實際的做用,但仍然能夠標示出要跳轉到的頁面的 URL 而且有更好的 html 語義。
在 handleClick 中,對沒有被 「preventDefault的 && 鼠標左鍵點擊的 && 非 _blank
跳轉 的&& 沒有按住其餘功能鍵的「 單擊進行 preventDefault,而後 push 進 history 中,這也是前面講過的 —— 路由的變化 與 頁面的跳轉 是不互相關聯的,rr4 在 Link 中經過 history 庫的 push 調用了 HTML5 history 的 pushState
,可是這僅僅會讓路由變化,其餘什麼都沒有改變。還記不記得 Router 中的 listen,它會監聽路由的變化,而後經過 context 更新 props 和 nextContext 讓下層的 Route 去從新匹配,完成須要渲染部分的更新。
handleClick = event => {
if (this.props.onClick) this.props.onClick(event);
if (
!event.defaultPrevented && // onClick prevented default
event.button === 0 && // ignore everything but left clicks
!this.props.target && // let browser handle "target=_blank" etc.
!isModifiedEvent(event) // ignore clicks with modifier keys
) {
event.preventDefault();
const { history } = this.context.router;
const { replace, to } = this.props;
if (replace) {
history.replace(to);
} else {
history.push(to);
}
}
};
複製代碼
withRouter 的做用是讓咱們在普通的非直接嵌套在 Route 中的組件也能得到路由的信息,這時候咱們就要 WithRouter(wrappedComponent)
來建立一個 HOC 傳遞 props,WithRouter 的其實就是用 Route 包裹了 SomeComponent 的一個 HOC。
建立 Route 有三種方法,這裏直接採用了傳遞 children
props 的方法,由於這個 HOC 要原封不動的渲染 wrappedComponent(children
props 比較少用獲得,某種程度上是一個內部方法)。
在最後返回 HOC 時,使用了 hoistStatics 這個方法,這個方法的做用是保留 SomeComponent 類的靜態方法,由於 HOC 是在 wrappedComponent 的外層又包了一層 Route,因此要將 wrappedComponent 類的靜態方法轉移給新的 Route,具體參見 Static Methods Must Be Copied Over。
如今回到一開始的問題,從新理解一下點擊一個 Link 跳轉的過程。
有兩件事須要完成:
過程以下:
hitsory.push(to)
,這個函數實際上就是包裝了一下 window.history.pushState()
,是 HTML5 history 的 API,可是 pushState 以後除了地址欄有變化其餘沒有任何影響,到這一步已經完成了目標1:路由的改變。看到這裏相信你已經可以理解前端路由的實現及 react-router 的實現,可是 react-router 有不少的不足,這也是爲何 reach-router 的出現的緣由。
在下篇文章,我會介紹如何作一個能夠緩存的 Route —— 好比在列表頁跳轉到詳情頁再後退的時候,恢復列表頁的模樣,包括狀態及滾動位置等。
倉庫的地址: react-live-route,喜歡能夠 star,歡迎提出 issue。
原文發佈於個人 GitHub blog,有全部文章的歸檔,歡迎 star。