【译】手摸手写一个你本身的 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

(完)

相关文章
相关标签/搜索