【譯】手摸手寫一個你本身的 React Router v4

我還記得我最初開始學習前端路由時候的感受。那時我還年輕不懂事,剛剛開始摸索SPA。從一開始我就把程序代碼和路由代碼分開對待,我感受這是兩個不一樣的東西,它們就像同父異母的親兄弟,彼此不喜歡可是不得不在一塊兒生活。前端

在過去的幾年裏,我有幸可以將路由的思想傳授給其餘開發人員。不幸的是,事實證實,咱們大多數人的大腦彷佛與個人大腦有着類似的思考方式。我認爲這有幾個緣由。首先,路由一般很是複雜。對於這些庫的做者來講,這使得在路由中找到正確的抽象變得更加複雜。其次,因爲這種複雜性,路由庫的使用者每每盲目地信任抽象,而不真正瞭解底層的狀況,在本教程中,咱們將深刻解決這兩個問題。首先,經過從新建立咱們本身的React Router v4的簡化版本,咱們會對前者有所瞭解,也就是說,RRv4是不是一個合理的抽象。react

這裏是咱們的應用程序代碼,當咱們實現了咱們的路由,咱們能夠用這些代碼來作測試。完整的demo能夠參考這裏git

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 不熟悉,這裏作一個基本的介紹,當URL與您在Routes的path中指定的位置匹配時,Routes渲染相應的UI。Links提供了一種聲明性的、可訪問的方式來導航應用程序。換句話說,Link組件容許您更新URL, Route組件基於這個新URL更改UI。本教程的重點實際上並非教授RRV4的基礎知識,所以若是上面的代碼還不是很熟悉,請看官方文檔。github

首先要注意的是,咱們已經將路由器提供給咱們的兩個組件(Link和Route)引入到咱們的應用程序中。我最喜歡React Router v4的一點是,API只是組件。這意味着,若是您已經熟悉React,那麼您對組件以及如何組合組件的直覺將繼續適用於您的路由代碼。對於咱們這裏的用例來講,更方便的是,由於咱們已經熟悉瞭如何建立組件,建立咱們本身的React Router只須要作咱們已經作過的事情。正則表達式


咱們將從建立Route組件開始。在深刻研究代碼以前,讓咱們先來檢查一下這個API(它所須要的工具很是方便)。數組

在上面的示例中,您會注意到能夠包含三個props。exact,path和component。這意味着Route組件的propTypes目前是這樣的,瀏覽器

static propTypes = {
  exact: PropTypes.bool,
  path: PropTypes.string,
  component: PropTypes.func,
}
複製代碼

這裏有一些微妙之處。首先,不須要path的緣由是,若是沒有給Route指定路徑,它將自動渲染。其次,組件沒有標記爲required的緣由也在於,若是路徑匹配,實際上有幾種不一樣的方法告訴React Router您想呈現的UI。在咱們上面的例子中沒有的一種方法是render屬性。它是這樣的,bash

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

render容許您方便地內聯一個函數,該函數返回一些UI,而不是建立一個單獨的組件。咱們也會將它添加到propTypes中,react-router

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

如今咱們知道了 Route接收到哪些props了,讓咱們來再次討論它實際的功能。當URL與您在Route 的path屬性中指定的位置匹配時,Route渲染相應的UI。根據這個定義,咱們知道將須要一些功能來檢查當前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, // global DOM variable
      { path, exact }
    )

    if (!match) {
      // Do nothing because the current
      // location doesn't match the path prop.

      return null
    }

    if (component) {
      // The component prop takes precedent over the
      // render method. If the current location matches
      // the path prop, create a new element passing in
      // match as the prop.

      return React.createElement(component, { match })
    }

    if (render) {
      // If there's a match but component
      // was undefined, invoke the render
      // prop passing in match as an argument.

      return render({ match })
    }

    return null
  }
}
複製代碼

如今,Route 看起來很穩定了。若是匹配了傳進來的path,咱們就渲染組件不然返回null。

讓咱們退一步來討論一下路由。在客戶端應用程序中,用戶只有兩種方式更新URL。第一種方法是單擊錨標籤,第二種方法是單擊後退/前進按鈕。咱們的路由器須要知道當前URL並基於它呈現UI。這也意味着咱們的路由須要知道何時URL發生了變化,這樣它就能夠根據這個新的URL來決定顯示哪一個新的UI。若是咱們知道更新URL的惟一方法是經過錨標記或前進/後退按鈕,那麼咱們能夠開始計劃並對這些更改做出響應。稍後,當咱們構建組件時,咱們將討論錨標記,可是如今,我想重點關注後退/前進按鈕。React Router使用History .listen方法來監聽當前URL的變化,但爲了不引入其餘庫,咱們將使用HTML5的popstate事件。popstate正是咱們所須要的,它將在用戶單擊前進或後退按鈕時觸發。由於基於當前URL呈現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,它將啓動從新渲染。

如今,不管咱們渲染多少個,它們都會基於forward/back按鈕偵聽、從新匹配和從新渲染。

在這以前,咱們一直使用matchPath函數。這個函數對於咱們的路由很是關鍵,由於它將決定當前URL是否與咱們上面討論的組件的路徑匹配。matchPath的一個細微差異是,咱們須要確保咱們考慮到的exact屬性。若是你不知道確切是怎麼作的,這裏有一個直接來自文檔的解釋,

當爲true時,僅當路徑與location.pathname相等時才匹配。

path location.pathname exact matches?
/one /one/two true no
/one /one/two false yes

如今,讓咱們深刻了解matchPath函數的實現。若是您回頭看看Route組件,您將看到matchPath是這樣的調用的,

const match = matchPath(location.pathname, { path, exact })
複製代碼

match是對象仍是null取決因而否存在匹配。基於這個調用,咱們能夠構建matchPath的第一部分,

const matchPath = (pathname, options) => {
  const { exact = false, path } = options
}
複製代碼

這裏咱們使用了一些ES6語法。意思是,建立一個叫作exact的變量它等於options.exact,若是沒有定義,則設爲false。還要建立一個名爲path的變量,該變量等於options.path。

前面我提到"path不是必須的緣由是,若是沒有給定路徑,它將自動渲染」。由於它間接地就是咱們的matchPath函數,它決定是否渲染UI(經過是否存在匹配),如今讓咱們添加這個功能。

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

  if (!path) {
    return {
      path: null,
      url: pathname,
      isExact: true,
    }
  }
}
複製代碼

接下來是匹配部分。React Router 使用pathToRegexp來匹配路徑,爲了簡單咱們這裏就用簡單正則表達式。

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)
}
複製代碼

.exec 返回匹配到的路徑的數組,不然返回null。 咱們來看一個例子,當咱們路由到/topics/components時匹配到的路徑。

若是你不熟悉.exec,若是它找到匹配它會返回一個包含匹配文本的數組,不然它返回null。

下面是咱們的示例應用程序路由到/topics/components時的每一次匹配

path location.pathname return value
/ /topics/components ['/']
/about /topics/components null
/topics /topics/components ['/topics']
/topics/rendering /topics/components null
/topics/components /topics/components ['/topics/components']
/topics/props-v-state /topics/components null
/topics /topics/components ['/topics']

注意,咱們爲應用中的每一個<Route>都獲得了匹配。這是由於,每一個<Route>在它的渲染方法中調用matchPath

如今咱們知道了.exec返回的匹配項是什麼,咱們如今須要作的就是肯定是否存在匹配項。

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) {
    // There wasn't a match.
    return null
  }

  const url = match[0]
  const isExact = pathname === url

  if (exact && !isExact) {
    // There was a match, but it wasn't
    // an exact match as specified by
    // the exact prop.

    return null
  }

  return {
    path,
    url,
    isExact,
  }
}
複製代碼

前面我提到,若是您是用戶,那麼只有兩種方法能夠更新URL,經過後退/前進按鈕,或者單擊錨標籤。咱們已經處理了經過路由中的popstate事件偵聽器對後退/前進單擊進行從新渲染,如今讓咱們經過構建<Link>組件來處理錨標籤。

LinkAPI 是這樣的,

<Link to='/some-path' replace={false} />
複製代碼

to 是一個字符串,是要連接到的位置,而replace是一個布爾值,當該值爲true時,單擊該連接將替換歷史堆棧中的當前條目,而不是添加一個新條目。

將這些propTypes添加到連接組件中,咱們獲得,

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

如今咱們知道Link組件中的render方法須要返回一個錨標籤,可是咱們顯然不但願每次切換路由時都致使整個頁面刷新,所以咱們將經過向錨標籤添加onClick處理程序來劫持錨標籤

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

  handleClick = (event) => {
    const { replace, to } = this.props
    event.preventDefault()

    // route here.
  }

  render() {
    const { to, children} = this.props

    return (
      <a href={to} onClick={this.handleClick}> {children} </a>
    )
  }
}
複製代碼

如今所缺乏的就是改變當前的位置。爲了作到這一點,React Router使用了Historypushreplace方法,可是咱們將使用HTML5pushStatereplaceState方法來避免添加依賴項。

在這篇文章中,咱們將History庫做爲一種避免外部依賴的方法,但它對於真正的React Router代碼很是重要,由於它規範了在不一樣瀏覽器環境中管理會話歷史的差別。

pushStatereplaceState都接受三個參數。第一個是與新的歷史記錄條目相關聯的對象——咱們不須要這個功能,因此咱們只傳遞一個空對象。第二個是title,咱們也不須要它,因此咱們傳入null。第三個,也是咱們將要用到的,是一個相對URL

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

const historyReplace = (path) => {
  history.replaceState({}, null, path)
}
複製代碼

如今在咱們的Link組件中,咱們將調用historyPushhistoryReplace取決於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將保持徹底相同。這是由於即便咱們使用historyReplacehistoryPush函數更改位置,咱們的<Route>並不知道該更改,也不知道它們應該從新渲染和匹配。爲了解決這個問題,咱們須要跟蹤哪些<Route>已經呈現,並在路由發生變化時調用forceUpdate

React Router經過使用setStatecontexthistory的組合來解決這個問題。監聽包裝代碼的路由器組件內部。

爲了保持路由器的簡單性,咱們將經過將<Route>的實例保存到一個數組中,來跟蹤哪些<Route>已經呈現,而後每當發生位置更改時,咱們能夠遍歷該數組並對全部實例調用forceUpdate

let instances = []

const register = (comp) => instances.push(comp)
const unregister = (comp) => instances.splice(instances.indexOf(comp), 1)
複製代碼

注意,咱們建立了兩個函數。每當掛載<Route>時,咱們將調用register;每當卸載<Route>時,咱們將調用unregister。而後,不管什麼時候調用historyPushhistoryReplace(每當用戶單擊<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)
  }
  ...
}
複製代碼

如今,讓咱們更新historyPush和historyReplace,

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>都將意識到這一點並從新匹配和渲染。

如今,咱們的完整路由器代碼以下所示,上面的示例應用程序能夠完美地使用它。

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 還帶了一個額外的<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 Router的優雅和「Just Components」API。我老是說React會讓你成爲一個更好的JavaScript開發者。我如今也相信React Router會讓你成爲一個更好的React開發者。由於一切都是組件,若是你知道React,你就知道React Router

原文地址: Build your own React Router v4

(完)

相關文章
相關標籤/搜索