細說React(二)

上篇文章主要介紹了React的基本用法,此次將介紹一個React路由組件—react-router。html

在 web 應用開發中,路由系統是不可或缺的一部分。在瀏覽器當前的 URL 發生變化時,路由系統會作出一些響應,用來保證用戶界面與 URL 的同步。隨着單頁應用時代的到來,爲之服務的前端路由系統也相繼出現了。有一些獨立的第三方路由系統,好比 director,代碼庫也比較輕量。固然,主流的前端框架也都有本身的路由,好比 Backbone、Ember、Angular、React 等等。那 react-router 相對於其餘路由系統又針對 React 作了哪些優化呢?它是如何利用了 React 的 UI 狀態機特性呢?又是如何將 JSX 這種聲明式的特性用在路由中?前端

一個簡單的示例

如今,咱們經過一個簡易的博客系統示例來解釋剛剛遇到的疑問,它包含了查看文章歸檔、文章詳細、登陸、退出以及權限校驗幾個功能,該系統的完整代碼託管在 JS Bin(注意,文中示例代碼使用了與之對應的 ES6 語法),你能夠點擊連接查看。此外,該實例所有基於最新的 react-router 1.0 進行編寫。下面看一下 react-router 的應用實例:react

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 類庫,引入它的特性也是無可厚非。仔細看一下,你會發現:git

  • Router 與 Route 同樣都是 react 組件,它的 history 對象是整個路由系統的核心,它暴漏了不少屬性和方法在路由系統中使用;github

  • Route 的 path 屬性表示路由組件所對應的路徑,能夠是絕對或相對路徑,相對路徑可繼承;web

  • Redirect 是一個重定向組件,有 from 和 to 兩個屬性;算法

  • Route 的 onEnter 鉤子將用於在渲染對象的組件前作攔截操做,好比驗證權限;express

  • 在 Route 中,可使用 component 指定單個組件,或者經過 components 指定多個組件集合;redux

  • 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 相關資源

相關文章
相關標籤/搜索