react-router

325 阅读11分钟

静态路由和动态路由

react-router v4是一个非常大的版本改动,具体体现在从“静态路由”到“动态路由”的转变上。一般将“静态路由”看作一种配置,当启动react项目时,会先生成好一个路由表,发生页面跳转时,react会根据地址到路由表中找到对应的处理页面或处理方法。而动态路由不是作为一个项目运行的配置文件存储在外部,它在项目render的时候才开始定义,router的作者认为route应当和其它普通组件一样,它的作用不是提供路由配置,而是一个普通的UI组件。而这也符合react的开发思想——一切皆组件。 由于我自己对之前版本的路由了解不多,这里就不做比较了,有兴趣的小伙伴可以自己去了解一下。这里引一段router作者为什么要做这样大的改动的解释:

To be candid, we were pretty frustrated with the direction we’d taken React Router by v2. We (Michael and Ryan) felt limited by the API, recognized we were reimplementing parts of React (lifecycles, and more),
and it just didn’t match the mental model React has given us for composing UI.
We ended up with API that wasn’t “outside” of React, an API that composed, or naturally fell into place, 
with the rest of React.
坦率地说,我们对于之前版本的Route感到十分沮丧,我和我的小伙伴意识到我们在重新实现react的部分功能,
比如生命周期和其它更多的,但是这一点都不符合react的模型设计(UI组件)。
我们真正想要开发出的不是脱离了react的API ,而是一个本身就属于react一部分的API.这才是我们想要的route(英语功底太差,大家将就着看吧)
——引自react-router的作者

在Web前端开发中,我们经常会需要处理页面路由问题。习惯上,路由信息会在一个地方集中配置好,我们可以称之为“静态路由”,或者叫“中心化式路由”。以react-router v3版本为例,代码类似下面这样:

import { Router, Route, IndexRoute, browserHistory } from 'react-router'

const App = () => (
  <Router history={browserHistory}>
    <Route path="/" component={RootPage}>
      <IndexRoute component={HomePage} />
      <Route path="/users" component={UsersPage} />
    </Route>
  </Router>
)

render(<App />, document.getElementById('app'))


可以看到,在程序的顶层组件上配置好了所有路由信息,并通过嵌套关系体现不同的层次。但是,react-router v4版本进行了革命性的改动,使之更加符合React的“组件化”思想,我们可以称之为“动态路由”,或者借用区块链中的术语,称之为“去中心化路由”。用v4版本改写后的代码类似于下面这样:

import { BrowserRouter, Route } from 'react-router-dom'

const App = () => (
  <BrowserRouter>
    <RootPage />
  </BrowserRouter>
)

const RootPage = () => (
  <div>
    <Route path="/" exact component={HomePage} />
    <Route path="/users" component={UsersPage} />
  </div>
)

render(<App />, document.getElementById('app'))


可以发现,路由的配置不再是全部位于顶层组件中了,而是分散在不同的组件中,通过组件的嵌套关系来实现路由的层次。另外,和静态路由事先定义好所有页面不同,动态路由可以在渲染时根据路径匹配结果,动态决定渲染哪些组件,这样就可以充分实现页面的复用,减少重复渲染。

安装

正如我前面所说,对于web应用,我们只需要安装react-router-dom:

不过在node_modules下你依然会看到react-router的身影,这是react-router-dom依赖的包,另外还有一个history包,这个下面会提到。

<Router>

<Router>是实现路由最外层的容器,一般情况下我们不再需要直接使用它,而是使用在它基础之上封装的几个适用于不同环境的组件,react-router-dom的Router有四种:

一般我们很少会用到<MemoryRouter>和<StaticRouter>,在web应用中更多的是用react-router-dom扩展出来的<BrowserRouter>和<HashRouter>,这两个就是我前面提到的前端路由的两种解决办法的各自实现。

为了不被后面的一些配置弄迷糊,我们从<Router>的实现源码来看看路由到底传了些什么东西。

router.js

class Router extends React.Component {
  //检测接收的参数
  static propTypes = {
    history: PropTypes.object.isRequired, //必须传入
    children: PropTypes.node
  }

  //设置传递给子组件的属性
  getChildContext() {
    return {
      router: {
        ...this.context.router, 
        history: this.props.history, //核心对象
        route: {
          location: this.props.history.location, //history里的location对象
          match: this.state.match //当路由路径和当前路径成功匹配,一些有关的路径信息会存放在这里,嵌套路由会用到它。
        }
      }
    }
  }
    state = {
      match: this.computeMatch(this.props.history.location.pathname)
    }

  computeMatch(pathname) {
    return {
      path: '/',
      url: '/', 
      params: {}, //页面间传递参数
      isExact: pathname === '/'
    }
  }
}

这里面最重要的就是需要我们传入的history对象,我前面提到过我们一般不会直接使用<Router>组件,因为这个组件要求我们手动传入history对象,但这个对象又非常重要,而且不同的开发环境需要不同的history,所以针对这种情况react-router才衍生了两个插件react-router-dom和react-router-native(我认为这是比较重要的原因,浏览器有一个history对象,所以web应用的路由都是在此对象基础上扩展的)。 接着让我们来看一下react-router-dom用到的来自history的两个方法:

  • createBrowserHistory 适用于现代浏览器(支持h5 history API)

  • createHashHistory 适用于需要兼容老版本浏览器的情况

这两个方法就分别对应了两个组件:<BrowserRouter>和<HashRouter>,它俩返回的history对象拥有的属性是一样的,但是各自的实现不同。

//createHashHistory.js
var HashChangeEvent = 'hashchange'; //hash值改变时会触发该事件
var createHashHistory = function createHashHistory() {
  var globalHistory = window.history; //全局的history对象
  var handleHashChange = function handleHashChange() {} //hash值变化时操作的方法
}
//createBrowserHistory.js
var PopStateEvent = 'popstate'; //监听url的变化事件
var HashChangeEvent = 'hashchange'; //依然监听了hash改变的事件,但是多加了一个判断是是否需要监听hash改变,如果不需要就不绑定该事件。
var createBrowserHistory = function createBrowserHistory() {
  var globalHistory = window.history; //全局的history对象
  var handlePop = function handlePop(location) {} //出栈操作
}

//createHashHistory.js,createBrowserHistory.js导出的history对象
const history = {
    length: globalHistory.length, //globalHistory就是window.history
    action: "POP", //操作历史状态都属于出栈操作
    location: initialLocation, //最重要的!!前面的Router.js源码向子组件单独传递了这个对象,因为路由匹配会用到它。
    createHref, //生成的url地址样式,如果是hash则加一个'#'
    push, //扩展history.pushState()方法
    replace, //扩展history.replaceState()方法
    go, //history.go()方法
    goBack, //history.back()方法
    goForward, //history.forward()方法
    block,
    listen
}

我们从控制台打印一下看看这个history:

所以,我们直接用<BrowserRouter>与使用<Router>搭配createBrowserHistory()方法是一样的效果。

import {
    Router,
} from 'react-router-dom'
import createBrowserHistory from 'history/createBrowserHistory';

const history = createBrowserHistory();

const App = () => (
    <Router history={history}>
        <div>{/*其它*/}</div>
    </Router>
)

就等于:

import {
    BrowserRouter,
} from 'react-router-dom'

const App = () => (
    <BrowserRouter>
        <div>{/*其它*/}</div>
    </BrowserRouter>
)

<BrowserRouter>和<HashRouter>使用注意点

<HashRouter>生成的url路径看起来是这样的:

http://localhost:8080/#/user

我们知道hash值是不会传到服务器端的,所以使用hash记录状态不需要服务器端配合,但是<BrowserRouter>生成的路径是这样的:

http://localhost:8080/user

这时候在此目录下刷新浏览器会重新向服务器发起请求,服务器端没有配置这个路径,所以会出现can't GET /user这种错误,而解决方法就是,修改devServer的配置(前面我们配置了热替换,其实就是用webpack-dev-server搭了一个本地服务器): webpack.config.js

devServer: {
        publicPath: publicPath,
        contentBase: path.resolve(__dirname, 'build'),
        inline: true,
        hot: true,  
        historyApiFallback: true, //增加
    },

例子

import React from 'react'
import { Router, Route, Link } from 'react-router'

const App = React.createClass({
  render() {
    return (
      <div>
        <h1>App</h1>
        <ul>
          <li><Link to="/about">About</Link></li>
          <li><Link to="/inbox">Inbox</Link></li>
        </ul>
        {this.props.children}
      </div>
    )
  }
})

const About = React.createClass({
  render() {
    return <h3>About</h3>
  }
})

const Inbox = React.createClass({
  render() {
    return (
      <div>
        <h2>Inbox</h2>
        {this.props.children || "Welcome to your Inbox"}
      </div>
    )
  }
})

const Message = React.createClass({
  render() {
    return <h3>Message {this.props.params.id}</h3>
  }
})

React.render((
  <Router>
    <Route path="/" component={App}>
      <Route path="about" component={About} />
      <Route path="inbox" component={Inbox}>
        <Route path="messages/:id" component={Message} />
      </Route>
    </Route>
  </Router>
), document.body)

添加首页

想象一下当 URL 为 / 时,我们想渲染一个在 App 中的组件。不过在此时,App 的 render 中的 this.props.children 还是 undefined。这种情况我们可以使用 IndexRoute 来设置一个默认页面。

import { IndexRoute } from 'react-router'

const Dashboard = React.createClass({
  render() {
    return <div>Welcome to the app!</div>
  }
})

React.render((
  <Router>
    <Route path="/" component={App}>
      {/* 当 url 为/时渲染 Dashboard */}
      <IndexRoute component={Dashboard} />
      <Route path="about" component={About} />
      <Route path="inbox" component={Inbox}>
        <Route path="messages/:id" component={Message} />
      </Route>
    </Route>
  </Router>
), document.body)

现在,App 的 render 中的 this.props.children 将会是 <Dashboard>这个元素。这个功能类似 Apache 的DirectoryIndex 以及 nginx的 index指令,上述功能都是在当请求的 URL 匹配某个目录时,允许你制定一个类似index.html的入口文件。

让 UI 从 URL 中解耦出来

如果我们可以将 /inbox 从 /inbox/messages/:id 中去除,并且还能够让 Message 嵌套在 App -> Inbox 中渲染,那会非常赞。绝对路径可以让我们做到这一点。

React.render((
  <Router>
    <Route path="/" component={App}>
      <IndexRoute component={Dashboard} />
      <Route path="about" component={About} />
      <Route path="inbox" component={Inbox}>
        {/* 使用 /messages/:id 替换 messages/:id */}
        <Route path="/messages/:id" component={Message} />
      </Route>
    </Route>
  </Router>
), document.body)

在多层嵌套路由中使用绝对路径的能力让我们对 URL 拥有绝对的掌控。我们无需在 URL 中添加更多的层级,从而可以使用更简洁的 URL。

兼容旧的 URL

等一下,我们刚刚改变了一个 URL! 这样不好。 现在任何人访问 /inbox/messages/5 都会看到一个错误页面。:(

不要担心。我们可以使用 <Redirect> 使这个 URL 重新正常工作。

import { Redirect } from 'react-router'

React.render((
  <Router>
    <Route path="/" component={App}>
      <IndexRoute component={Dashboard} />
      <Route path="about" component={About} />
      <Route path="inbox" component={Inbox}>
        <Route path="/messages/:id" component={Message} />

        {/* 跳转 /inbox/messages/:id 到 /messages/:id */}
        <Redirect from="messages/:id" to="/messages/:id" />
      </Route>
    </Route>
  </Router>
), document.body)

现在当有人点击 /inbox/messages/5 这个链接,他们会被自动跳转到 /messages/5。 :raised_hands:

进入和离开的Hook

Route 可以定义 onEnter 和 onLeave 两个 hook ,这些hook会在页面跳转确认时触发一次。这些 hook 对于一些情况非常的有用,例如权限验证或者在路由跳转前将一些数据持久化保存起来。

在路由跳转过程中,onLeave hook 会在所有将离开的路由中触发,从最下层的子路由开始直到最外层父路由结束。然后onEnter hook会从最外层的父路由开始直到最下层子路由结束。

继续我们上面的例子,如果一个用户点击链接,从 /messages/5 跳转到 /about,下面是这些 hook 的执行顺序:

  • /messages/:id 的 onLeave

  • /inbox 的 onLeave

  • /about 的 onEnter

替换的配置方式

因为 route 一般被嵌套使用,所以使用 JSX 这种天然具有简洁嵌套型语法的结构来描述它们的关系非常方便。然而,如果你不想使用 JSX,也可以直接使用原生 route 数组对象。

上面我们讨论的路由配置可以被写成下面这个样子:

const routeConfig = [
  { path: '/',
    component: App,
    indexRoute: { component: Dashboard },
    childRoutes: [
      { path: 'about', component: About },
      { path: 'inbox',
        component: Inbox,
        childRoutes: [
          { path: '/messages/:id', component: Message },
          { path: 'messages/:id',
            onEnter: function (nextState, replaceState) {
              replaceState(null, '/messages/' + nextState.params.id)
            }
          }
        ]
      }
    ]
  }
]

React.render(<Router routes={routeConfig} />, document.body)

路由匹配原理

路由拥有三个属性来决定是否“匹配“一个 URL:

  • 嵌套关系

  • 路径语法

  • 优先级

嵌套关系

React Router 使用路由嵌套的概念来让你定义 view 的嵌套集合,当一个给定的 URL 被调用时,整个集合中(命中的部分)都会被渲染。嵌套路由被描述成一种树形结构。React Router 会深度优先遍历整个路由配置来寻找一个与给定的 URL 相匹配的路由。

路径语法

路由路径是匹配一个(或一部分)URL 的 一个字符串模式。大部分的路由路径都可以直接按照字面量理解,除了以下几个特殊的符号:

  • :paramName – 匹配一段位于 /、? 或 # 之后的 URL。 命中的部分将被作为一个参数
  • () – 在它内部的内容被认为是可选的
    • – 匹配任意字符(非贪婪的)直到命中下一个字符或者整个 URL 的末尾,并创建一个 splat 参数
<Route path="/hello/:name">         // 匹配 /hello/michael 和 /hello/ryan
<Route path="/hello(/:name)">       // 匹配 /hello, /hello/michael 和 /hello/ryan
<Route path="/files/*.*">           // 匹配 /files/hello.jpg 和 /files/path/to/hello.jpg

如果一个路由使用了相对路径,那么完整的路径将由它的所有祖先节点的路径和自身指定的相对路径拼接而成。使用绝对路径可以使路由匹配行为忽略嵌套关系。

优先级

最后,路由算法会根据定义的顺序自顶向下匹配路由。因此,当你拥有两个兄弟路由节点配置时,你必须确认前一个路由不会匹配后一个路由中的路径。例如,千万不要这么做:

<Route path="/comments" ... />
<Redirect from="/comments" ... />