由淺入深地教你開發本身的 React Router v4

做者:Tyler <br/>
編譯:鬍子大哈 react

翻譯原文:http://huziketang.com/blog/posts/detail?postId=58d36df87413fc2e82408555 <br/>
英文原文:Build your own React Router v4)git

轉載請註明出處,保留原文連接以及做者信息程序員


我還記得我第一次學習開發客戶端應用路由時的感受,那時候我仍是一個涉足在「單頁面應用」的未出世的小夥子,那會兒,要是說它沒把個人腦子弄的跟屎似的,那我是在撒謊。一開始的時候,個人感受是個人應用程序代碼和路由代碼是兩個獨立且不一樣的體系,就像是兩個同父異母的兄弟,互相不喜歡可是又不得不在一塊兒。github

通過了一些年的努力,我終於有幸可以教其餘開發者關於路由的一些問題了。我發現,好像不少人對於這個問題的思考方式都和我當時很相似。我以爲有幾個緣由。首先,路由問題確實很複雜,對於那些路由庫的開發者而言,找到一個合適的路由抽象概念來解釋這個問題就更加複雜。第二,正是因爲路由的複雜性,這些路由庫的使用者傾向於只使用庫就行了,而不去弄懂到底背後是什麼原理。數組

本文中,咱們會深刻地來闡述這兩個問題。咱們會經過建立一個簡單版本的 React Router v4 來解決第二個問題,而經過這個過程來闡釋第一個問題。也就是說經過咱們本身構建 RRv4 來解釋 RRv4 是不是一個合適的路由抽象。瀏覽器

下面是將要用來測試咱們所構建的 React Router 的代碼。最終的代碼實例你能夠在這裏獲得。session

const Home = () => (
  <h2>Home</h2>
)
const About = () => (
  <h2>About</h2>
)
const Topic = ({ topicId }) => (
  <h3>{topicId}</h3>
)
const Topics = ({ match }) => {
  const items = [
    { name: 'Rendering with React', slug: 'rendering' },
    { name: 'Components', slug: 'components' },
    { name: 'Props v. State', slug: 'props-v-state' },
  ]
  return (
    <div>
      <h2>Topics</h2>
      <ul>
        {items.map(({ name, slug }) => (
          <li key={name}>
            <Link to={`${match.url}/${slug}`}>{name}</Link>
          </li>
        ))}
      </ul>
      {items.map(({ name, slug }) => (
        <Route 
          key={name} 
          path={`${match.path}/${slug}`} 
          render={() => (
            <Topic topicId={name} />
        )} />
      ))}
      <Route exact path={match.url} render={() => (
        <h3>Please select a topic.</h3>
      )}/>
    </div>
  )
}
const App = () => (
  <div>
    <ul>
      <li><Link to="/">Home</Link></li>
      <li><Link to="/about">About</Link></li>
      <li><Link to="/topics">Topics</Link></li>
    </ul>
    <hr/>
    <Route exact path="/" component={Home}/>
    <Route path="/about" component={About}/>
    <Route path="/topics" component={Topics} />
  </div>
)

若是你還不熟悉 React Router v4,就先了解幾個基本問題。Route 用來渲染 UI,當一個 URL 匹配上了你所指定的路由路徑,就進行渲染。Link 提供了一個能夠瀏覽訪問你 app 的方法。換句話講,Link 組件容許你更新你的 URL,而 Route 組件根據你所提供的新 URL 來改變 UI。react-router

本文並不會手把手的教你 RRV4 的基礎,因此若是上面的代碼你看起來很費勁的話,能夠先來這裏看一下官方文檔。把玩一下里面的例子,當你以爲順手了的時候,歡迎回來繼續閱讀app

如上段所說,路由給咱們提供了兩個組件能夠用於你的 app:LinkRoute。我喜歡 React Router v4 的緣由是它的 API 「只是組件」而已,能夠理解成沒有引入其餘概念。這就是說若是你對 React 很熟悉的話,那麼你對組件以及怎麼組合組件必定有本身的理解,而這對於你寫路由代碼依然適用。這就很方便了,由於已經熟悉瞭如何創造組件,那麼建立你本身的 React Router 就只是作你已經熟悉的事情——建立組件。函數

如今就來一塊兒建立咱們的 Route 組件。在上面的例子中,能夠注意到 <Route> 使用了三個屬性:exactpathcomponent。他們的屬性類型(propTypes)對於 Route 組件來是這樣的:

static propTypes = {
  exact: PropTypes.bool,
  path: PropTypes.string,
  component: PropTypes.func,
}

這裏有些小細節。首先,path 並不須要,由於若是路由中沒有給 path 那麼將會自動渲染。第二,component 也不須要,是由於若是路徑匹配上了,有不少不一樣的方法來告訴 React Router 要渲染什麼 UI。其中一個上面沒有提到的方法就是使用 render 來通知 React Router,具體代碼像這樣:

<Route path='/settings' render={({ match }) => {
  return <Settings authed={isAuthed} match={match} />
}} />

render 容許你建立一個直接返回 UI 的內聯函數而不用建立額外的組件,因此咱們也能夠把它添加到 proTypes 中:

static propTypes = {
  exact: PropTypes.bool,
  path: PropTypes.string,
  component: PropTypes.func,
  render: PropTypes.func,
}

如今咱們知道了 Route 接收的屬性,咱們來了解一下它們的具體功能。還記得上面說的:「當 URL 匹配上了你所指定的路由 path 之後,Route 渲染其對應的 UI」。基於這樣的定義,能夠知道,<Route> 須要一些功能性函數,來判斷當前的 URL 是否匹配上了組件的 path 屬性。若是匹配上了,那麼返回渲染的 UI;若是沒有那麼什麼都不作而且返回 null。

一塊兒來看一下這個匹配函數應該怎麼寫,暫且把它叫作 matchPath 吧。

class Route extends Component {
  static propTypes = {
    exact: PropTypes.bool,
    path: PropTypes.string,
    component: PropTypes.func,
    render: PropTypes.func,
  }
  render () {
    const {
      path,
      exact,
      component,
      render,
    } = this.props
    const match = matchPath(
      location.pathname, // 全局 DOM 變量
      { path, exact }
    )
    if (!match) {
      // 什麼都不作,由於沒有匹配上 path 屬性
      return null
    }
    if (component) {
      // 若是當前地址匹配上了 path 屬性
      // 以 component 建立新元素而且經過 match 傳遞
      return React.createElement(component, { match })
    }
    if (render) {
      // 若是匹配上了且 component 沒有定義
      // 則調用 render 並以 match 做爲參數
      return render({ match })
    }
    return null
  }
}

上面的代碼即實現了:若是匹配上了 path 屬性,就返回 UI,不然什麼也不作。

咱們再來談一下路由的問題。在客戶端應用這邊,通常來說只有兩種方式更新 URL。一種是用戶點擊 a 標籤,一種是點擊後退/前進按鈕。基本上咱們的路由只要關心 URL 的變化而且返回相應的 UI 便可。假設咱們知道更新 URL 的方式只有上面兩種,那麼就能夠針對這兩種狀況作特殊處理了。稍後在構建 <Link> 組件的時候再詳細介紹 a 標籤的狀況,這裏先討論後退/前進按鈕。 React Router 使用了 History工程裏的 .listen 方法來監聽當前 URL 的變化,爲了不再引入其餘的庫,咱們使用 HTML5 的 popstate 事件來實現這一功能。當用戶點擊了後退/前進按鈕,popstate 就被觸發,咱們須要的就是這個功能。由於 Route 渲染 UI 是根據當前 URL來作的,所以給 Route 配上監聽能力也是合理的,在 popstate 觸發的地方從新渲染 UI。就是說在觸發 popstate 時檢查是否匹配上了新的 URL,若是是則渲染 UI,若是不是,什麼也不作,下面看一下代碼。

class Route extends Component {
  static propTypes: {
    path: PropTypes.string,
    exact: PropTypes.bool,
    component: PropTypes.func,
    render: PropTypes.func,
  }
  componentWillMount() {
    addEventListener("popstate", this.handlePop)
  }
  componentWillUnmount() {
    removeEventListener("popstate", this.handlePop)
  }
  handlePop = () => {
    this.forceUpdate()
  }
  render() {
    const {
      path,
      exact,
      component,
      render,
    } = this.props
    const match = matchPath(location.pathname, { path, exact })
    if (!match)
      return null
    if (component)
      return React.createElement(component, { match })
    if (render)
      return render({ match })
    return null
  }
}

這裏要注意的是咱們只是加了一個 popstate 監聽,當 popstate 觸發的時候,調用 forceUpdate 來強制作從新渲染的判斷。

這樣就實現了全部的 <Route> 都會監聽,根據後退/前進按鈕來「重匹配」、「重判斷」和「重渲染」。

到如今,咱們一直尚未實現的是 matchPath 函數。這個函數在咱們的 router 中是特別關鍵的,由於它是判斷當前 URL 是否匹配上了 <Route> 組件的關鍵點。matchPath 值得注意的一點是必定要把 <Route>exact 考慮清楚。若是你對 exact 還不瞭解,看下下面這句話,給出了規範文檔中的解釋:

只有當所給路徑精確匹配上 location.pathname 時才返回 true。

接下來就來具體實現 matchPath 函數。若是你回頭看一下上面 Route 組件的代碼,你能夠看到 matchPath 函數是這樣的:

const match = matchPath(location.pathname, { path, exact })

這裏的 match 要麼是對象,要麼是 null,這得取決因而否匹配上 path。根據這個聲明,咱們來寫 matchPath 代碼:

const matchPatch = (pathname, options) => {
  const { exact = false, path } = options
}

這裏使用 ES6 語法。上面的意思是,建立一個叫作 exact 的變量,使其等於 options.exact,而且若是非 null 的話則設置其爲 false。一樣建立一個叫作 path 的變量,使其等於 options.path。

接下來就添加判斷是否匹配。React Router 使用 pathToRegex 來實現,只須要寫簡單的正則匹配就能夠了。

const matchPatch = (pathname, options) => {
  const { exact = false, path } = options
  if (!path) {
    return {
      path: null,
      url: pathname,
      isExact: true,
    }
  }
  const match = new RegExp(`^${path}`).exec(pathname)
}

若是匹配上了,那麼返回一個包含有全部匹配串的數組,不然返回 null。

下面是咱們示例 app 的路由 '/topics/components' 的一些匹配項。

注意:每一個 <Route> 都在本身的渲染方法裏調用 matchPath,因此要爲每一個 <Route> 配一個 match

如今咱們要作的是添加判斷是否有匹配的代碼:

const matchPatch = (pathname, options) => {
  const { exact = false, path } = options
  if (!path) {
    return {
      path: null,
      url: pathname,
      isExact: true,
    }
  }
  const match = new RegExp(`^${path}`).exec(pathname)
  if (!match) {
    // 沒有匹配上
    return null
  }
  const url = match[0]
  const isExact = pathname === url
  if (exact && !isExact) {
    // 匹配上了,可是不是精確匹配
    return null
  }
  return {
    path,
    url,
    isExact,
  }
}

提示一下以前有講過的,對於用戶來說,有兩種方式更新 URL:經過後退/前進按鈕和經過點擊 a 標籤。對於後退/前進點擊來講,使用 popstate 事件給 Route 添加監聽就能夠,如今來看一下如何經過 Link 解決 a 標籤問題。

Link 的 API 以下:

<Link to='/some-path' replace={false} />

這裏 to 是一個 string 類型,指的是要連接到的地址。replace 是一個布爾值,若是是 true,那麼點擊連接將替換當前的實體到歷史堆棧,而不是添加一個新的進去。

添加這些 propTypes 到 Link 組件就獲得:

class Link extends Component {
  static propTypes = {
    to: PropTypes.string.isRequired,
    replace: PropTypes.bool,
  }
}

咱們知道在 Link 組件中的渲染函數須要返回一個 a 標籤,可是咱們不想每次變路由都進行一次全頁面刷新,因此經過增長一個 onClick 處理程序來劫持 a 標籤。

class Link extends Component {
  static propTypes = {
    to: PropTypes.string.isRequired,
    replace: PropTypes.bool,
  }
  handleClick = (event) => {
    const { replace, to } = this.props
    event.preventDefault()
    // 這裏是路由
  }
  render() {
    const { to, children} = this.props
    return (
      <a href={to} onClick={this.handleClick}>
        {children}
      </a>
    )
  }
}

ok,代碼寫到如今,就差更改當前 URL 了。在 React Router 是使用 History 工程裏面的 pushreplace 方法。爲了不增長新依賴,這裏我使用 HTML5 的 pushStatereplaceState

本文中咱們爲了防止引入額外的依賴,一直也沒采用 History 庫。可是它對真實的 React Router 倒是相當重要的,由於它對不一樣的 session 管理和不一樣的瀏覽器環境進行了規範化處理。

pushStatereplaceState 都接收三個參數。第一個參數是一個與歷史實體相關聯的對象,咱們不須要,因此設置成一個空對象。第二個參數是標題,咱們也不須要,因此也設置成空。第三個是咱們須要使用的,指的是:相關 URL。

const historyPush = (path) => {
  history.pushState({}, null, path)
}
const historyReplace = (path) => {
  history.replaceState({}, null, path)
}

Link 組件內部,會調用 historyPush 或者 historyReplace,依賴於前面提到的 replace 屬性。

class Link extends Component {
  static propTypes = {
    to: PropTypes.string.isRequired,
    replace: PropTypes.bool,
  }
  handleClick = (event) => {
    const { replace, to } = this.props
    event.preventDefault()
    replace ? historyReplace(to) : historyPush(to)
  }
  render() {
    const { to, children} = this.props
    return (
      <a href={to} onClick={this.handleClick}>
        {children}
      </a>
    )
  }
}

如今就只剩下最後一件很關鍵的問題了,若是你想把上面的例子用在本身的路由代碼裏面,你須要注意這個問題。當你瀏覽時,URL 會發生改變,可是 UI 卻沒有刷新,這是爲何呢?這是由於,儘管你經過 historyReplace 或者 historyPush 改變了地址,可是 <Route> 並無意識到已經改變了,也不知道應該重匹配和重渲染。爲了解決這個問題,須要跟蹤每一條 <Route> 而且當路由發生改變的時候調用 forceUpdate

React Router 經過設置狀態、上下文和歷史信息的組合來解決這個問題。監聽路由組件的內部代碼。

爲了使路由簡單,咱們經過把全部路由對象放到一個數組裏的方式來實現 <Route> 跟蹤。每當發生地址改變的時候,就遍歷一遍數組,調用相應對象的 forceUpdate 函數。

let instances = []
const register = (comp) => instances.push(comp)
const unregister = (comp) => instances.splice(instances.indexOf(comp), 1)

注意這裏建立了兩個函數。當 <Route> 「裝配」上,就調用 register;當「解裝配」,就調用 unregister。而後只要調用 historyPush 或者 historyReplace(實際上用戶每次點擊 <Link> 都會調用),就遍歷對象數組,並調用 forceUpdate

首先更新 <Route> 組件:

class Route extends Component {
  static propTypes: {
    path: PropTypes.string,
    exact: PropTypes.bool,
    component: PropTypes.func,
    render: PropTypes.func,
  }
  componentWillMount() {
    addEventListener("popstate", this.handlePop)
    register(this)
  }
  componentWillUnmount() {
    unregister(this)
    removeEventListener("popstate", this.handlePop)
  }
...
}

再更新 historyPushhistoryReplace

const historyPush = (path) => {
  history.pushState({}, null, path)
  instances.forEach(instance => instance.forceUpdate())
}
const historyReplace = (path) => {
  history.replaceState({}, null, path)
  instances.forEach(instance => instance.forceUpdate())
}

這時只要 <Link> 被點擊而且地址發生變化,每一個 <Route> 都會接收到消息,而且進行重匹配和重渲染。

這就完成了全部的路由代碼了,而且實例 app 用這些代碼能夠完美運行!

import React, { PropTypes, Component } from 'react'
let instances = []
const register = (comp) => instances.push(comp)
const unregister = (comp) =>      instances.splice(instances.indexOf(comp), 1)
const historyPush = (path) => {
  history.pushState({}, null, path)
  instances.forEach(instance => instance.forceUpdate())
}
const historyReplace = (path) => {
  history.replaceState({}, null, path)
  instances.forEach(instance => instance.forceUpdate())
}
const matchPath = (pathname, options) => {
  const { exact = false, path } = options
  if (!path) {
    return {
      path: null,
      url: pathname,
      isExact: true
    }
  }
  const match = new RegExp(`^${path}`).exec(pathname)
  if (!match)
    return null
  const url = match[0]
  const isExact = pathname === url
  if (exact && !isExact)
    return null
  return {
    path,
    url,
    isExact,
  }
}
class Route extends Component {
  static propTypes: {
    path: PropTypes.string,
    exact: PropTypes.bool,
    component: PropTypes.func,
    render: PropTypes.func,
  }
  componentWillMount() {
    addEventListener("popstate", this.handlePop)
    register(this)
  }
  componentWillUnmount() {
    unregister(this)
    removeEventListener("popstate", this.handlePop)
  }
  handlePop = () => {
    this.forceUpdate()
  }
  render() {
    const {
      path,
      exact,
      component,
      render,
    } = this.props
    const match = matchPath(location.pathname, { path, exact })
    if (!match)
      return null
    if (component)
      return React.createElement(component, { match })
    if (render)
      return render({ match })
    return null
  }
}
class Link extends Component {
  static propTypes = {
    to: PropTypes.string.isRequired,
    replace: PropTypes.bool,
  }
  handleClick = (event) => {
    const { replace, to } = this.props
    event.preventDefault()
    replace ? historyReplace(to) : historyPush(to)
  }
  render() {
    const { to, children} = this.props
    return (
      <a href={to} onClick={this.handleClick}>
        {children}
      </a>
    )
  }
}

另外:React Router API 還天然派生出了 <Redirect> 組件。使用上面咱們寫的代碼,這個組件能夠直接寫成:

class Redirect extends Component {
  static defaultProps = {
    push: false
  }
  static propTypes = {
    to: PropTypes.string.isRequired,
    push: PropTypes.bool.isRequired,
  }
  componentDidMount() {
    const { to, push } = this.props
    push ? historyPush(to) : historyReplace(to)
  }
  render() {
    return null
  }
}

注意這個組件並不渲染任何 UI,它只用來作路由定向使用。

我但願這篇文章對你在認識 React Router 上有所啓發。我總跟個人朋友們講,React 會使你成爲一個好的 JavaScript 程序員,而 React Router 會使你成爲一個好的 React 程序員。由於一切皆爲組件,你懂 React,你就懂 React Router。


我最近正在寫一本《React.js 小書》,對 React.js 感興趣的童鞋,歡迎指點

相關文章
相關標籤/搜索