【譯】手摸手寫一個你自己的 React Router v4

ANT1994發表於2019-01-17

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

在過去的幾年裡,我有幸能夠將路由的思想傳授給其他開發人員。不幸的是,事實證明,我們大多數人的大腦似乎與我的大腦有著相似的思考方式。我認為這有幾個原因。首先,路由通常非常複雜。對於這些庫的作者來說,這使得在路由中找到正確的抽象變得更加複雜。其次,由於這種複雜性,路由庫的使用者往往盲目地信任抽象,而不真正瞭解底層的情況,在本教程中,我們將深入解決這兩個問題。首先,通過重新建立我們自己的React Router v4的簡化版本,我們會對前者有所瞭解,也就是說,RRv4是否是一個合理的抽象。

這裡是我們的應用程式程式碼,當我們實現了我們的路由,我們可以用這些程式碼來做測試。完整的demo可以參考這裡

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的基礎知識,因此如果上面的程式碼還不是很熟悉,請看官方文件。

首先要注意的是,我們已經將路由器提供給我們的兩個元件(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屬性。它是這樣的,

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

render允許您方便地內聯一個函式,該函式返回一些UI,而不是建立一個單獨的元件。我們也會將它新增到propTypes中,

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

(完)

相關文章