標籤:
上篇文章主要介紹了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 組件渲染出的超連結到頁面重新整理的全過程。
點擊瀏覽器的前進和後退按鈕發生了什嗎?
可以簡單地把 網頁瀏覽器的記錄比做成一個僅有入棧操作的棧,當使用者瀏覽器到某一個頁面時將該文檔存入到棧中,點擊「後退」或「前進」按鈕時移動指標到 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 內部將該Object Storage Service到了 sessionStorage 中,也就是中的 saveState 操作
接下來的操作與第一種方式一致
使用這種方式(對應 history 包中的 createHistory 方法)進行路由需要服務端要做一個路由的配置將所有請求重新導向到入口檔案位置,你可以參考這個樣本,否則在使用者重新整理頁面時會報 404 錯誤。
實際上,上面提到的 state 對象不僅僅在第二種路由方式中可以使用。react-router 內部做了 polyfill,統一了 API。在使用第一種方式建立路由時你會發現 URL 中多了一個類似 _key=s1gvrm
的 query,這個 _key
就是為 react-router 內部在 sessionStorage 中讀取 state 對象所提供的。
react-router 相關資源
細說React(二)