ReactRouter-V4 构建之道与源码分析

ReactRouter-V4 构建之道与源码分析

ReactRouter-V4 构建之道与源码分析 翻译自Build your own React Router v4,从属于笔者的 Web 开发基础与工程实践 系列。

多年之后当我回想起初学客户端路由的那个下午,满脑子里充斥着的只是对于单页应用的惊叹与浆糊。彼时我还是将应用代码与路由代码当做两个独立的部分进行处理,就好像同父异母的兄弟尽管不喜欢对方但是不得不在一起。幸而这些年里我能够和其他优秀的开发者进行交流,了解他们对于客户端路由的看法。尽管他们中的大部分与我“英雄所见略同”,但是我还是找到了合适的平衡路由的抽象程度与复杂程度的方法。本文即是我在构建 React Router V4 过程中的考虑以及所谓路由即组件思想的落地实践。首先我们来看下我们在构建路由过程中的测试代码,你可以用它来测试你的自定义路由:

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 会在当前 URL 与 path 属性值相符的时候渲染相关组件,而 Link 提供了声明式的,易使用的方式来在应用内进行跳转。换言之,Link 组件允许你更新当前 URL,而 Route 组件则是根据 URL 渲染组件。本文并不专注于讲解 RRV4 的基础概念,你可以前往官方文档了解更多知识;本文是希望介绍我在构建 React Router V4 过程中的思维考虑过程,值得一提的是,我很欣赏 React Router V4 中的 Just Components 概念,这一点就不同于 React Router 之前的版本中将路由与组件隔离来看,而允许了路由组件像普通组件一样自由组合。相信对于 React 组件相当熟悉的开发者绝不会陌生于如何将路由组件嵌入到正常的应用中。

Route

我们首先来考量下如何构建Route组件,包括其暴露的 API,即 Props。在我们上面的示例中,我们会发现Route组件包含三个 Props:exact、path 以及 component。这也就意味着我们的propTypes声明如下:

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

这里有一些微妙的细节需要考虑,首先对于path并没有设置为必须参数,这是因为我们认为对于没有指定关联路径的Route组件应该自动默认渲染。而component参数也没有被设置为必须是因为我们提供了其他的方式进行渲染,譬如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 需要接收的组件参数之后,我们需要来考量其实际功能;Route 核心的功能在于能够当 URL 与 path 属性相一致时执行渲染操作。基于这个论断,我们首先需要实现判断是否匹配的功能,如果判断为匹配则执行渲染否则返回空值。我们在这里将该函数命名为 matchPatch,那么此时整个 Route 组件的 render 函数定义如下:

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 看起来已经相对明确了,当路径相匹配的时候才会执行界面渲染,否则返回为空。现在我们再回过头来考虑客户端路由中常见的跳转策略,一般来说用户只有两种方式会更新当前 URL。一种是用户点击了某个锚标签或者直接操作 history 对象的 replace/push 方法;另一种是用户点击前进/后退按钮。无论哪一种方式都要求我们的路由系统能够实时监听 URL 的变化,并且在 URL 发生变化时及时地做出响应,渲染出正确的页面。我们首先来考虑下如何处理用户点击前进/后退按钮。React Router 使用 History 的 .listen 方法来监听当前 URL 的变化,其本质上还是直接监听 HTML5 的 popstate 事件。popstate 事件会在用户点击某个前进/后退按钮的时候触发;而在每次重渲染的时候,每个 Route 组件都会重现检测当前 URL 是否匹配其预设的路径参数。

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 监听器;当监听到 popstate 事件被触发时,我们会调用 forceUpdate 函数来强制进行重渲染。总结而言,无论我们在系统中设置了多少的路由组件,它们都会独立地监听 popstate 事件并且相应地执行重渲染操作。接下来我们继续讨论 matchPath 这个 Route 组件中至关重要的函数,它负责决定当前路由组件的 path 参数是否与当前 URL 相一致。这里还必须提下我们设置的另一个 Route 的参数 exact,其用于指明路径匹配策略;当 exact 值被设置为 true 时,仅当路径完全匹配于 location.pathname 才会被认为匹配成功:

pathlocation.pathnameexactmatches?/one/one/twotrueno/one/one/twofalseyes

让我们深度了解下 matchPath 函数的工作原理,该函数的签名如下:

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

其中函数的返回值 match 应该根据路径是否匹配的情况返回为空或者一个对象。基于这些推导我们可以得出 matchPatch 的原型:

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

这里我们使用 ES6 的解构赋值,当某个属性未定义时我们使用预定义地默认值,即 false。我在上文提及的 path 非必要参数的具体支撑实现就在这里,我们首先进行空检测,当发现 path 为未定义或者为空时则直接返回匹配成功:

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

  if (!path) {
    return {
      path: null,
      url: pathname,
      isExact: true,
    }
  }
}

接下来继续考虑具体执行匹配的部分,React Router 使用了 pathToRegex 来检测是否匹配,即可以用简单的正则表达式:

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 函数,会在包含指定的文本时返回一个数组,否则返回空值;下表即是当我们的路由设置为 /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> 实例创建一个 match 对象。在获取到 match 对象之后,我们需要再做如下判断是否匹配:

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,
  }
}

Link

上文我们已经提及通过监听 popstate 状态来响应用户点击前进/后退事件,现在我们来考虑通过构建 Link 组件来处理用户通过点击锚标签进行跳转的事件。Link 组件的 API 应该如下所示:

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

其中的 to 是一个指向跳转目标地址的字符串,而 replace 则是布尔变量来指定当用户点击跳转时是替换 history 栈中的记录还是插入新的记录。基于上述的 API 设计,我们可以得到如下的组件声明:

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

现在我们已经知道 Link 组件的渲染函数中需要返回一个锚标签,不过我们的前提是要避免每次用户切换路由的时候都进行整页的刷新,因此我们需要为每个锚标签添加一个点击事件的处理器:

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>
    )
  }
}

这里实际的跳转操作我们还是执行 History 中的抽象的 push 与 replace 函数,在使用 browserHistory 的情况下我们本质上还是使用 HTML5 中的 pushState 与 replaceState 函数。pushState 与 replaceState 函数都要求输入三个参数,首先是一个与最新的历史记录相关的对象,在 React Router 中我们并不需要该对象,因此直接传入一个空对象;第二个参数是标题参数,我们同样不需要改变该值,因此直接传入空即可;最后第三个参数则是我们需要的,用于指明新的相对地址的字符串:

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

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

而后在 Link 组件内,我们会根据 replace 参数来调用 historyPush 或者 historyReplace 函数:

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>
    )
  }
}

组件注册

现在我们需要考虑如何保证用户点击了 Link 组件之后触发全部路由组件的检测与重渲染。在我们上面实现的 Link 组件中,用户执行跳转之后浏览器的显示地址会发生变化,但是页面尚不能重新渲染;我们声明的 Route 组件并不能收到相应的通知。为了解决这个问题,我们需要追踪那些显现在界面上实际被渲染的 Route 组件并且当路由变化时调用它们的 forceUpdate 方法。React Router 主要通过有机组合 setState、context 以及 history.listen 方法来实现该功能。每个 Route 组件被挂载时我们会将其加入到某个数组中,然后当位置变化时,我们可以遍历该数组然后对每个实例调用 forceUpdate 方法:

let instances = []

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

这里我们创建了两个函数,当 Route 挂载时调用 register 函数,而卸载时调用 unregister 函数。然后无论何时调用 historyPush 或者 historyReplace 函数时都会遍历实例数组中的对象的渲染方法,此时我们的 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 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
  }
}

注意这个组件并没有真实地进行界面渲染,而是仅仅进行了简单的跳转操作。到这里本文也就告一段落了,希望能够帮助你去了解 React Router V4 的设计思想以及 Just Component 的接口理念。我一直说 React 会让你成为更加优秀地开发者,而 React Router 则会是你不小的助力。

编辑于 2017-03-11 00:44