在 web 應用開發中,路由系統是不可或缺的一部分。在瀏覽器當前的 URL 發生變化時,路由系統會做出一些響應,用來保證使用者介面與 URL 的同步。隨著單頁應用時代的到來,為之服務的前端路由系統也相繼出現了。有一些獨立的第三方路由系統,比如 director,程式碼庫也比較輕量。當然,主流的前端框架也都有自己的路由,比如 Backbone、Ember、Angular、React 等等。那 react-router 相對於其他路由系統又針對 React 做了哪些優化呢?它是如何利用了 React 的 UI 狀態機特性呢?又是如何將 JSX 這種宣告式的特性用在路由中?
一個簡單的示例
現在,我們通過一個簡易的部落格系統示例來解釋剛剛遇到的疑問,它包含了檢視文章歸檔、文章詳細、登入、退出以及許可權校驗幾個功能,該系統的完整程式碼託管在 JS Bin(注意,文中示例程式碼使用了與之對應的 ES6 語法),你可以點選連結檢視。此外,該例項全部基於最新的 react-router 1.0 進行編寫。下面看一下 react-router 的應用例項:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
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 來宣告這個路由系統會是怎樣一番景象呢:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 |
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 示例中,看一下路由元件內部的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
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 元件內部的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
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 類似 }) |
上述程式碼展示了文章歸檔以及原創和轉載列表。現在回顧一下路由宣告部分的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<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 中想要做一個跳轉操作就可以通過該方法完成;
12345// 近似於 <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 的結構如下:
1 2 3 4 5 6 7 8 |
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 的結構如下:
1 2 3 4 5 6 |
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 時,應用的匹配結果如下圖所示:
對應的渲染結果如下:
1 2 3 |
<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 的參考資源確實不多。特別是 1.0 版本釋出後很多文件都已經過時了,所以大家在查閱的時候一定要小心。此外,為了方便讀者更好的理解 react-router 的底層原理,也找了一個相關的資源供大家參考。
前導知識
這裡彙集了一些關於 url fragment 以及 html5 history API 相關的部分資源:
- 6 Things You Should Know About Fragment URLs
- URL的井號 – 阮一峰的網路日誌
- Peculiar IQ
- An Introduction To The HTML5 History API
- Manipulating the browser history
react-router 相關資源
這裡主要是 react-router 的資源。由於 react-router 1.0 相對於之前版本的 API 差異較大,目前網路上的資源也主要是官方文件,不過中文版已經翻譯好,讀者可以按照喜好選擇:
- react-router/docs at master · rackt/react-router · GitHub
- React Router 中文文件
- rackt/history · GitHub
- Building a Router with Raw React
視訊資源(需梯子)