上一篇文章咱們講了React-Router
的基本用法,並實現了常見的前端路由鑑權。本文會繼續深刻React-Router
講講他的源碼,套路仍是同樣的,咱們先用官方的API實現一個簡單的例子,而後本身手寫這些API來替換官方的而且保持功能不變。javascript
本文所有代碼已經上傳GitHub,你們能夠拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-router-code前端
本文用的例子是上篇文章開始那個不帶鑑權的簡單路由跳轉例子,跑起來是這樣子的:vue
咱們再來回顧下代碼,在app.js
裏面咱們用Route
組件渲染了幾個路由:java
import React from 'react'; import { BrowserRouter as Router, Switch, Route, } from "react-router-dom"; import Home from './pages/Home'; import Login from './pages/Login'; import Backend from './pages/Backend'; import Admin from './pages/Admin'; function App() { return ( <Router> <Switch> <Route path="/login" component={Login}/> <Route path="/backend" component={Backend}/> <Route path="/admin" component={Admin}/> <Route path="/" component={Home}/> </Switch> </Router> ); } export default App;
每一個頁面的代碼都很簡單,只有一個標題和回首頁的連接,好比登陸頁長這樣,其餘幾個頁面相似:react
import React from 'react'; import { Link } from 'react-router-dom'; function Login() { return ( <> <h1>登陸頁</h1> <Link to="/">回首頁</Link> </> ); } export default Login;
這樣咱們就完成了一個最簡單的React-Router
的應用示例,咱們來分析下咱們用到了他的哪些API,這些API就是咱們今天要手寫的目標,仔細一看,咱們好像只用到了幾個組件,這幾個組件都是從react-router-dom
導出來的:android
BrowserRouter: 被咱們重命名爲了Router
,他包裹了整個React-Router
應用,感受跟 之前寫過的react-redux
的Provider
相似,我猜是用來注入context
之類的。Route: 這個組件是用來定義具體的路由的,接收路由地址
path
和對應渲染的組件做爲參數。iosSwitch:這個組件是用來設置匹配模式的,不加這個的話,若是瀏覽器地址匹配到了多個路由,這幾個路由都會渲染出來,加了這個只會渲染匹配的第一個路由組件。git
Link:這個是用來添加跳轉連接的,功能相似於原生的
a
標籤,我猜他裏面也是封裝了一個a
標籤。github
咱們代碼裏面最外層的就是BrowserRouter
,咱們先去看看他的源碼幹了啥,地址傳送門:https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/BrowserRouter.jsweb
看了他的源碼,咱們發現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} />; } }
在這個殼裏面還引用了兩個庫react-router
和history
,BrowserRouter
僅僅是調用history
的createHistory
獲得一個history
對象,而後用這個對象渲染了react-router
的Router
組件。看起來咱們要搞懂react-router-dom
的源碼還必須得去看react-router
和history
的源碼,如今咱們手上有好幾個須要搞懂的庫了,爲了看懂他們的源碼,咱們得先理清楚他們的結構關係。
React-Router
的結構是一個典型的monorepo
,monorepo
這兩年開始流行了,是一種比較新的多項目管理方式,與之相對的是傳統的multi-repo
。好比React-Router
的項目結構是這樣的:
注意這裏的packages
文件夾下面有四個文件夾,這四個文件夾每一個均可以做爲一個單獨的項目發佈。之因此把他們放在一塊兒,是由於他們以前有很強的依賴關係:
react-router:是React-Router
的核心庫,處理一些共用的邏輯react-router-config:是
React-Router
的配置處理,咱們通常不須要使用react-router-dom:瀏覽器上使用的庫,會引用
react-router
核心庫react-router-native:支持
React-Native
的路由庫,也會引用react-router
核心庫
像這樣多個倉庫,發佈多個包的狀況,傳統模式是給每一個庫都建一個git repo
,這種方式被稱爲multi-repo
。像React-Router
這樣將多個庫放在同一個git repo
裏面的就是monorepo
。這樣作的好處是若是出了一個BUG或者加一個新功能,須要同時改react-router
和react-router-dom
,monorepo
只須要一個commit
一次性就改好了,發佈也能夠一塊兒發佈。若是是multi-repo
則須要修改兩個repo
,而後分別發佈兩個repo
,發佈的時候還要協調兩個repo
之間的依賴關係。因此如今不少開源庫都使用monorepo
來將依賴很強的模塊放在一個repo
裏面,好比React源碼也是一個典型的monorepo
。
yarn
有一個workspaces
能夠支持monorepo
,使用這個功能須要在package.json
裏面配置workspaces
,好比這樣:
"workspaces": { "packages": [ "packages/*" ] }
扯遠了,monorepo
能夠後面單獨開一篇文章來說,這裏講這個主要是爲了說明React-Router
分拆成了多個包,這些包之間是有比較強的依賴的。
前面咱們還用了一個庫是history
,這個庫沒在React-Router
的monorepo
裏面,而是單獨的一個庫,由於官方把他寫的功能很獨立了,不必定非要結合React-Router
使用,在其餘地方也可使用。
我以前另外一篇文章講Vue-Router的原理提到過,前端路由實現無非這幾個關鍵點:
- 監聽URL的改變
- 改變vue-router裏面的current變量
- 監視current變量
- 獲取對應的組件
- render新組件
其實React-Router
的思路也是相似的,只是React-Router
將這些功能拆分得更散,監聽URL變化獨立成了history庫,vue-router裏面的current
變量在React裏面是用Context API
實現的,並且放到了核心庫react-router
裏面,一些跟平臺相關的組件則放到了對應的平臺庫react-router-dom
或者react-router-native
裏面。按照這個思路,咱們本身寫的React-Router
文件夾下面也建幾個對應的文件夾:
而後咱們順着這個思路一步一步的將咱們代碼裏面用到的API替換成本身的。
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} />; } } export default BrowserRouter;
上面的BrowserRouter
用到了react-router
的Router
組件,這個組件在瀏覽器和React-Native
端都有使用,主要獲取當前路由並經過Context API
將它傳遞下去:
import React from "react"; 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); this.state = { location: props.history.location // 將history的location掛載到state上 }; // 下面兩個變量是防護性代碼,防止根組件還沒渲染location就變了 // 若是location變化時,當前根組件還沒渲染出來,就先記下他,等當前組件mount了再設置到state上 this._isMounted = false; this._pendingLocation = null; // 經過history監聽路由變化,變化的時候,改變state上的location 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() { // render的內容很簡單,就是兩個context // 一個是路由的相關屬性,包括history和location等 // 一個只包含history信息,同時將子組件經過children渲染出來 return ( <RouterContext.Provider value={{ history: this.props.history, location: this.state.location, match: Router.computeRootMatch(this.state.location.pathname), }} > <HistoryContext.Provider children={this.props.children || null} value={this.props.history} /> </RouterContext.Provider> ); } } export default Router;
上述代碼是我精簡過的代碼,原版代碼能夠看這裏。這段代碼主要是建立了兩個context
,將路由信息和history
信息放到了這兩個context
上,其餘也沒幹啥了。關於React的Context API
我在另一篇文章詳細講過,這裏再也不贅述了。
前面咱們其實用到了history的三個API:
createBrowserHistory: 這個是用在BrowserRouter裏面的,用來建立一個history對象,後面的listen和unlisten都是掛載在這個API的返回對象上面的。history.listen:這個是用在Router組件裏面的,用來監聽路由變化。
history.unlisten:這個也是在Router組件裏面用的,是
listen
方法的返回值,用來在清理的時候取消監聽的。
下面咱們來實現這個history:
// 建立和管理listeners的方法 function createEvents() { let handlers = []; return { push(fn) { handlers.push(fn); return function () { handlers = handlers.filter(handler => handler !== fn); }; }, call(arg) { handlers.forEach(fn => fn && fn(arg)); } } } function createBrowserHistory() { const listeners = createEvents(); let location = { pathname: '/', }; // 路由變化時的回調 const handlePop = function () { const currentLocation = { pathname: window.location.pathname } listeners.call(currentLocation); // 路由變化時執行回調 } // 監聽popstate事件 // 注意pushState和replaceState並不會觸發popstate // 可是瀏覽器的前進後退會觸發popstate // 咱們這裏監聽這個事件是爲了處理瀏覽器的前進後退 window.addEventListener('popstate', handlePop); // 返回的history上有個listen方法 const history = { listen(listener) { return listeners.push(listener); }, location } return history; } export default createBrowserHistory;
上述history
代碼是超級精簡版的代碼,官方源碼不少,還支持其餘功能,咱們這裏只拎出來核心功能,對官方源碼感興趣的看這裏:https://github.com/ReactTraining/history/blob/28c89f4091ae9e1b0001341ea60c629674e83627/packages/history/index.ts#L397
咱們前面的應用裏面還有個很重要的組件是Route
組件,這個組件是用來匹配路由和具體的組件的。這個組件看似是從react-router-dom
裏面導出來的,其實他只是至關於作了一個轉發,原封不動的返回了react-router
的Route
組件:
這個組件其實只有一個做用,就是將參數上的path
拿來跟當前的location
作對比,若是匹配上了就渲染參數上的component
就行。爲了匹配path
和location
,還須要一個輔助方法matchPath
,我直接從源碼抄這個方法了。大體思路是將咱們傳入的參數path
轉成一個正則,而後用這個正則去匹配當前的pathname
:
import pathToRegexp from "path-to-regexp"; const cache = {}; const cacheLimit = 10000; let cacheCount = 0; function compilePath(path, options) { const cacheKey = `${options.end}${options.strict}${options.sensitive}`; const pathCache = cache[cacheKey] || (cache[cacheKey] = {}); if (pathCache[path]) return pathCache[path]; const keys = []; const regexp = pathToRegexp(path, keys, options); const result = { regexp, keys }; if (cacheCount < cacheLimit) { pathCache[path] = result; cacheCount++; } return result; } /** * 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); } export default matchPath;
而後是Route
組件,調用下matchPath
來看下當前路由是否匹配就好了,當前路由記得從RouterContext
裏面拿:
import React from "react"; import RouterContext from "./RouterContext.js"; import matchPath from "./matchPath.js"; /** * The public API for matching a single path and rendering. */ class Route extends React.Component { render() { return ( <RouterContext.Consumer> {context => { // 從RouterContext獲取location const location = context.location; const match = matchPath(location.pathname, this.props); // 調用matchPath檢測當前路由是否匹配 const props = { ...context, location, match }; let { component } = this.props; // render對應的component以前先用最新的參數match更新下RouterContext // 這樣下層嵌套的Route能夠拿到對的值 return ( <RouterContext.Provider value={props}> {props.match ? React.createElement(component, props) : null} </RouterContext.Provider> ); }} </RouterContext.Consumer> ); } } export default Route;
上述代碼也是精簡過的,官方源碼還支持函數組件和render
方法等,具體代碼能夠看這裏:https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Route.js
其實到這裏,React-Router
的核心功能已經實現了,可是咱們開始的例子中還用到了Switch
和Link
組件,咱們也一塊兒來把它實現了吧。
咱們上面的Route
組件的功能是隻要path
匹配上當前路由就渲染組件,也就意味着若是多個Route
的path
都匹配上了當前路由,這幾個組件都會渲染。因此Switch
組件的功能只有一個,就是即便多個Route
的path
都匹配上了當前路由,也只渲染第一個匹配上的組件。要實現這個功能其實也不難,把Switch
的children
拿出來循環,找出第一個匹配的child
,給它添加一個標記屬性computedMatch
,順便把其餘的child
所有幹掉,而後修改下Route
的渲染邏輯,先檢測computedMatch
,若是沒有這個再使用matchPath
本身去匹配:
import React from "react"; import RouterContext from "./RouterContext.js"; import matchPath from "./matchPath.js"; class Switch extends React.Component { render() { return ( <RouterContext.Consumer> {context => { const location = context.location; // 從RouterContext獲取location let element, match; // 兩個變量記錄第一次匹配上的子元素和match屬性 // 使用React.Children.forEach來遍歷子元素,而不能使用React.Children.toArray().find() // 由於toArray會給每一個子元素添加一個key,這會致使兩個有一樣component,可是不一樣URL的<Route>重複渲染 React.Children.forEach(this.props.children, child => { // 先檢測下match是否已經匹配到了 // 若是已經匹配過了,直接跳過 if (!match && React.isValidElement(child)) { element = child; const path = child.props.path; match = matchPath(location.pathname, { ...child.props, path }); } }); // 最終<Switch>組件的返回值只是匹配子元素的一個拷貝,其餘子元素被忽略了 // match屬性會被塞給拷貝元素的computedMatch // 若是一個都沒匹配上,返回null return match ? React.cloneElement(element, { location, computedMatch: match }) : null; }} </RouterContext.Consumer> ); } } export default Switch;
而後修改下Route
組件,讓他先檢查computedMatch
:
// ... 省略其餘代碼 ... const match = this.props.computedMatch ? this.props.computedMatch : matchPath(location.pathname, this.props); // 調用matchPath檢測當前路由是否匹配
Switch
組件其實也是在react-router
裏面,源碼跟咱們上面寫的差很少:https://github.com/ReactTraining/react-router/blob/master/packages/react-router/modules/Switch.js
Link
組件功能也很簡單,就是一個跳轉,瀏覽器上要實現一個跳轉,能夠用a
標籤,可是若是直接使用a
標籤可能會致使頁面刷新,因此不能直接使用它,而應該使用history API
,history API
具體文檔能夠看這裏。咱們這裏要跳轉URL能夠直接使用history.pushState
。使用history.pushState
須要注意一下幾點:
history.pushState
只會改變history
狀態,不會刷新頁面。換句話說就是你用了這個API,你會看到瀏覽器地址欄的地址變化了,可是頁面並無變化。- 當你使用
history.pushState
或者history.replaceState
改變history
狀態的時候,popstate
事件並不會觸發,因此history
裏面的回調不會自動調用,當用戶使用history.push
的時候咱們須要手動調用回調函數。history.pushState(state, title[, url])
接收三個參數,第一個參數state
是往新路由傳遞的信息,能夠爲空,官方React-Router
會往裏面加一個隨機的key
和其餘信息,咱們這裏直接爲空吧,第二個參數title
目前大多數瀏覽器都不支持,能夠直接給個空字符串,第三個參數url
是可選的,是咱們這裏的關鍵,這個參數是要跳往的目標地址。- 因爲
history
已經成爲了一個獨立的庫,因此咱們應該將history.pushState
相關處理加到history
庫裏面。
咱們先在history
裏面新加一個APIpush
,這個API會調用history.pushState
並手動執行回調:
// ... 省略其餘代碼 ... push(url) { const history = window.history; // 這裏pushState並不會觸發popstate // 可是咱們仍然要這樣作,是爲了保持state棧的一致性 history.pushState(null, '', url); // 因爲push並不觸發popstate,咱們須要手動調用回調函數 location = { pathname: url }; listeners.call(location); }
上面說了咱們直接使用a
標籤會致使頁面刷新,可是若是不使用a
標籤,Link
組件應該渲染個什麼標籤在頁面上呢?能夠隨便渲染個span
,div
什麼的都行,可是可能會跟你們平時的習慣不同,還可能致使一些樣式失效,因此官方仍是選擇了渲染一個a
標籤在這裏,只是使用event.preventDefault
禁止了默認行爲,而後用history api
本身實現了跳轉,固然你能夠本身傳component
參數進去改變默認的a
標籤。由於是a
標籤,不能兼容native
,因此Link
組件實際上是在react-router-dom
這個包裏面:
import React from "react"; import RouterContext from "../react-router/RouterContext"; // LinkAnchor只是渲染了一個沒有默認行爲的a標籤 // 跳轉行爲由傳進來的navigate實現 function LinkAnchor({navigate, ...rest}) { let props = { ...rest, onClick: event => { event.preventDefault(); navigate(); } } return <a {...props} />; } function Link({ component = LinkAnchor, // component默認是LinkAnchor to, ...rest }) { return ( <RouterContext.Consumer> {context => { const { history } = context; // 從RouterContext獲取history對象 const props = { ...rest, href: to, navigate() { history.push(to); } }; return React.createElement(component, props); }} </RouterContext.Consumer> ); } export default Link;
上述代碼是精簡版的Link
,基本邏輯跟官方源碼同樣:https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/Link.js
到這裏開頭示例用到的所有API都換成了咱們本身的,其實也實現了React-Router
的核心功能。可是咱們只實現了H5 history
模式,hash
模式並無實現,其實有了這個架子,添加hash
模式也比較簡單了,基本架子不變,在react-router-dom
裏面添加一個HashRouter
,他的基本結構跟BrowserRouter
是同樣的,只是他會調用history
的createHashHistory
,createHashHistory
裏面不只僅會去監聽popstate
,某些瀏覽器在hash
變化的時候不會觸發popstate
,因此還須要監聽hashchange
事件。對應的源碼以下,你們能夠自行閱讀:
HashRouter: https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/modules/HashRouter.js
createHashHistory: https://github.com/ReactTraining/history/blob/28c89f4091ae9e1b0001341ea60c629674e83627/packages/history/index.ts#L616
React-Router
的核心源碼咱們已經讀完了,下面咱們來總結下:
React-Router
由於有跨平臺的需求,因此分拆了好幾個包,這幾個包採用monorepo
的方式管理:
react-router
是核心包,包含了大部分邏輯和組件,處理context
和路由匹配都在這裏。react-router-dom
是瀏覽器使用的包,像Link
這樣須要渲染具體的a
標籤的組件就在這裏。react-router-native
是react-native
使用的包,裏面包含了android
和ios
具體的項目。history
,跟history
相關的處理都放在了這裏,好比push
,replace
什麼的。React-Router
實現時核心邏輯以下:
history
或者hash
React
組件能夠監聽路由變化。React
。React
的響應式數據上,也就是將這個值寫到根router
的state
上,而後經過context
傳給子組件。path
和當前瀏覽器地址作一個對比,匹配上就渲染對應的組件。在使用popstate
時須要注意:
history.pushState
和history.replaceState
並不會觸發popstate
,要通知React
須要咱們手動調用回調函數。popstate
事件,因此咱們仍是要監聽popstate
,目的是兼容前進後退按鈕。本文所有代碼已經上傳GitHub,你們能夠拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/React/react-router-code
官方文檔:https://reactrouter.com/web/guides/quick-start
GitHub源碼地址:https://github.com/ReactTraining/react-router/tree/master/packages
文章的最後,感謝你花費寶貴的時間閱讀本文,若是本文給了你一點點幫助或者啓發,請不要吝嗇你的贊和GitHub小星星,你的支持是做者持續創做的動力。
做者博文GitHub項目地址: https://github.com/dennis-jiang/Front-End-Knowledges