ReactRouter的實現
ReactRouter
是React
的核心元件,主要是作為React
的路由管理器,保持UI
與URL
同步,其擁有簡單的API
與強大的功能例如程式碼緩衝載入、動態路由匹配、以及建立正確的位置過渡處理等。
描述
React Router
是建立在history
物件之上的,簡而言之一個history
物件知道如何去監聽瀏覽器位址列的變化,並解析這個URL
轉化為location
物件,然後router
使用它匹配到路由,最後正確地渲染對應的元件,常用的history
有三種形式: Browser History
、Hash History
、Memory History
。
Browser History
Browser History
是使用React Router
的應用推薦的history
,其使用瀏覽器中的History
物件的pushState
、replaceState
等API
以及popstate
事件等來處理URL
,其能夠建立一個像https://www.example.com/path
這樣真實的URL
,同樣在頁面跳轉時無須重新載入頁面,當然也不會對於服務端進行請求,當然對於history
模式仍然是需要後端的配置支援,用以支援非首頁的請求以及重新整理時後端返回的資源,由於應用是個單頁客戶端應用,如果後臺沒有正確的配置,當使用者在瀏覽器直接訪問URL
時就會返回404
,所以需要在服務端增加一個覆蓋所有情況的候選資源,如果URL
匹配不到任何靜態資源時,則應該返回同一個index.html
應用依賴頁面,例如在Nginx
下的配置。
location / {
try_files $uri $uri/ /index.html;
}
Hash History
Hash
符號即#
原本的目的是用來指示URL
中指示網頁中的位置,例如https://www.example.com/index.html#print
即代表example
的index.html
的print
位置,瀏覽器讀取這個URL
後,會自動將print
位置滾動至可視區域,通常使用<a>
標籤的name
屬性或者<div>
標籤的id
屬性指定錨點。
通過window.location.hash
屬效能夠讀取錨點位置,可以為Hash
的改變新增hashchange
監聽事件,每一次改變Hash
,都會在瀏覽器的訪問歷史中增加一個記錄,此外Hash
雖然出現在URL
中,但不會被包括在HTTP
請求中,即#
及之後的字元不會被髮送到服務端進行資源或資料的請求,其是用來指導瀏覽器動作的,對伺服器端沒有效果,因此改變Hash
不會重新載入頁面。
ReactRouter
的作用就是通過改變URL
,在不重新請求頁面的情況下,更新頁面檢視,從而動態載入與銷燬元件,簡單的說就是,雖然位址列的地址改變了,但是並不是一個全新的頁面,而是之前的頁面某些部分進行了修改,這也是SPA
單頁應用的特點,其所有的活動侷限於一個Web
頁面中,非懶載入的頁面僅在該Web
頁面初始化時載入相應的HTML
、JavaScript
、CSS
檔案,一旦頁面載入完成,SPA
不會進行頁面的重新載入或跳轉,而是利用JavaScript
動態的變換HTML
,預設Hash
模式是通過錨點實現路由以及控制元件的顯示與隱藏來實現類似於頁面跳轉的互動。
Memory History
Memory History
不會在位址列被操作或讀取,這就可以解釋如何實現伺服器渲染的,同時其也非常適合測試和其他的渲染環境例如React Native
,和另外兩種History
的一點不同是我們必須建立它,這種方式便於測試。
const history = createMemoryHistory(location);
實現
我們來實現一個非常簡單的Browser History
模式與Hash History
模式的實現,因為H5
的pushState
方法不能在本地檔案協議file://
執行,所以執行起來需要搭建一個http://
環境,使用webpack
、Nginx
、Apache
等都可以,回到Browser History
模式路由,能夠實現history
路由跳轉不重新整理頁面得益與H5
提供的pushState()
、replaceState()
等方法以及popstate
等事件,這些方法都是也可以改變路由路徑,但不作頁面跳轉,當然如果在後端不配置好的情況下路由改編後重新整理頁面會提示404
,對於Hash History
模式,我們的實現思路相似,主要在於沒有使用pushState
等H5
的API
,以及監聽事件不同,通過監聽其hashchange
事件的變化,然後拿到對應的location.hash
更新對應的檢視。
<!-- Browser History -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Router</title>
</head>
<body>
<ul>
<li><a href="/home">home</a></li>
<li><a href="/about">about</a></li>
<div id="routeView"></div>
</ul>
</body>
<script>
function Router() {
this.routeView = null; // 元件承載的檢視容器
this.routes = Object.create(null); // 定義的路由
}
// 繫結路由匹配後事件
Router.prototype.route = function (path, callback) {
this.routes[path] = () => this.routeView.innerHTML = callback() || "";
};
// 初始化
Router.prototype.init = function(root, rootView) {
this.routeView = rootView; // 指定承載檢視容器
this.refresh(); // 初始化即重新整理檢視
root.addEventListener("click", (e) => { // 事件委託到root
if (e.target.nodeName === "A") {
e.preventDefault();
history.pushState(null, "", e.target.getAttribute("href"));
this.refresh(); // 觸發即重新整理檢視
}
})
// 監聽使用者點選後退與前進
// pushState與replaceState不會觸發popstate事件
window.addEventListener("popstate", this.refresh.bind(this), false);
};
// 重新整理檢視
Router.prototype.refresh = function () {
let path = location.pathname;
console.log("refresh", path);
if(this.routes[path]) this.routes[path]();
else this.routeView.innerHTML = "";
};
window.Router = new Router();
Router.route("/home", function() {
return "home";
});
Router.route("/about", function () {
return "about";
});
Router.init(document, document.getElementById("routeView"));
</script>
</html>
<!-- Hash History -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Router</title>
</head>
<body>
<ul>
<li><a href="#/home">home</a></li>
<li><a href="#/about">about</a></li>
<div id="routeView"></div>
</ul>
</body>
<script>
function Router() {
this.routeView = null; // 元件承載的檢視容器
this.routes = Object.create(null); // 定義的路由
}
// 繫結路由匹配後事件
Router.prototype.route = function (path, callback) {
this.routes[path] = () => this.routeView.innerHTML = callback() || "";
};
// 初始化
Router.prototype.init = function(root, rootView) {
this.routeView = rootView; // 指定承載檢視容器
this.refresh(); // 初始化觸發
// 監聽hashchange事件用以重新整理
window.addEventListener("hashchange", this.refresh.bind(this), false);
};
// 重新整理檢視
Router.prototype.refresh = function () {
let hash = location.hash;
console.log("refresh", hash);
if(this.routes[hash]) this.routes[hash]();
else this.routeView.innerHTML = "";
};
window.Router = new Router();
Router.route("#/home", function() {
return "home";
});
Router.route("#/about", function () {
return "about";
});
Router.init(document, document.getElementById("routeView"));
</script>
</html>
分析
我們可以看一下ReactRouter
的實現,commit id
為eef79d5
,TAG
是4.4.0
,在這之前我們需要先了解一下history
庫,history
庫,是ReactRouter
依賴的一個對window.history
加強版的history
庫,其中主要用到的有match
物件表示當前的URL
與path
的匹配的結果,location
物件是history
庫基於window.location
的一個衍生。
ReactRouter
將路由拆成了幾個包: react-router
負責通用的路由邏輯,react-router-dom
負責瀏覽器的路由管理,react-router-native
負責react-native
的路由管理。
我們以BrowserRouter
元件為例,BrowserRouter
在react-router-dom
中,它是一個高階元件,在內部建立一個全域性的history
物件,可以監聽整個路由的變化,並將history
作為props
傳遞給react-router
的Router
元件,Router
元件再會將這個history
的屬性作為context
傳遞給子元件。
// packages\react-router-dom\modules\HashRouter.js line 10
class BrowserRouter extends React.Component {
history = createHistory(this.props);
render() {
return <Router history={this.history} children={this.props.children} />;
}
}
接下來我們到Router
元件,Router
元件建立了一個React Context
環境,其藉助context
向Route
傳遞context
,這也解釋了為什麼Router
要在所有Route
的外面。在Router
的componentWillMount
中,新增了history.listen
,其能夠監聽路由的變化並執行回撥事件,在這裡即會觸發setState
。當setState
時即每次路由變化時 ->
觸發頂層Router
的回撥事件 ->
Router
進行setState
->
向下傳遞 nextContext
此時context
中含有最新的location
->
下面的Route
獲取新的nextContext
判斷是否進行渲染。
// line packages\react-router\modules\Router.js line 10
class Router extends React.Component {
static computeRootMatch(pathname) {
return { path: "/", url: "/", params: {}, isExact: pathname === "/" };
}
constructor(props) {
super(props);
this.state = {
location: props.history.location
};
// This is a bit of a hack. We have to start listening for location
// changes here in the constructor in case there are any <Redirect>s
// on the initial render. If there are, they will replace/push when
// they mount and since cDM fires in children before parents, we may
// get a new location before the <Router> is mounted.
this._isMounted = false;
this._pendingLocation = null;
if (!props.staticContext) {
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();
}
render() {
return (
<RouterContext.Provider
children={this.props.children || null}
value={{
history: this.props.history,
location: this.state.location,
match: Router.computeRootMatch(this.state.location.pathname),
staticContext: this.props.staticContext
}}
/>
);
}
}
我們在使用時都是使用Router
來巢狀Route
,所以此時就到Route
元件,Route
的作用是匹配路由,並傳遞給要渲染的元件props
,Route
接受上層的Router
傳入的context
,Router
中的history
監聽著整個頁面的路由變化,當頁面發生跳轉時,history
觸發監聽事件,Router
向下傳遞nextContext
,就會更新Route
的props
和context
來判斷當前Route
的path
是否匹配location
,如果匹配則渲染,否則不渲染,是否匹配的依據就是computeMatch
這個函式,在下文會有分析,這裡只需要知道匹配失敗則match
為null
,如果匹配成功則將match
的結果作為props
的一部分,在render
中傳遞給傳進來的要渲染的元件。Route
接受三種型別的render props
,<Route component>
、<Route render>
、<Route children>
,此時要注意的是如果傳入的component
是一個行內函數,由於每次的props.component
都是新建立的,所以React
在diff
的時候會認為進來了一個全新的元件,所以會將舊的元件unmount
再re-mount
。這時候就要使用render
,少了一層包裹的component
元素,render
展開後的元素型別每次都是一樣的,就不會發生re-mount
了,另外children
也不會發生re-mount
。
// \packages\react-router\modules\Route.js line 17
class Route extends React.Component {
render() {
return (
<RouterContext.Consumer>
{context => {
invariant(context, "You should not use <Route> outside a <Router>");
const location = this.props.location || context.location;
const match = this.props.computedMatch
? this.props.computedMatch // <Switch> already computed the match for us
: this.props.path
? matchPath(location.pathname, this.props)
: context.match;
const props = { ...context, location, match };
let { children, component, render } = this.props;
// Preact uses an empty array as children by
// default, so use null if that's the case.
if (Array.isArray(children) && children.length === 0) {
children = null;
}
if (typeof children === "function") {
children = children(props);
// ...
}
return (
<RouterContext.Provider value={props}>
{children && !isEmptyChildren(children)
? children
: props.match
? component
? React.createElement(component, props)
: render
? render(props)
: null
: null}
</RouterContext.Provider>
);
}}
</RouterContext.Consumer>
);
}
}
我們實際上我們可能寫的最多的就是Link
這個標籤了,所以我們再來看一下<Link>
元件,我們可以看到Link
最終還是建立一個a
標籤來包裹住要跳轉的元素,在這個a
標籤的handleClick
點選事件中會preventDefault
禁止預設的跳轉,所以實際上這裡的href
並沒有實際的作用,但仍然可以標示出要跳轉到的頁面的URL
並且有更好的html
語義。在handleClick
中,對沒有被preventDefault
、滑鼠左鍵點選的、非_blank
跳轉的、沒有按住其他功能鍵的單擊進行preventDefault
,然後push
進history
中,這也是前面講過的路由的變化與 頁面的跳轉是不互相關聯的,ReactRouter
在Link
中通過history
庫的push
呼叫了HTML5 history
的pushState
,但是這僅僅會讓路由變化,其他什麼都沒有改變。在Router
中的listen
,它會監聽路由的變化,然後通過context
更新props
和nextContext
讓下層的Route
去重新匹配,完成需要渲染部分的更新。
// packages\react-router-dom\modules\Link.js line 14
class Link extends React.Component {
handleClick(event, history) {
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 || this.props.target === "_self") && // let browser handle "target=_blank" etc.
!isModifiedEvent(event) // ignore clicks with modifier keys
) {
event.preventDefault();
const method = this.props.replace ? history.replace : history.push;
method(this.props.to);
}
}
render() {
const { innerRef, replace, to, ...rest } = 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
{...rest}
onClick={event => this.handleClick(event, context.history)}
href={href}
ref={innerRef}
/>
);
}}
</RouterContext.Consumer>
);
}
}
每日一題
https://github.com/WindrunnerMax/EveryDay
參考
https://zhuanlan.zhihu.com/p/44548552
https://github.com/fi3ework/blog/issues/21
https://juejin.cn/post/6844903661672333326
https://juejin.cn/post/6844904094772002823
https://juejin.cn/post/6844903878568181768
https://segmentfault.com/a/1190000014294604
https://github.com/youngwind/blog/issues/109
http://react-guide.github.io/react-router-cn/docs/guides/basics/Histories.html