【译】手摸手写一个你自己的 React Router v4

我还记得我最初开始学习前端路由时候的感觉。那时我还年轻不懂事,刚刚开始摸索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

(完)

猜你喜欢

转载自juejin.im/post/5c3ffeace51d45521054159d