React 路由简介

Web 应用中,路由系统是不可或缺的一部分,尤其是单页面应用,在浏览器 URL 发生变化时,路由系统会做出一些响应,来控制组件的加载与切换,React 全家桶中也有配套的路由系统,在路由 2.0 版本时叫做 react-router,在路由 4.0 时更名为 react-router-dom,我们本次就针对较新版本的 Router 系统进行介绍。

创建项目

为了方便演示如何 Router,我们使用 create-react-app 创建一个 React 项目,并删除 src 文件夹内多余文件,创建我们需要的文件 index.js,目录结构如下:

  
    react-router
      |- public
      | |- favicon.ico
      | |- index.html
      | |- manifest.json
      |- src
      | |- pages
      | | |- Add.js
      | | |- Detail.js
      | | |- Home.js
      | | |- Index.js
      | | |- List.js
      | | |- Login.js
      | | |- Logo.js
      | | |- MenuLink.js
      | | |- Profile.js
      | | |- Protected.js
      | | |- User.js
      | |- App.js
      | |- index.css
      | |- index.js
      |- .gitignore
      |- package.json
      |- README.md
      |- yarn.lock
  

其中主组件为 App,在 index.js 中渲染,index.js 代码如下:

/* 路径:~react-router/src/index.js */
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App />, window.root);

App 组件主要用来渲染菜单导航和路由组件,我们将在下面完善代码。

HashRouter 和 BrowserRouter

React Router 中,给我们提供了一些路由相关的组件,其中最重要的就是实现路由的 HashRouterBrowserRouter,我们知道浏览器的 hash 值发生变化会阻止页面的跳转,而 HashRouter 就是利用这个特性实现的,通过监听 onhanshchange 事件在 hash 值改变的时候做出响应,BrowserRouter 则是利用 H5 的新 History APIpushState 方法构造的的历史记录集合来实现的。

通常情况下,在开发的时候使用 HashRouter 更多,而在真正上线时替换成 BrowserRouter,两种 Router 在地址栏上的表现上区别只是是否含有 #,两种 Router 的使用如下:

/* 路径:~react-router/src/App.js —— HashRouter */
import React, { Component } from 'react';
import { HashRouter } from 'react-router-dom';

export default class App extends Component {
  render() {
    return (
      <HashRouter>
      {/* 路由相关代码 */}
      </HashRouter>
    )
  }
}
/* 路径:~react-router/src/App.js —— BrowserRouter */
import React, { Component } from 'react';
import { BrowserRouter } from 'react-router-dom';

export default class App extends Component {
  render() {
    return (
      <BrowserRouter>
      {/* 路由相关代码 */}
      </BrowserRouter>
    )
  }
}

其实就是使用 React Router 中提供的这两种类型的路由组件对路由相关的 JSX 进行包裹。

Route 组件是用来定义路由跳转切换组件的区域,通过 path 属性定义匹配的路由,component 属性来定义渲染的组件,渲染后就是一个 div 标签,Link 是用来点击跳转路由的,通常用来定义导航栏内容,通过 to 属性设置匹配的路由,需要与 Routepath 一一对应,点击后可切换到对应的路由组件,渲染后为一个 a 标签。

创建路由跳转的组件

下面我们来创建三个路由对应的组件,分别为首页、用户、个人中心,对应的组件分别为 Home.jsUser.jsProfile.js

/* 路径:~react-router/src/pages/Home.js */
import React, { Component } from 'react';

export default class Home extends Component {
  render() {
    return (
      <div>主页</div>
    )
  }
}
/* 路径:~react-router/src/pages/User.js */
import React, { Component } from 'react';

export default class User extends Component {
  render() {
    return (
      <div>用户</div>
    )
  }
}
/* 路径:~react-router/src/pages/Profile.js */
import React, { Component } from 'react';

export default class Profile extends Component {
  render() {
    return (
      <div>个人中心</div>
    )
  }
}

使用 LinkRoute 配合使用如下,点击 Link 会在类名 container 的元素种加载路由路径对应的组件。

/* 路径:~react-router/src/App.js */
import React, { Component } from 'react';
import { HashRouter, Route, Link } from 'react-router-dom';
import Home from './pages/Home';
import User from './pages/User';
import Profile from './pages/Profile';

export default class App extends Component {
  render() {
    return (
      <HashRouter>
        <div>
          <ul className="nav">
            <li>
              <Link to="/home">首页</Link>
            </li>
            <li>
              <Link to="/user">用户</Link>
            </li>
            <li>
              <Link to="/profile">个人中心</Link>
            </li>
          </ul>
          <div className="container">
            <Route path="/home" component={Home} />
            <Route path="/user" component={User} />
            <Route path="/profile" component={Profile} />
          </div>
        </div>
      </HashRouter>
    )
  }
}

启动项目后上面的代码已经可以帮助我们实现页面路由的切换,但是上面的代码 LinkRoute 组件混在一起,我们其实可以将 App 拆分成两个组件,一个用来存放 Link 部分,一个用来存放 Route 部分,创建 Index 组件,将 Link 的部分抽取出去,代码修改如下:

/* 路径:~react-router/src/App.js —— 修改后 */
import React, { Component } from 'react';
import { HashRouter, Route } from 'react-router-dom';
import Home from './pages/Home';
import User from './pages/User';
import Profile from './pages/Profile';
import Index from './pages/Index';

export default class App extends Component {
  render() {
    return (
      <HashRouter>
        <Index>
          <Route path="/home" component={Home} />
          <Route path="/user" component={User} />
          <Route path="/profile" component={Profile} />
        </Index>
      </HashRouter>
    )
  }
}
/* 路径:~react-router/src/pages/Index.js */
import React, { Component } from 'react';
import { Link } from 'react-router-dom';

export default class Index extends Component {
  render() {
    return (
      <div>
        <a className="navbar-brand">管理系统</a>
      </div>
      <ul className="nav">
        <li>
          <Link to="/home">首页</Link>
        </li>
        <li>
          <Link to="/user">用户</Link>
        </li>
        <li>
          <Link to="/profile">个人中心</Link>
        </li>
      </ul>
      <div className="container">
        {this.props.children}
      </div>
    )
  }
}

经过修改之后 Index 组件专门用来维护导航组件 LinkApp 组件专门用来维护路由组件 Route,这样代码看起来就不那么混乱了。

Route 组件的 exact 属性

上面我们所定义的路由为一级路由,在路由匹配并成功加载对应组件后,如果组件又由多个组件组成,并有类似导航的操作(当然不仅限于导航)来控制其他的组件视图的切换,则需要匹配二级路由,这就出现了一个问题,我们以 /user 为例,假设添加一个新的路由 /user/add,那么 React 会由上到下依次匹配,/user/add 中包含 /user,因此会同时渲染两个组件,这不是我们希望的。

React Router 内部给我们提供了解决方案,就是给路由设置严格匹配,我们只需要让 /user 对应的 Route 组件添加 exact 属性,并将值设置为 true 即可,所以匹配 /user/add 时就不会出现 /user 对应的路由组件也被渲染的情况,当然也可以将 exact 简写到 Route 组件上省略赋值为 true 的过程。

/* 路径:~react-router/src/App.js —— 添加 exact */
import React, { Component } from 'react';
import { HashRouter, Route } from 'react-router-dom';
import Home from './pages/Home';
import User from './pages/User';
import Profile from './pages/Profile';
import Index from './pages/Index';

export default class App extends Component {
  render() {
    return (
      <HashRouter>
        <Index>
          <Route path="/home" component={Home} />
          <Route path="/user" exact component={User} />
          <Route path="/user/add" component={User} />
          <Route path="/profile" component={Profile} />
        </Index>
      </HashRouter>
    )
  }
}

Switch 组件

因为 React 的路由是由上至下依次进行匹配的,如果有两个同名路由进行匹配,会同时加载两个组件,这也是我们需要优化的,React RouterSwitch 组件就是来做这件事的,只需要将多个 Route 组件包裹起来,就可以实现只要成功匹配一个路由就不再继续匹配。

/* 路径:~react-router/src/App.js —— 添加 Switch 组件 */
import React, { Component } from 'react';
import { HashRouter, Route, Switch } from 'react-router-dom';
import Home from './pages/Home';
import User from './pages/User';
import Profile from './pages/Profile';
import Index from './pages/Index';

export default class App extends Component {
  render() {
    return (
      <HashRouter>
        <Index>
          <Switch>
            <Route path="/home" component={Home} />
            <Route path="/user" exact component={User} />
            <Route path="/user/add" component={User} />
            <Route path="/user/add" component={User} /> {/* 同名路由 */}
            <Route path="/profile" component={Profile} />
          </Switch>
        </Index>
      </HashRouter>
    )
  }
}

使用 Switch 组件优化后,启动项目就可以发现只渲染了一个 User 组件。

Redirect 组件

React 开发中经常遇到路径输入错误的情况,通常情况有两种处理方式,第一种是跳转到一个 404 页面,第二种方式是将页面路由重定向到主页,而 React Router 提供的 Redirect 组件就是帮助我们在所有路由都匹配失败时重定向的,使用时通常放在最后一个 Route 组件的下面用来 “兜底”,使用 to 属性来定义重定向的路由。

/* 路径:~react-router/src/App.js —— 添加 Redirect 组件 */
import React, { Component } from 'react';
import { HashRouter, Route, Switch, Redirect } from 'react-router-dom';
import Home from './pages/Home';
import User from './pages/User';
import Profile from './pages/Profile';
import Index from './pages/Index';

export default class App extends Component {
  render() {
    return (
      <HashRouter>
        <Index>
          <Switch>
            <Route path="/home" component={Home} />
            <Route path="/user" exact={true} component={User} />
            <Route path="/profile" component={Profile} />
            <Redirect to="/home" /> {/* 无法匹配路由时重定向 */}
          </Switch>
        </Index>
      </HashRouter>
    )
  }
}

注意:Redirect 不能放在 Route 组件的上面,因为放在上面不会匹配任何的路由,而会直接重定向到设置的页面。

对于路由都没有匹配而返回 404 页面我们这里也简单说一下,但是这样的用法非常少,使用 Redirect 重定向到指定页面的方式会更多一些。

/* 路径:~react-router/src/App.js —— 匹配失败跳转 404 页面 */
import React, { Component } from 'react';
import { HashRouter, Route, Switch } from 'react-router-dom';
import Home from './pages/Home';
import User from './pages/User';
import Profile from './pages/Profile';
import Index from './pages/Index';

export default class App extends Component {
  render() {
    return (
      <HashRouter>
        <Index>
          <Switch>
            <Route path="/home" component={Home} />
            <Route path="/user" exact={true} component={User} />
            <Route path="/profile" component={Profile} />
            <Route path="/" component={Error} /> {/* Error 组件代表 404 */}
          </Switch>
        </Index>
      </HashRouter>
    )
  }
}

由于其他的路由都匹配失败,最后会和 / 匹配,所以会显示 Error 组件,这里处理 404Route 的组件也必须放在最下面来 “兜底”。

二级路由

实现二级路由

在了解 React Router 的基本使用后,我们用同样的知识点来给 User 组件写一个二级路由,User 中有一个子导航,分别对应用户列表 List 组件和添加用户 Add 组件,代码的套路与之前相同。

/* 路径:~react-router/src/pages/User.js */
import React, { Component } from 'react';
import { Link, Route, Switch } from 'react-router-dom';
import Add from './Add';
import List from './List';

export default class User extends Component {
  render() {
    return (
      <div>
        <ul className="sub-nav">
          <li>
            <Link to="/user/list">用户列表</Link>
          </li>
          <li>
            <Link to="/user/add">添加用户</Link>
          </li>
        </ul>
        <div className="sub-container">
          <Switch>
            <Route path="/user/list" component={List} />
            <Route path="/user/add" component={Add} />
          </Switch>
        </div>
      </div>
    )
  }
}

解决默认路径不匹配的问题

当通过 React Router 访问 /user 的时候,会先加载 User 组件,再加载 User 内部的组件包括子导航,但是 /user 的路径既没有和 /user/add 匹配,也没有和 /user/list 匹配,这样渲染了一个空的类名为 sub-containediv 标签,我们应该让 User 组件加载时子路由默认可以匹配一个路由组件,解决方式如下:

/* 路径:~react-router/src/pages/User.js —— Redirect 组件重定向的方式 */
import React, { Component } from 'react';
import { Link, Route, Switch, Redirect } from 'react-router-dom';
import Add from './Add';
import List from './List';

export default class User extends Component {
  render() {
    return (
      <div>
        <ul className="sub-nav">
          <li>
            <Link to="/user/list">用户列表</Link>
          </li>
          <li>
            <Link to="/user/add">添加用户</Link>
          </li>
        </ul>
        <div className="sub-container">
          <Switch>
            <Route path="/user/list" component={List} />
            <Route path="/user/add" component={Add} />
            <Redirect to="/user/list" /> {/* 重定向到 List 组件 */}
          </Switch>
        </div>
      </div>
    )
  }
}

上面的方式是使用 Redirect 组件重定向的方式实现的,但是这样访问的 /user,路径会自动改变为 /user/list,感觉上有一些奇怪,当然还有另外的解决方式。

/* 路径:~react-router/src/pages/User.js —— Route 组件严格匹配 */
import React, { Component } from 'react';
import { Link, Route, Switch } from 'react-router-dom';
import Add from './Add';
import List from './List';

export default class User extends Component {
  render() {
    return (
      <div>
        <ul className="sub-nav">
          <li>
            <Link to="/user/list">用户列表</Link>
          </li>
          <li>
            <Link to="/user/add">添加用户</Link>
          </li>
        </ul>
        <div className="sub-container">
          <Switch>
            <Route path="/user" exact component={List} />
            <Route path="/user/list" component={List} />
            <Route path="/user/add" component={Add} />
          </Switch>
        </div>
      </div>
    )
  }
}

上面的方式是当匹配到了 /user 的路由也加载默认要渲染的 List 组件实现的,但是为了防止向下继续匹配,可以添加 exact 设置严格匹配,进一步优化可以使用 Switch 组件,让路由成功匹配一次后不再向下匹配。

编程式导航

我们经常会遇到一个场景,就是在某些交互之后实现页面的自动跳转,而对于 React 搭建的单页面应用来说就是路由切换,在 React 中都最初是通过 Link 组件的点击手动实现的路由切换,那么怎么通过纯编程的方式在某些交互后自动切换路由呢,其实 React RouterRoute 组件会给内部渲染的组件传递路由相关的三个参数 historylocationmatch

history 上存储了 length 属性代表当前支持存入历史记录的数量,也同样存储了 location,用来存储路由路径的相关信息,还有用来操作路由跳转的方法 go(传入数字代表前进或后退几页)、goBack(后退)、goForward(前进)、replace(用其他路由替换当前历史)、push,其中最常用的就是 push 方法,下面会着重介绍,match 中存储了一些路由匹配的相关信息,如 url,即浏览器输入的路径,真正匹配的路径 path 属性以及是代表否严格匹配的 isExact 属性,在 match 中最重要的是 params 属性,值为对象,用来存储路由参数,这个我们放在后面来说。

下面在 Add 组件中添加一输入框和按钮,当点击按钮时将输入框的数据存入 localStorage 中,并自动将路由跳转到 /user/list,即渲染 List 组件,然后将数据取出渲染到 List 组件中,这是一个很常见的需求,添加数据然后跳到详情页的场景,下面是 Add 组件中的实现。

/* 路径:~react-router/src/pages/Add.js */
import React, { Component } from 'react';

export default class Add extends Component {
  input = React.createRef() // 非受控组件取值

  // 表单提交事件
  handleSubmit = (e) => {
    e.preventDefault(); // 取消默认的页面跳转事件

    // 先从 localStorage 获取已有数据
    const lists = JSON.parse(localStorage.getItem('lists')) || [];

    // 添加新数据
    lists.push({
      id: lists.length + 1,
      username: this.input.current.value
    });

    // 存入 localStorage
    localStorage.setItem('lists', JSON.stringify(lists));

    // 编程式导航,自动跳转到 List
    this.props.history.push('/user/list');
  }

  render() {
    return (
      <div>
        <form className="form" onSubmit={this.handleSubmit}>
          <label htmlFor="username" className="control-label">用户名</label>
          <input
            className="form-control"
            type="text"
            id="username"
            ref={this.input}
          />
          <br />
          <input type="submit" className="btn btn-success" />
        </form>
      </div>
    )
  }
}

在上面我们通过 Route 传递给渲染组件的 historypush 方法实现了路由的自动跳转,push 方法接收的参数就是将要跳转的路径字符串,List 组件代码如下:

/* 路径:~react-router/src/pages/List.js */
import React, { Component } from 'react';

export default class List extends Component {
  state = { users: [] }

  componentWillMount() {
    // 取出 localStorage 数据并更新状态
    const users = JSON.parse(localStorage.getItem('lists')) || [];
    this.setState({ users });
  }

  render() {
    return (
      <table>
        <thead>
          <tr>
            <th>用户 ID</th>
            <th>用户名</th>
          </tr>
        </thead>
        <tbody>
          {
            this.state.users.map(({ id, username }) => {
              return (
                <tr key={id}>
                  <td>{id}</td>
                  <td>{username}</td>
                </tr>
              )
            })
          }
        </tbody>
      </table>
    )
  }
}

取出 localStorage 中的数据在 List 中渲染时有两点注意,第一是取出数据和设置状态应该在 render 渲染 JSX 之前,这样在没有执行 render 时会合并状态并只渲染一次,也就是说 componentWillMount “钩子” 和 render “钩子” 的 return 语句前更新状态都是可以的,如果在 componentDidMount “钩子” 中更新会导致组件渲染两次,在 React 开发中如果获取数据的过程是同步的(localStorage 取值是同步的),不需要渲染两次。

第二点是在使用表格元素 table 渲染时,必须要含有 theadtbody,这是 React 规定的,不可以省略。

路由参数的传递

现在在我们的 List 组件表格中,点击每一行都可以跳转到学生 ID 对应的详情 Detail 组件中,由于每一个学生的 ID 不同渲染的详情也不相同,此时需要将学生 ID 作为路由参数进行传递,并在 Detail 内渲染对应的内容,由于 Detail 组件的渲染与 List 组件是同一区域,所以仍然是二级路由,我们需要在 User 组件中进行添加。

/* 路径:~react-router/src/pages/User.js —— 增加 Detail 二级路由 */
import React, { Component } from 'react';
import { Link, Route, Switch } from 'react-router-dom';
import Add from './Add';
import List from './List';
import Detail from './Detail';

export default class User extends Component {
  render() {
    return (
      <div>
        <ul className="sub-nav">
          <li>
            <Link to="/user/list">用户列表</Link>
          </li>
          <li>
            <Link to="/user/add">添加用户</Link>
          </li>
        </ul>
        <div className="sub-container">
          <Switch>
            <Route path="/user" exact component={List} />
            <Route path="/user/list" component={List} />
            <Route path="/user/add" component={Add} />
            <Route path="/user/detail/:id" component={Detail} />
          </Switch>
        </div>
      </div>
    )
  }
}

React Router 中,我们通过给路由后面添加 /:paramname 的方式添加参数,也可以通过 /:paramname/:paramname 传递多个参数(形参),由于在 List 中点击表格的的某行的单元格跳转路由,所以 List 组件修改如下:

/* 路径:~react-router/src/pages/List.js —— 点击跳转 Detail 并传递路由参数 */
import React, { Component } from 'react';
import { Link } from 'react-router-dom';

export default class List extends Component {
  state = { users: [] }

  componentWillMount() {
    // 取出 localStorage 数据并更新状态
    const users = JSON.parse(localStorage.getItem('lists')) || [];
    this.setState({ users });
  }

  render() {
    return (
      <table>
        <thead>
          <tr>
            <th>用户 ID</th>
            <th>用户名</th>
          </tr>
        </thead>
        <tbody>
            {
              this.state.users.map(({ id, username }) => {
                return (
                  <tr key={id}>
                    <td>{id}</td>
                    <td>
                      <Link to={`/user/detail/${id}`}>{username}</Link>
                    </td>
                  </tr>
                )
              })
            }
        </tbody>
      </table>
    )
  }
}

List 组件中,同样使用 Link 组件对要点击切换路由的节点进行包裹,并用 to 属性设置跳转的路由和路由参数(实参),现在点击就可以实现从 List 组件到 Detail 组件的切换,如果我们有些 List 的数据想在跳转到 Detail 组件时直接带过去,则可以使用另一种写法如下:

/* 路径:~react-router/src/pages/List.js —— 点击跳转 Detail 并传递路由参数和数据 */
import React, { Component } from 'react';
import { Link } from 'react-router-dom';

export default class List extends Component {
  state = { users: [] }

  componentWillMount() {
      // 取出 localStorage 数据并更新状态
      const users = JSON.parse(localStorage.getItem('lists')) || [];
      this.setState({ users });
  }

  render() {
    return (
      <table>
        <thead>
          <tr>
            <th>用户 ID</th>
            <th>用户名</th>
          </tr>
        </thead>
        <tbody>
          {
            this.state.users.map(({ id, username }) => {
              return (
                <tr key={id}>
                  <td>{id}</td>
                  <td>
                    <Link
                      to={{
                        pathname: `/user/detail/${id}`,
                        state: username
                      }}
                    >
                      {username}
                    </Link>
                  </td>
                </tr>
              )
            })
          }
        </tbody>
      </table>
    )
  }
}

不同的是给 to 属性传入的值从一个代表路由的字符串变成了一个对象,而把路由的字符串作为了 pathname 属性的值,state 属性则代表了路由跳转传给渲染组件的数据,还记得渲染的组件使用 Route 组件包裹的,会传入 historyloacltionmatch 三个属性,同样的,通过点击 Link 传递的路由参数和数据都可以在 props 上获取到,前者通过 location.state 或者 history.location.state 上获取到,后者可以通过 match.params 上获取到,那么 Detail 组件将传递过来的参数渲染,代码如下:

/* 路径:~react-router/src/pages/Detail.js */
import React, { Component } from 'react';

export default class Detail extends Component {
  state = { user: {} }

  componentWillMount() {
    // 有值说明是点击过来的,否则是地址栏输入的
    const data = this.props.location.state;

    // 获取路由参数
    const id = parseInt(this.props.match.params.id);

    // 如果是点击过来的直接将数据设置给 state,否则去 localStorage 取值设置给 state
    if (data) {
      this.setState({ user: { id, username: data }});
    } else {
      const users = JSON.parse(localStorage.getItem('lists')) || [];
      const user = users.find(item => item.id === id);

      this.setState({ user: { id, username: user.username }});
    }
  }

  render() {
    return (
      <div>
        <span>{this.state.user.id}</span>
        <span> ------- </span>
        <span>{this.state.user.username}</span>
      </div>
    )
  }
}

这里有两点注意点:

  • 首先通过组件 props.match.params 获取的路由参数都是字符串格式,如果原本类型为数字,使用时应转换成数字类型;
  • 其次是传递的数据,也就是组件通过 props.location.state 获取的数据,只有在通过 Link组件点击过去才会存在,在地址栏输入为 undefined,所以防止用户刷新页面导致数据丢失,应该在两种情况下处理不同的获取数据的逻辑。

withRouter 函数

在之前的编程式导航中我们使用了 Route 传递给渲染组件的 props.history.push 方法实现的,现在假设我们要对一个不是路由跳转的组件,通过点击事件来获取 historylocationmatch 属性,并使用 history 上的路由设置方法进行跳转路由,这应该如何实现呢?

其实 React Route 给我们提供了一个函数 withRouter 方法,在调用该方法时,则会返回一个新的组件,当然其实这是一个高阶组件的应用,withRouter 方法内部帮我们在传入的组件外层包装了一层 Route 组件,并传入了 historylocationmatch 属性作为参数,所以当我们使用返回的组件时可以通过 props 属性获取 historylocationmatch

下面针对我们之前的 Index 组件的内的 “管理系统” 的标签抽出一个新的组件,并将这个组件添加点击可以跳转到登录页 Login 组件的功能,Login 组件为一级路由,所以我们应该修改 App 组件,添加一个 /login 的路由,Login 和修改后的 App 组件如下:

/* 路径:~react-router/src/App.js —— 添加 Login 组件路由 */
import React, { Component } from 'react';
import { HashRouter, Route, Switch, Redirect } from 'react-router-dom';
import Home from './pages/Home';
import User from './pages/User';
import Profile from './pages/Profile';
import Index from './pages/Index';
import Login from './pages/Login';

export default class App extends Component {
  render() {
    return (
      <HashRouter>
        <Index>
          <Switch>
            <Route path="/home" component={Home }/>
            <Route path="/user" exact={true} component={User} />
            <Route path="/profile" component={Profile} />
            <Route path="/login" component={Login}/> {/* 添加登录页路由 */}
            <Redirect to="/home" /> {/* 无法匹配路由时重定向 */}
          </Switch>
        </Index>
      </HashRouter>
    )
  }
}
/* 路径:~react-router/src/pages/Login.js —— 添加登录和退出功能 */
import React, { Component } from 'react';

export default class Login extends Component {
  login = () => {
    localStorage.setItem('login', 'ok');
  }

  exit = () => {
    localStorage.removeItem('login');
  }

  render() {
    return (
      <div>
        <button onClick={this.login}>登录</button>
        <button onClick={this.exit}>退出</button>
      </div>
    )
  }
}

Login 中顺便添加了两个按钮来模拟 “登录” 和 “退出”,并给按钮添加了事件,在登录时向 localStorage 中添加 login 属性,在退出时清除这个属性,以模拟登录状态。

抽取出 Logo 后的 Index 组件也应该添加一个新的导航为 “登录”,Login 组件和修改后的 Index 组件如下:

/* 路径:~react-router/src/pages/Index.js —— 抽出 Logo 组件并添加登录导航 */
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import Logo from './Logo';

export default class Index extends Component {
  render() {
    return (
      <div>
        <Logo />
      </div>
      <ul className="nav">
        <li>
          <Link to="/home">首页</Link>
        </li>
        <li>
          <Link to="/user">用户</Link>
        </li>
        <li>
          <Link to="/profile">个人中心</Link>
        </li>
        <li>
          <Link to="/login">登录</Link>
        </li>
      </ul>
      <div className="container">
        {this.props.children}
      </div>
    )
  }
}
/* 路径:~react-router/src/pages/Logo.js */
import React, { Component } from 'react';
import { withRouter } from 'react-router-dom';

class Logo extends Component {
  change = () => {
    console.log(this.props);
    this.props.history.push('/login');
  }

  render() {
    return (
      <div className="navbar-brand" onClick={this.change}>管理系统</div>
    )
  }
}

export default withRouter(Logo);

通过 Logo 案例的代码我们可以看出,其实最后导出的并不是 Logo 组件,而是使用 withRouter 函数包装后返回的高阶组件,withRouter 方法内部帮我们搞定了 Logo 组件的 props 没有 historylocationmatch 属性的问题。

受保护的路由

以前在点击个人中心时会直接渲染 Profile 组件,在给 Login 组件添加 “登录” 和 “退出” 之后,再次点击个人中心时,应该先对登录状态进行验证,如果 localStorage 中存在 login 属性,则渲染 ProfileRoute 组件,否则重定向到登录页,如果在登录页点击登录后再重新跳回个人中心(从哪来回哪去)。

这就需要我对 App 组件路由部分的代码进行修改,使用高阶组件来添加登录验证逻辑,当然,这个高阶组件不是 React Router 提供的,需要我们自己来实现,这种做法被官方称作 “受保护的路由”。

/* 路径:~react-router/src/App.js —— 添加受保护的路由 */
import React, { Component } from 'react';
import { HashRouter, Route, Switch, Redirect } from 'react-router-dom';
import Home from './pages/Home';
import User from './pages/User';
import Profile from './pages/Profile';
import Index from './pages/Index';
import Login from './pages/Login';
import Protected from './pages/Protected';

export default class App extends Component {
  render() {
    return (
      <HashRouter>
        <Index>
          <Switch>
            <Route path="/home" component={Home} />
            <Route path="/user" exact component={User} />
            {/* 添加受保护的路由 */}
            <Protected path="/profile" component={Profile} />
            <Route path="/login" component={Login} />
            {/* 无法匹配路由时重定向 */}
            <Redirect to="/home" />
          </Switch>
        </Index>
      </HashRouter>
    )
  }
}

我们重写了 App 组件中个人中心对应的路由,将原来的 Route 组件用高阶组件 Protected 代替,也就等于是将原本传入的参数 pathcomponent 传入了高阶组件 Protected,下面来看一下高阶组件 Protected 的实现。

/* 路径:~react-router/src/pages/Protected.js —— 添加受保护的路由 */
import React, { Component } from 'react';
import { Route, Redirect } from 'react-router-dom';

export default class Protected extends Component {
  render() {
    const login = localStorage.getItem('login');
    return login ? (
      <Route {...this.props} />
    ) : (
      <Redirect to={{ pathname: '/login', state: { 'from': '/profile' }}} />
    )
  }
}

Protected 获取登录状态,存在时直接渲染了 Route 组件,并将 pathcomponent 参数传入,如果不存在则渲染 Redirect 组件重定向到登录页,传入的参数同 Link 组件的规则相同,pathname 代表重定向的路径,state 代表带过去的数据,我们这里添加了一个 from 属性,用来记录渲染登录页的来源,即个人中心。

接下来就是 Login 组件中在点击登录后验证是否存在 state,如果存在则返回存储的 from 对应的路由,即个人中心,不存在则跳回首页,Login 修改如下:

/* 路径:~react-router/src/pages/Login.js —— 完善登录功能 */
import React, { Component } from 'react';

export default class Login extends Component {
  login = () => {
    localStorage.setItem('login', 'ok');

    // 获取上一个路由传递的 state
    const prevPathDate = this.props.location.state;

    // 存在 state 则返回来源对应的页面,否则回主页
    if (prevPathDate) {
      this.props.history.push(prevPathDate.from);
    } else {
      this.props.history.push('/home');
    }
  }

  exit = () => {
    localStorage.removeItem('login');
  }

  render() {
    return (
      <div>
        <button onClick={this.login}>登录</button>
        <button onClick={this.exit}>退出</button>
      </div>
    )
  }
}

这样 “受保护的路由” 功能就实现了,其实就是在跳转路由之前起到了一个 “拦截” 的作用,经常的使用场景是权限管理,这是一个路由的应用,也是一个高阶组件的应用,这样的应用在大型复杂的 React 中会频繁使用,还是比较重要的。

在实际项目开发中,我们经常遇到导航标签被选中时被添加一个代表 “激活” 的类名,用于添加与其他导航选项不同的样式,React Router 已经给我们提供了 NavLink 组件用于实现这个功能,NavLink 组件具备 Link 组件所有的功能,唯一不同的就是 NavLink 组件在被选中时不止发生路由跳转,还会给渲染后的 a 标签添加一个名为 activeclass 属性,而我们只需要通过 css 去给类名 active 设置样式即可。

/* 路径:~react-router/src/index.css —— 激活样式 */
a.active {
  color: skyblue !important;
}

设置好激活样式以后,我们只需要在 Index 组件中引入激活样式的 css 文件并将 Link 组件替换成 NavLink 组件即可。

/* 路径:~react-router/src/pages/Index.js —— 将 Link 修改为 NavLink */
import React, { Component } from 'react';
import { NavLink } from 'react-router-dom';
import Logo from './Logo';

// 引入激活样式
import '../index.css';

export default class Index extends Component {
  render() {
    return (
      <div>
        <Logo />
      </div>
      <ul className="nav">
        <li>
          <NavLink to="/home">首页</NavLink>
        </li>
        <li>
          <NavLink to="/user">用户</NavLink>
        </li>
        <li>
          <NavLink to="/profile">个人中心</NavLink>
        </li>
        <li>
          <NavLink to="/login">登录</NavLink>
        </li>
      </ul>
      <div className="container">
        {this.props.children}
      </div>
    )
  }
}

自定义导航组件实现激活

React Router 在给我们提供的导航组件 NavLink 功能有限,只会给内部的 a 标签在选中时添加 active 类名,如果我们想实现给一个 li 标签添加 active 就需要我们自己封装一个组件来实现这个功能,其实还是通过高阶组件来实现的,首先我们定义这个高阶组件的名字为 MenuLink,将 Index 组件中的 li 标签和 NavLink 组件统一替换成 MenuLink 组件,代码如下:

/* 路径:~react-router/src/pages/Index.js —— 将 Link 修改为 NavLink */
import React, { Component } from 'react';
import { NavLink } from 'react-router-dom';
import Logo from './Logo';
import MenuLink from './MenuLink'

export default class Index extends Component {
  render() {
    return (
      <div>
        <Logo />
      </div>
      <ul className="nav">
        <MenuLink to="/home">首页</MenuLink>
        <MenuLink to="/user">用户</MenuLink>
        <MenuLink to="/profile">个人中心</MenuLink>
        <MenuLink to="/login">登录</MenuLink>
      </ul>
      <div className="container">
        {this.props.children}
      </div>
    )
  }
}

在实现 MenuLink 组件之前我们分析一下实现思路,首先我们依然模拟 NavLink 的方式给 MenuLink 传入了 to 属性,值为将要跳转的路由,所以我们应该在 MenuLink 组件中来接收这个路由,而 MenuLink 内部一定是包含 liLink 组件的,我们可以将这个 to 属性传递给 Link 组件,如果想要通过激活状态给外层的 li 标签设置状态我们需要知道是否匹配了路由,并可以通过 match 属性获得,所以在 li 的外层应该有 Route 组件配合,因为只有 Route 组件才会将 historylocationmatch 作为参数传递给其内部渲染的组件。

这就要说到 Route 组件的渲染模式,在传入 component 属性时,只有匹配组件才会渲染内部组件,我们显然是需要时时刻刻都渲染内部的 liLink,并通过点击 Link 渲染真正的路由组件,所以我们需要用到第二种渲染方式,就是通过 children 属性指定时刻需要渲染的组件,实现代码如下:

/* 路径:~react-router/src/pages/MenuLink.js */
import React, { Component } from 'react';
import { Route, Link } from 'react-router-dom';
import '../index.css';

export default class MenuLink extends Component {
  render() {
    return (
      <Route
        path={this.props.to}
        children={({ match }) => (
          <li className={match ? 'active' : ''}>
            <Link to={this.props.to}>{this.props.children}</Link>
          </li>
        )}
      />
    )
  }
}

上面代码中由于 children 组件并不需要操作状态和使用生命周期 “钩子”,所以我们直接使用了函数组件实现,因为 active 类名添加给了 li,所以我们需要在 MenuLink 组件中引入样式文件 index.css 并将修改,代码如下:

/* 路径:~react-router/src/index.css —— 激活样式修改后 */
li.active a {
  color: skyblue !important;
}

总结

本篇通过一个简单的案例使用了由 React Router 所提供的、开发中常用的功能,但美中不足的是并没有使用一些 UI 库或者 CSS 样式来美化,为了更明显的看到 React Router 各个功能使用后的效果,建议大家在实现上面代码的同时自己添加一些 CSS 样式。