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

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

多年以后当我回想起初学客户端路由的那个下午,满脑子里充斥着的只是对于单页应用的惊叹与浆糊。彼时我仍是将应用代码与路由代码当作两个独立的部分进行处理,就好像同父异母的兄弟尽管不喜欢对方可是不得不在一块儿。幸而这些年里我可以和其余优秀的开发者进行交流,了解他们对于客户端路由的见解。尽管他们中的大部分与我“英雄所见略同”,可是我仍是找到了合适的平衡路由的抽象程度与复杂程度的方法。本文便是我在构建 React Router V4 过程当中的考虑以及所谓路由即组件思想的落地实践。首先咱们来看下咱们在构建路由过程当中的测试代码,你能够用它来测试你的自定义路由: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 尚不是彻底了解,咱们先对上述代码中涉及到的相关关键字进行解释。Route 会在当前 URL 与 path 属性值相符的时候渲染相关组件,而 Link 提供了声明式的,易使用的方式来在应用内进行跳转。换言之,Link 组件容许你更新当前 URL,而 Route 组件则是根据 URL 渲染组件。本文并不专一于讲解 RRV4 的基础概念,你能够前往官方文档了解更多知识;本文是但愿介绍我在构建 React Router V4 过程当中的思惟考虑过程,值得一提的是,我很欣赏 React Router V4 中的 Just Components 概念,这一点就不一样于 React Router 以前的版本中将路由与组件隔离来看,而容许了路由组件像普通组件同样自由组合。相信对于 React 组件至关熟悉的开发者毫不会陌生于如何将路由组件嵌入到正常的应用中。github

Route

咱们首先来考量下如何构建Route组件,包括其暴露的 API,即 Props。在咱们上面的示例中,咱们会发现Route组件包含三个 Props:exactpath 以及 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 函数定义以下:react-router

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 才会被认为匹配成功:源码分析

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

让咱们深度了解下 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 中的抽象的 pushreplace 函数,在使用 browserHistory 的状况下咱们本质上仍是使用 HTML5 中的 pushStatereplaceState 函数。pushStatereplaceState 函数都要求输入三个参数,首先是一个与最新的历史记录相关的对象,在 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 主要经过有机组合 setStatecontext 以及 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)
  }

  ...
}

而后咱们须要更新 historyPushhistoryReplace 函数:

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 则会是你不小的助力。

相关文章
相关标签/搜索