由浅入深地教你开发本身的 React Router v4

做者:Tyler

编译:胡子大哈 javascript

翻译原文:huziketang.com/blog/posts/…

英文原文:Build your own React Router v4)java

转载请注明出处,保留原文连接以及做者信息react


我还记得我第一次学习开发客户端应用路由时的感受,那时候我仍是一个涉足在“单页面应用”的未出世的小伙子,那会儿,要是说它没把个人脑子弄的跟屎似的,那我是在撒谎。一开始的时候,个人感受是个人应用程序代码和路由代码是两个独立且不一样的体系,就像是两个同父异母的兄弟,互相不喜欢可是又不得不在一块儿。git

通过了一些年的努力,我终于有幸可以教其余开发者关于路由的一些问题了。我发现,好像不少人对于这个问题的思考方式都和我当时很相似。我以为有几个缘由。首先,路由问题确实很复杂,对于那些路由库的开发者而言,找到一个合适的路由抽象概念来解释这个问题就更加复杂。第二,正是因为路由的复杂性,这些路由库的使用者倾向于只使用库就行了,而不去弄懂到底背后是什么原理。程序员

本文中,咱们会深刻地来阐述这两个问题。咱们会经过建立一个简单版本的 React Router v4 来解决第二个问题,而经过这个过程来阐释第一个问题。也就是说经过咱们本身构建 RRv4 来解释 RRv4 是不是一个合适的路由抽象。github

下面是将要用来测试咱们所构建的 React Router 的代码。最终的代码实例你能够在这里获得。数组

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 用来渲染 UI,当一个 URL 匹配上了你所指定的路由路径,就进行渲染。Link 提供了一个能够浏览访问你 app 的方法。换句话讲,Link 组件容许你更新你的 URL,而 Route 组件根据你所提供的新 URL 来改变 UI。浏览器

本文并不会手把手的教你 RRV4 的基础,因此若是上面的代码你看起来很费劲的话,能够先来这里看一下官方文档。把玩一下里面的例子,当你以为顺手了的时候,欢迎回来继续阅读session

如上段所说,路由给咱们提供了两个组件能够用于你的 app:LinkRoute。我喜欢 React Router v4 的缘由是它的 API “只是组件”而已,能够理解成没有引入其余概念。这就是说若是你对 React 很熟悉的话,那么你对组件以及怎么组合组件必定有本身的理解,而这对于你写路由代码依然适用。这就很方便了,由于已经熟悉了如何创造组件,那么建立你本身的 React Router 就只是作你已经熟悉的事情——建立组件。react-router

如今就来一块儿建立咱们的 Route 组件。在上面的例子中,能够注意到 <Route> 使用了三个属性:exactpathcomponent。他们的属性类型(propTypes)对于 Route 组件来是这样的:

static propTypes = {
  exact: PropTypes.bool,
  path: PropTypes.string,
  component: PropTypes.func,
}复制代码

这里有些小细节。首先,path 并不须要,由于若是路由中没有给 path 那么将会自动渲染。第二,component 也不须要,是由于若是路径匹配上了,有不少不一样的方法来告诉 React Router 要渲染什么 UI。其中一个上面没有提到的方法就是使用 render 来通知 React Router,具体代码像这样:

<Route path='/settings' render={({ match }) => {
  return <Settings authed={isAuthed} match={match} /> }} />复制代码

render 容许你建立一个直接返回 UI 的内联函数而不用建立额外的组件,因此咱们也能够把它添加到 proTypes 中:

static propTypes = {
  exact: PropTypes.bool,
  path: PropTypes.string,
  component: PropTypes.func,
  render: PropTypes.func,
}复制代码

如今咱们知道了 Route 接收的属性,咱们来了解一下它们的具体功能。还记得上面说的:“当 URL 匹配上了你所指定的路由 path 之后,Route 渲染其对应的 UI”。基于这样的定义,能够知道,<Route> 须要一些功能性函数,来判断当前的 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, // 全局 DOM 变量
      { path, exact }
    )
    if (!match) {
      // 什么都不作,由于没有匹配上 path 属性
      return null
    }
    if (component) {
      // 若是当前地址匹配上了 path 属性
      // 以 component 建立新元素而且经过 match 传递
      return React.createElement(component, { match })
    }
    if (render) {
      // 若是匹配上了且 component 没有定义
      // 则调用 render 并以 match 做为参数
      return render({ match })
    }
    return null
  }
}复制代码

上面的代码即实现了:若是匹配上了 path 属性,就返回 UI,不然什么也不作。

咱们再来谈一下路由的问题。在客户端应用这边,通常来说只有两种方式更新 URL。一种是用户点击 a 标签,一种是点击后退/前进按钮。基本上咱们的路由只要关心 URL 的变化而且返回相应的 UI 便可。假设咱们知道更新 URL 的方式只有上面两种,那么就能够针对这两种状况作特殊处理了。稍后在构建 <Link> 组件的时候再详细介绍 a 标签的状况,这里先讨论后退/前进按钮。 React Router 使用了 History工程里的 .listen 方法来监听当前 URL 的变化,为了不再引入其余的库,咱们使用 HTML5 的 popstate 事件来实现这一功能。当用户点击了后退/前进按钮,popstate 就被触发,咱们须要的就是这个功能。由于 Route 渲染 UI 是根据当前 URL来作的,所以给 Route 配上监听能力也是合理的,在 popstate 触发的地方从新渲染 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 来强制作从新渲染的判断。

这样就实现了全部的 <Route> 都会监听,根据后退/前进按钮来“重匹配”、“重判断”和“重渲染”。

到如今,咱们一直尚未实现的是 matchPath 函数。这个函数在咱们的 router 中是特别关键的,由于它是判断当前 URL 是否匹配上了 <Route> 组件的关键点。matchPath 值得注意的一点是必定要把 <Route>exact 考虑清楚。若是你对 exact 还不了解,看下下面这句话,给出了规范文档中的解释:

只有当所给路径精确匹配上 location.pathname 时才返回 true。

接下来就来具体实现 matchPath 函数。若是你回头看一下上面 Route 组件的代码,你能够看到 matchPath 函数是这样的:

const match = matchPath(location.pathname, { path, exact })复制代码

这里的 match 要么是对象,要么是 null,这得取决因而否匹配上 path。根据这个声明,咱们来写 matchPath 代码:

const matchPatch = (pathname, options) => {
  const { exact = false, path } = options
}复制代码

这里使用 ES6 语法。上面的意思是,建立一个叫作 exact 的变量,使其等于 options.exact,而且若是非 null 的话则设置其为 false。一样建立一个叫作 path 的变量,使其等于 options.path。

接下来就添加判断是否匹配。React Router 使用 pathToRegex 来实现,只须要写简单的正则匹配就能够了。

const matchPatch = (pathname, options) => {
  const { exact = false, path } = options
  if (!path) {
    return {
      path: null,
      url: pathname,
      isExact: true,
    }
  }
  const match = new RegExp(`^${path}`).exec(pathname)
}复制代码

若是匹配上了,那么返回一个包含有全部匹配串的数组,不然返回 null。

下面是咱们示例 app 的路由 '/topics/components' 的一些匹配项。

注意:每一个 <Route> 都在本身的渲染方法里调用 matchPath,因此要为每一个 <Route> 配一个 match

如今咱们要作的是添加判断是否有匹配的代码:

const matchPatch = (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,
  }
}复制代码

提示一下以前有讲过的,对于用户来说,有两种方式更新 URL:经过后退/前进按钮和经过点击 a 标签。对于后退/前进点击来讲,使用 popstate 事件给 Route 添加监听就能够,如今来看一下如何经过 Link 解决 a 标签问题。

Link 的 API 以下:

<Link to='/some-path' replace={false} />复制代码

这里 to 是一个 string 类型,指的是要连接到的地址。replace 是一个布尔值,若是是 true,那么点击连接将替换当前的实体到历史堆栈,而不是添加一个新的进去。

添加这些 propTypes 到 Link 组件就获得:

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

咱们知道在 Link 组件中的渲染函数须要返回一个 a 标签,可是咱们不想每次变路由都进行一次全页面刷新,因此经过增长一个 onClick 处理程序来劫持 a 标签。

class Link extends Component {
  static propTypes = {
    to: PropTypes.string.isRequired,
    replace: PropTypes.bool,
  }
  handleClick = (event) => {
    const { replace, to } = this.props
    event.preventDefault()
    // 这里是路由
  }
  render() {
    const { to, children} = this.props
    return (
      <a href={to} onClick={this.handleClick}> {children} </a>
    )
  }
}复制代码

ok,代码写到如今,就差更改当前 URL 了。在 React Router 是使用 History 工程里面的 pushreplace 方法。为了不增长新依赖,这里我使用 HTML5 的 pushStatereplaceState

本文中咱们为了防止引入额外的依赖,一直也没采用 History 库。可是它对真实的 React Router 倒是相当重要的,由于它对不一样的 session 管理和不一样的浏览器环境进行了规范化处理。

pushStatereplaceState 都接收三个参数。第一个参数是一个与历史实体相关联的对象,咱们不须要,因此设置成一个空对象。第二个参数是标题,咱们也不须要,因此也设置成空。第三个是咱们须要使用的,指的是:相关 URL。

const historyPush = (path) => {
  history.pushState({}, null, path)
}
const historyReplace = (path) => {
  history.replaceState({}, null, path)
}复制代码

Link 组件内部,会调用 historyPush 或者 historyReplace,依赖于前面提到的 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 却没有刷新,这是为何呢?这是由于,尽管你经过 historyReplace 或者 historyPush 改变了地址,可是 <Route> 并无意识到已经改变了,也不知道应该重匹配和重渲染。为了解决这个问题,须要跟踪每一条 <Route> 而且当路由发生改变的时候调用 forceUpdate

React Router 经过设置状态、上下文和历史信息的组合来解决这个问题。监听路由组件的内部代码。

为了使路由简单,咱们经过把全部路由对象放到一个数组里的方式来实现 <Route> 跟踪。每当发生地址改变的时候,就遍历一遍数组,调用相应对象的 forceUpdate 函数。

let instances = []
const register = (comp) => instances.push(comp)
const unregister = (comp) => instances.splice(instances.indexOf(comp), 1)复制代码

注意这里建立了两个函数。当 <Route> “装配”上,就调用 register;当“解装配”,就调用 unregister。而后只要调用 historyPush 或者 historyReplace(实际上用户每次点击 <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)
  }
...
}复制代码

再更新 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> 都会接收到消息,而且进行重匹配和重渲染。

这就完成了全部的路由代码了,而且实例 app 用这些代码能够完美运行!

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
  }
}复制代码

注意这个组件并不渲染任何 UI,它只用来作路由定向使用。

我但愿这篇文章对你在认识 React Router 上有所启发。我总跟个人朋友们讲,React 会使你成为一个好的 JavaScript 程序员,而 React Router 会使你成为一个好的 React 程序员。由于一切皆为组件,你懂 React,你就懂 React Router。


我最近正在写一本《React.js 小书》,对 React.js 感兴趣的童鞋,欢迎指点

相关文章
相关标签/搜索