上篇文章主要介紹了React的基本用法,這次將介紹一個React路由組件—react-router。
在 web 應用開發中,路由系統是不可或缺的一部分。在瀏覽器當前的 URL 發生變化時,路由系統會做出一些響應,用來保證用戶界面與 URL 的同步。隨着單頁應用時代的到來,為之服務的前端路由系統也相繼出現了。有一些獨立的第三方路由系統,比如 director,代碼庫也比較輕量。當然,主流的前端框架也都有自己的路由,比如 Backbone、Ember、Angular、React 等等。那 react-router 相對於其他路由系統又針對 React 做了哪些優化呢?它是如何利用了 React 的 UI 狀態機特性呢?又是如何將 JSX 這種聲明式的特性用在路由中?
一個簡單的示例
現在,我們通過一個簡易的博客系統示例來解釋剛剛遇到的疑問,它包含了查看文章歸檔、文章詳細、登錄、退出以及權限校驗幾個功能,該系統的完整代碼托管在 JS Bin(注意,文中示例代碼使用了與之對應的 ES6 語法),你可以點擊鏈接查看。此外,該實例全部基於最新的 react-router 1.0 進行編寫。下面看一下 react-router 的應用實例:
import React from 'react'; import { render, findDOMNode } from 'react-dom'; import { Router, Route, Link, IndexRoute, Redirect } from 'react-router'; import { createHistory, createHashHistory, useBasename } from 'history'; // 此處用於添加根路徑 const history = useBasename(createHashHistory)({ queryKey: '_key', basename: '/blog-app', }); React.render(( <Router history={history}> <Route path="/" component={BlogApp}> <IndexRoute component={SignIn}/> <Route path="signIn" component={SignIn}/> <Route path="signOut" component={SignOut}/> <Redirect from="/archives" to="/archives/posts"/> <Route onEnter={requireAuth} path="archives" component={Archives}> <Route path="posts" components={{ original: Original, reproduce: Reproduce, }}/> </Route> <Route path="article/:id" component={Article}/> <Route path="about" component={About}/> </Route> </Router> ), document.getElementById('example'));
如果你以前並沒有接觸過 react-router,相反只是用過剛才提到的 Backbone 的路由或者是 director,你一定會對這種聲明式的寫法感到驚訝。不過細想這也是情理之中,畢竟是只服務與 React 類庫,引入它的特性也是無可厚非。仔細看一下,你會發現:
-
Router 與 Route 一樣都是 react 組件,它的 history 對象是整個路由系統的核心,它暴漏了很多屬性和方法在路由系統中使用;
-
Route 的 path 屬性表示路由組件所對應的路徑,可以是絕對或相對路徑,相對路徑可繼承;
-
Redirect 是一個重定向組件,有 from 和 to 兩個屬性;
-
Route 的 onEnter 鈎子將用於在渲染對象的組件前做攔截操作,比如驗證權限;
-
在 Route 中,可以使用 component 指定單個組件,或者通過 components 指定多個組件集合;
-
param 通過
/:param
的方式傳遞,這種寫法與 express 以及 ruby on rails 保持一致,符合 RestFul 規范;
下面再看一下如果使用 director 來聲明這個路由系統會是怎樣一番景象呢:
import React from 'react'; import { render } from 'react-dom'; import { Router } from 'director'; const App = React.createClass({ getInitialState() { return { app: null } }, componentDidMount() { const router = Router({ '/signIn': { on() { this.setState({ app: (<BlogApp><SignIn/></BlogApp>) }) }, }, '/signOut': { 結構與 signIn 類似 }, '/archives': { '/posts': { on() { this.setState({ app: (<BlogApp><Archives original={Original} reproduct={Reproduct}/></BlogApp>) }) }, }, }, '/article': { '/:id': { on (id) { this.setState({ app: (<BlogApp><Article id={id}/></BlogApp>) }) }, }, }, }); }, render() { return <div>{React.cloneElement(this.state.app)}</div>; }, }) render(<App/>, document.getElementById('example'));
從代碼的優雅程度、可讀性以及維護性上看絕對 react-router 在這里更勝一籌。分析上面的代碼,每個路由的渲染邏輯都相對獨立的,這樣就需要寫很多重復的代碼,這里雖然可以借助 React 的 setState 來統一管理路由返回的組件,將 render 方法做一定的封裝,但結果卻是要多維護一個 state,在 react-router 中這一步根本不需要。此外,這種命令式的寫法與 React 代碼放在一起也是略顯突兀。而 react-router 中的聲明式寫法在組件繼承上確實很清晰易懂,而且更加符合 React 的風格。包括這里的默認路由、重定向等等都使用了這種聲明式。相信讀到這里你已經放棄了在 React 中使用 react-router 外的路由系統!
接下來,還是回到 react-router 示例中,看一下路由組件內部的代碼:
const SignIn = React.createClass({ handleSubmit(e) { e.preventDefault(); const email = findDOMNode(this.refs.name).value; const pass = findDOMNode(this.refs.pass).value; // 此處通過修改 localStorage 模擬了登錄效果 if (pass !== 'password') { return; } localStorage.setItem('login', 'true'); const location = this.props.location; if (location.state && location.state.nextPathname) { this.props.history.replaceState(null, location.state.nextPathname); } else { // 這里使用 replaceState 方法做了跳轉,但在瀏覽器歷史中不會多一條記錄,因為是替換了當前的記錄 this.props.history.replaceState(null, '/about'); } }, render() { if (hasLogin()) { return <p>你已經登錄系統!<Link to="/signOut">點此退出</Link></p>; } return ( <form onSubmit={this.handleSubmit}> <label><input ref="name"/></label><br/> <label><input ref="pass"/></label> (password)<br/> <button type="submit">登錄</button> </form> ); } }); const SignOut = React.createClass({ componentDidMount() { localStorage.setItem('login', 'false'); }, render() { return <p>已經退出!</p>; } })
上面的代碼表示了博客系統的登錄以及退出功能。登錄成功,默認跳轉到 /about
路徑下,如果在 state 對象中存儲了 nextPathname,則跳轉到該路徑下。在這里需要指出每一個路由(Route)中聲明的組件(比如 SignIn)在渲染之前都會被傳入一些 props
,具體是在源碼中的 RoutingContext.js 中完成,主要包括:
-
history 對象,它提供了很多有用的方法可以在路由系統中使用,比如剛剛用到的
history.replaceState
,用於替換當前的 URL,並且會將被替換的 URL 在瀏覽器歷史中刪除。函數的第一個參數是 state 對象,第二個是路徑; -
location 對象,它可以簡單的認為是 URL 的對象形式表示,這里要提的是
location.state
,這里 state 的含義與 HTML5 history.pushState API 中的 state 對象一樣。每個 URL 都會對應一個 state 對象,你可以在對象里存儲數據,但這個數據卻不會出現在 URL 中。實際上,數據被存在了 sessionStorage 中;
事實上,剛才提到的兩個對象同時存在於路由組件的 context 中,你還可以通過 React 的 context API 在組件的子級組件中獲取到這兩個對象。比如在 SignIn 組件的內部又包含了一個 SignInChild 組件,你就可以在組件內部通過 this.context.history
獲取到 history 對象,進而調用它的 API 進行跳轉等操作。
接下來,我們一起看一下 Archives 組件內部的代碼:
const Archives = React.createClass({ render() { return ( <div> 原創:<br/> {this.props.original} 轉載:<br/> {this.props.reproduce} </div> ); } }); const Original = React.createClass({ render() { return ( <div className="archives"> <ul> {blogData.slice(0, 4).map((item, index) => { return ( <li key={index}> <Link to={`/article/${index}`} query={{type: 'Original'}} state={{title: item.title}}> {item.title} </Link> </li> ) })} </ul> </div> ); } }); const Reproduce = React.createClass({ // 與 Original 類似 })
上述代碼展示了文章歸檔以及原創和轉載列表。現在回顧一下路由聲明部分的代碼:
<Redirect from="/archives" to="/archives/posts"/> <Route onEnter={requireAuth} path="archives" component={Archives}> <Route path="posts" components={{ original: Original, reproduce: Reproduce, }}/> </Route> function requireAuth(nextState, replaceState) { if (!hasLogin()) { replaceState({ nextPathname: nextState.location.pathname }, '/signIn'); } }
上述的代碼中有三點值得注意:
-
用到了一個 Redirect 組件,將
/archives
重定向到/archives/posts
下; -
onEnter 鈎子中用於判斷用戶是否登錄,如果未登錄則使用
replaceState
方法重定向,該方法的作用與<Redirect/>
組件類似,不會在瀏覽器中留下重定向前的歷史; -
如果使用 components 聲明路由所對應的多個組件,在組件內部可以通過
this.props.original
(本例中)來獲取組件;
到這里,我們的博客路由系統基本已經講完了,希望你能夠對 react-router 最基本的 API 及其內部的基本原理有一定的了解。再總結一下 react-router 作為 React 路由系統的特點和優勢所在:
-
結合 JSX 采用聲明式的語法,很優雅的實現了路由嵌套以及路由回調組件的聲明,包括重定向組件,默認路由等,這歸功於其內部的匹配算法,可以通過 URL(准確的說應該是 location 對象) 在組件樹中准確匹配出需要渲染的組件。這一點絕對完勝 director 等路由在 React 中的表現;
-
不需要單獨維護 state 表示當前路由,這一點也是使用 director 等路由免不了要做的;
-
除了路由組件外,還可以通過 history 對象中的
pushState
或replaceState
方法進行路由和重定向,比如在 flux 的 store 中想要做一個跳轉操作就可以通過該方法完成;// 近似於 <Link to={path} state={null}/> history.pushState(null, path); // 近似於 <Redirect from={currentPath} to={nextPath}/> history.replaceState(null, nextPath);
當然還有一些其他的特性沒有在這里介紹,比如在大型應用中按需載入路由組件、服務端渲染以及整合 redux/relay 框架,這些都是用其他路由系統很難完成的。接下來的部分主要來講解示例背后的基本原理。
原理分析
在這一部分主要會講解路由的基本原理,react-router 的狀態機特性,在用戶點擊了 Link 組件后路由系統中到底發生了哪些,前端路由如何處理瀏覽器的前進和后退功能。
路由的基本原理
無論是傳統的后端 MVC 主導的應用,還是在當下最流行的單頁面應用中,路由的職責都很重要,但原理並不復雜,即保證視圖和 URL 的同步,而視圖可以看成是資源的一種表現。當用戶在頁面中進行操作時,應用會在若干個交互狀態中切換,路由則可以記錄下某些重要的狀態,比如在一個博客系統中用戶是否登錄、在訪問哪一篇文章、位於文章歸檔列表的第幾頁。而這些變化同樣會被記錄在瀏覽器的歷史中,用戶可以通過瀏覽器的前進、后退按鈕切換狀態,同樣可以將 URL 分享給好友。簡而言之,用戶可以通過手動輸入或者與頁面進行交互來改變 URL,然后通過同步或者異步的方式向服務端發送請求獲取資源(當然,資源也可能存在於本地),成功后重新繪制 UI,原理如下圖所示:
react-router 的狀態機特性
我們看到 react-router 中的很多特性都與 React 保持了一致,比如它的聲明式組件、組件嵌套,當然也包括 React 的狀態機特性,因為畢竟它就是基於 React 構建並且為之所用的。回想一下在 React 中,我們把組件比作是一個函數,state/props 作為函數的參數,當它們發生變化時會觸發函數執行,進而幫助我們重新繪制 UI。那么在 react-router 中將會是什么樣子呢?在 react-router 中,我們可以把 Router 組件看成是一個函數,Location 作為參數,返回的結果同樣是 UI,二者的對比如下圖所示:
上圖說明了只要 URL 一致,那么返回的 UI 界面總是相同的。或許你還很好奇在這個簡單的狀態機后面究竟是什么樣子呢?在點擊 Link 后路由系統發生了什么?在點擊瀏覽器的前進和后退按鈕后路由系統又做了哪些?那么請看下圖:
接下來的兩部分會對上圖做詳細的講解。
點擊 Link 后路由系統發生了什么?
Link 組件最終會渲染為 HTML 標簽 <a>
,它的 to、query、hash 屬性會被組合在一起並渲染為 href 屬性。雖然 Link 被渲染為超鏈接,但在內部實現上使用腳本攔截了瀏覽器的默認行為,然后調用了 history.pushState
方法(注意,文中出現的 history 指的是通過 history 包里面的 create*History 方法創建的對象,window.history
則指定瀏覽器原生的 history 對象,由於有些 API 相同,不要弄混)。history 包中底層的 pushState 方法支持傳入兩個參數 state 和 path,在函數體內有將這兩個參數傳輸到 createLocation 方法中,返回 location 的結構如下:
location = { pathname, // 當前路徑,即 Link 中的 to 屬性 search, // search hash, // hash state, // state 對象 action, // location 類型,在點擊 Link 時為 PUSH,瀏覽器前進后退時為 POP,調用 replaceState 方法時為 REPLACE key, // 用於操作 sessionStorage 存取 state 對象 };
系統會將上述 location 對象作為參數傳入到 TransitionTo 方法中,然后調用 window.location.hash
或者window.history.pushState()
修改了應用的 URL,這取決於你創建 history 對象的方式。同時會觸發 history.listen
中注冊的事件監聽器。
接下來請看路由系統內部是如何修改 UI 的。在得到了新的 location 對象后,系統內部的 matchRoutes
方法會匹配出 Route 組件樹中與當前 location 對象匹配的一個子集,並且得到了 nextState
,具體的匹配算法不在這里講解,感興趣的同學可以點擊查看,state 的結構如下:
nextState = { location, // 當前的 location 對象 routes, // 與 location 對象匹配的 Route 樹的子集,是一個數組 params, // 傳入的 param,即 URL 中的參數 components, // routes 中每個元素對應的組件,同樣是數組 };
在 Router 組件的 componentWillMount
生命周期方法中調用了 history.listen(listener)
方法。listener 會在上述 matchRoutes 方法執行成功后執行 listener(nextState)
,nextState 對象每個屬性的具體含義已經在上述代碼中注釋,接下來執行 this.setState(nextState)
就可以實現重新渲染 Router 組件。舉個簡單的例子,當 URL(准確的說應該是 location.pathname) 為 /archives/posts
時,應用的匹配結果如下圖所示:
對應的渲染結果如下:
<BlogApp> <Archives original={Original} reproduce={Reproduce}/> </BlogApp>
到這里,系統已經完成了當用戶點擊一個由 Link 組件渲染出的超鏈接到頁面刷新的全過程。
點擊瀏覽器的前進和后退按鈕發生了什么?
可以簡單地把 web 瀏覽器的歷史記錄比做成一個僅有入棧操作的棧,當用戶瀏覽器到某一個頁面時將該文檔存入到棧中,點擊「后退」或「前進」按鈕時移動指針到 history 棧中對應的某一個文檔。在傳統的瀏覽器中,文檔都是從服務端請求過來的。不過現代的瀏覽器一般都會支持兩種方式用於動態的生成並載入頁面。
location.hash 與 hashchange 事件
這也是比較簡單並且兼容性也比較好的一種方式,詳細請看下面幾點:
-
使用
hashchange
事件來監聽window.location.hash
的變化 -
hash 發生變化瀏覽器會更新 URL,並且在 history 棧中產生一條記錄
-
路由系統會將所有的路由信息都保存到
location.hash
中 -
在 react-router 內部注冊了
window.addEventListener('hashchange', listener, false)
事件監聽器 -
listener 內部可以通過 hash fragment 獲取到當前 URL 對應的 location 對象
-
接下來的過程與點擊 <Link/> 時保持一致
當然,你會想到不僅僅在前進和后退會觸發 hashchange
事件,應該說每次路由操作都會有 hash 的變化。確實如此,為了解決這個問題,路由系統內部通過判斷 currentLocation 與 nextLocation 是否相等來處理該問題。不過,從它的實現原理上來看,由於路由操作 hash 發生變化而重復調用 transitonTo(location)
這一步確實無可避免,這也是我在上圖中所畫的虛線的含義。
這種方法會在瀏覽器的 URL 中添加一個 # 號,不過出於兼容性的考慮(ie8+),路由系統內部將這種方式(對應 history 包中的createHashHistory 方法)作為創建 history 對象的默認方法。
history.pushState 與 popstate 事件
新的 HTML5 規范中還提出了一個相對復雜但更加健壯的方式來解決該問題,請看下面幾點:
-
上文中提到了可以通過
window.history.pushState(state, title, path)
方法(更多關於 history 對象的詳細 API 可以查看這里)來改變瀏覽器的 URL,實際上該方法同時在 history 棧中存入了 state 對象。 -
在瀏覽器前進和后退時觸發
popstate
事件,然后注冊window.addEventListener('popstate', listener, false)
,並且可以在事件對象中取出對應的 state 對象 -
state 對象可以存儲一些恢復該頁面所需要的簡單信息,上文中已經提到 state 會作為屬性存儲在 location 對象中,這樣你就可以在組件中通過
location.state
來獲取到 -
在 react-router 內部將該對象存儲到了 sessionStorage 中,也就是上圖中的 saveState 操作
-
接下來的操作與第一種方式一致
使用這種方式(對應 history 包中的 createHistory 方法)進行路由需要服務端要做一個路由的配置將所有請求重定向到入口文件位置,你可以參考這個示例,否則在用戶刷新頁面時會報 404 錯誤。
實際上,上面提到的 state 對象不僅僅在第二種路由方式中可以使用。react-router 內部做了 polyfill,統一了 API。在使用第一種方式創建路由時你會發現 URL 中多了一個類似 _key=s1gvrm
的 query,這個 _key
就是為 react-router 內部在 sessionStorage 中讀取 state 對象所提供的。
react-router 相關資源