React Router 4 全方位解析

Avatar of Brad Westfall
Brad Westfall

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 价值 200 美元的免费积分!

我在 React Rally 2016 上第一次遇到了 Michael Jackson,当时我刚写完 一篇关于 React Router 3 的文章。Michael 是 React Router 的主要作者之一,与 Ryan Florence 共同编写。能遇见这样一位构建了我非常喜欢的工具的人,我感到非常兴奋,但当他说“让我向你展示我们对 React Router 4 的想法,它完全不同!”时,我感到震惊。说实话,我不理解新的方向以及为什么需要如此大的改变。鉴于路由器是应用程序架构中如此重要的组成部分,这可能会改变一些我喜欢的模式。这些变化的想法让我感到焦虑。考虑到社区的凝聚力和 React Router 在许多 React 应用程序中发挥的巨大作用,我不知道社区会如何接受这些变化。

几个月后,React Router 4 发布了,我从 Twitter 上的热议中就能看出,人们对这次彻底的重写有不同的看法。这让我回想起 React Router 第一个版本为了其渐进概念而受到的抵制。在某些方面,早期版本的 React Router 与我们对应用程序路由器“应该是什么”的传统思维模式类似,将所有路由规则放在一个地方。然而,嵌套 JSX 路由的使用并没有被所有人接受。但正如 JSX 本身克服了它的批评者(至少大部分)一样,许多人开始相信嵌套 JSX 路由是一个非常酷的想法。

因此,我学习了 React Router 4。不可否认,第一天很艰难。困难不在于 API,而是在于使用它的模式和策略。我对 React Router 3 的使用思维模式并没有很好地迁移到 v4。如果我想取得成功,我将不得不改变我对路由器和布局组件之间关系的思考方式。最终,新的模式出现了,对我来说很有意义,我对路由器的新的方向感到非常满意。React Router 4 让我能够做所有 v3 可以做到的事情,甚至更多。此外,起初我对 v4 的使用过于复杂。当我获得了新的思维模式后,我意识到这个新的方向太棒了!

我写这篇文章的用意不是要重新讲述 React Router 4 已经写得很好的文档。我将涵盖最常见的 API 概念,但重点是我已经发现有效的模式和策略。

React Router 5 现在已发布,它与 React Router 4 向后兼容。它主要有 错误修复和内部增强,使其更兼容 React 16。

以下是一些您需要熟悉才能阅读这篇文章的 JavaScript 概念

如果您喜欢直接跳到一个可工作的演示,那就看这里吧

查看演示

新的 API 和新的思维模式

早期版本的 React Router 将路由规则集中到一个地方,将它们与布局组件分开。当然,路由器可以被分割并组织成多个文件,但在概念上,路由器是一个单元,基本上是一个功能强大的配置文件。

也许最好的方法是分别在每个版本中编写一个简单的两页应用程序并进行比较,以便了解 v4 与 v3 的不同之处。示例应用程序只有两个路由,用于首页和用户的页面。

这是 v3 中的代码

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

const PrimaryLayout = props => (
  <div className="primary-layout">
    <header>
      Our React Router 3 App
    </header>
    <main>
      {props.children}
    </main>
  </div>
)

const HomePage =() => <div>Home Page</div>
const UsersPage = () => <div>Users Page</div>

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

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

以下是一些 v3 中的关键概念,在 v4 中不再适用

  • 路由器被集中到一个地方。
  • 布局和页面嵌套是由 <Route> 组件的嵌套推导出来的。
  • 布局和页面组件完全不知道它们是路由器的一部分。

React Router 4 不再提倡使用集中的路由器。相反,路由规则存在于布局和 UI 本身中。例如,这是 v4 中同一个应用程序的代码

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

const PrimaryLayout = () => (
  <div className="primary-layout">
    <header>
      Our React Router 4 App
    </header>
    <main>
      <Route path="/" exact component={HomePage} />
      <Route path="/users" component={UsersPage} />
    </main>
  </div>
)

const HomePage =() => <div>Home Page</div>
const UsersPage = () => <div>Users Page</div>

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

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

新的 API 概念:由于我们的应用程序是为浏览器设计的,我们需要用 <BrowserRouter> 包裹它,它来自 v4。还要注意我们现在从 react-router-dom 中导入(这意味着我们使用 npm install react-router-dom 而不是 react-router)。提示!它现在被称为 react-router-dom,因为还有一个 原生版本

使用 React Router v4 构建的应用程序,首先映入眼帘的是“路由器”似乎不见了。在 v3 中,路由器是一个巨大的东西,我们直接渲染到 DOM 中,它负责协调我们的应用程序。现在,除了 <BrowserRouter> 之外,我们首先扔到 DOM 中的是我们的应用程序本身。

v3 中的另一个主要部分,即 v4 示例中没有使用的是 {props.children} 来嵌套组件。这是因为在 v4 中,无论 <Route> 组件被编写在哪里,如果路由匹配,子组件都将在那里渲染。

包容性路由

在前面的示例中,您可能已经注意到 exact 属性。那么它到底是什么呢?v3 中的路由规则是“排他的”,这意味着只有一条路由会匹配。v4 中的路由默认是“包容性的”,这意味着多个 <Route> 可以同时匹配并渲染。

在前面的示例中,我们试图根据路径渲染 HomePageUsersPage。如果从示例中删除了 exact 属性,那么在浏览器中访问 `/users` 时,HomePageUsersPage 组件都会同时渲染。

要更好地理解匹配逻辑,请查看 path-to-regexp,它现在被 v4 用来确定路由是否匹配 URL。

为了演示包容性路由如何有用,让我们在标题中包含一个 UserMenu,但仅当我们位于应用程序的用户部分时才包含它

const PrimaryLayout = () => (
  <div className="primary-layout">
    <header>
      Our React Router 4 App
      <Route path="/users" component={UsersMenu} />
    </header>
    <main>
      <Route path="/" exact component={HomePage} />
      <Route path="/users" component={UsersPage} />
    </main>
  </div>
)

现在,当用户访问 `/users` 时,两个组件都会渲染。v3 中也有一些模式可以实现类似的功能,但这更困难。由于 v4 的包容性路由,现在变得轻而易举。

排他性路由

如果您需要在一个组中只有一条路由匹配,请使用 <Switch> 来启用排他性路由

const PrimaryLayout = () => (
  <div className="primary-layout">
    <PrimaryHeader />
    <main>
      <Switch>
        <Route path="/" exact component={HomePage} />
        <Route path="/users/add" component={UserAddPage} />
        <Route path="/users" component={UsersPage} />
        <Redirect to="/" />
      </Switch>
    </main>
  </div>
)

给定 <Switch> 中只有一条路由会渲染。但是,如果我们要将 HomePage 路由放在第一个,我们仍然需要在 HomePage 路由上使用 exact。否则,当访问像 `/users` 或 `/users/add` 这样的路径时,首页路由也会匹配。事实上,在使用排他性路由策略时(就像使用传统路由器一直以来的情况一样),策略性放置是关键。请注意,我们将 /users/add 的路由策略性地放在 /users 之前,以确保正确的匹配。由于路径 /users/add 会匹配 `/users` 和 `/users/add`,因此将 /users/add 放在第一个是最好的。

当然,如果我们在某些情况下使用 exact,我们可以将它们放在任何顺序,但至少我们有选择。

<Redirect> 组件在遇到时总是会执行浏览器重定向,但当它位于 <Switch> 语句中时,重定向组件只有在没有其他路由先匹配的情况下才会渲染。要了解 <Redirect> 如何在非 switch 环境中使用,请参阅下面的授权路由

“索引路由”和“未找到”

虽然 v4 中不再有 <IndexRoute>,但使用 <Route exact> 可以实现相同的效果。或者,如果没有任何路由解析,那么可以使用 <Switch><Redirect> 将其重定向到具有有效路径的默认页面(就像我在示例中使用 HomePage 一样),甚至重定向到一个未找到页面。

嵌套布局

您可能开始预测嵌套的子布局以及如何实现它们。我认为自己不会遇到这个概念的困难,但我确实遇到了。React Router v4 为我们提供了很多选择,这使得它非常强大。但是,选择意味着我们可以自由选择并非理想的策略。从表面上看,嵌套布局很简单,但根据您的选择,您可能会遇到摩擦,因为您的路由组织方式。

为了演示,让我们想象一下,我们想要扩展用户部分,以便我们有一个“浏览用户”页面和一个“用户资料”页面。我们也想要类似的产品页面。用户和产品都需要特殊的子布局,这些子布局对每个部分都是独特的。例如,每个部分可能具有不同的导航选项卡。有几种方法可以解决这个问题,一些方法很好,一些方法很差。第一种方法不是很好,但我希望向您展示,以免您陷入这种陷阱。第二种方法要好得多。

对于第一个,让我们修改我们的PrimaryLayout,以适应用户和产品的浏览和资料页面

const PrimaryLayout = props => {
  return (
    <div className="primary-layout">
      <PrimaryHeader />
      <main>
        <Switch>
          <Route path="/" exact component={HomePage} />
          <Route path="/users" exact component={BrowseUsersPage} />
          <Route path="/users/:userId" component={UserProfilePage} />
          <Route path="/products" exact component={BrowseProductsPage} />
          <Route path="/products/:productId" component={ProductProfilePage} />
          <Redirect to="/" />
        </Switch>
      </main>
    </div>
  )
}

虽然这在技术上是可行的,但仔细看看这两个用户页面,就会发现问题所在

const BrowseUsersPage = () => (
  <div className="user-sub-layout">
    <aside>
      <UserNav />
    </aside>
    <div className="primary-content">
      <BrowseUserTable />
    </div>
  </div>
)

const UserProfilePage = props => (
  <div className="user-sub-layout">
    <aside>
      <UserNav />
    </aside>
    <div className="primary-content">
      <UserProfile userId={props.match.params.userId} />
    </div>
  </div>
)

新的 API 概念:props.match被提供给由<Route>渲染的任何组件。正如您所看到的,userIdprops.match.params提供。在v4 文档中查看更多信息。或者,如果任何组件需要访问props.match,但该组件不是由<Route>直接渲染的,我们可以使用withRouter() 高阶组件。

每个用户页面不仅渲染其各自的内容,而且还必须关心子布局本身(并且子布局对每个页面都重复)。虽然这个例子很小,可能看起来微不足道,但在实际应用中,重复的代码会成为问题。更不用说,每次BrowseUsersPageUserProfilePage被渲染时,它都会创建一个新的UserNav实例,这意味着它的所有生命周期方法都将重新开始。如果导航选项卡需要初始网络流量,这会导致不必要的请求——这一切都是因为我们如何决定使用路由器。

以下是一种更好的方法

const PrimaryLayout = props => {
  return (
    <div className="primary-layout">
      <PrimaryHeader />
      <main>
        <Switch>
          <Route path="/" exact component={HomePage} />
          <Route path="/users" component={UserSubLayout} />
          <Route path="/products" component={ProductSubLayout} />
          <Redirect to="/" />
        </Switch>
      </main>
    </div>
  )
}

我们没有为用户和产品的每个页面设置四个路由,而是为每个部分的布局设置了两个路由。

请注意,上面的路由不再使用exact属性,因为我们希望/users匹配以/users开头的任何路由,对于产品也是如此。

通过这种策略,子布局的任务就变成了渲染额外的路由。以下是UserSubLayout可能的样子

const UserSubLayout = () => (
  <div className="user-sub-layout">
    <aside>
      <UserNav />
    </aside>
    <div className="primary-content">
      <Switch>
        <Route path="/users" exact component={BrowseUsersPage} />
        <Route path="/users/:userId" component={UserProfilePage} />
      </Switch>
    </div>
  </div>
)

新策略中最明显的优势是布局不会在所有用户页面之间重复。这也是双赢的,因为它不会像第一个例子那样遇到相同生命周期问题。

需要注意的是,即使我们在布局结构中嵌套很深,路由仍然需要识别其完整路径才能匹配。为了节省您的重复输入(如果您决定将“users”更改为其他内容),请使用props.match.path代替

const UserSubLayout = props => (
  <div className="user-sub-layout">
    <aside>
      <UserNav />
    </aside>
    <div className="primary-content">
      <Switch>
        <Route path={props.match.path} exact component={BrowseUsersPage} />
        <Route path={`${props.match.path}/:userId`} component={UserProfilePage} />
      </Switch>
    </div>
  </div>
)

匹配

正如我们已经看到的,props.match对于了解资料正在渲染的userId以及编写路由非常有用。match对象为我们提供了几个属性,包括match.paramsmatch.pathmatch.url更多属性

match.path vs match.url

这两个之间的区别乍一看可能不清楚。在控制台中记录它们有时会显示相同的输出,使它们的差异更加不清楚。例如,当浏览器路径为/users时,这两个控制台日志将输出相同的值

const UserSubLayout = ({ match }) => {
  console.log(match.url)   // output: "/users"
  console.log(match.path)  // output: "/users"
  return (
    <div className="user-sub-layout">
      <aside>
        <UserNav />
      </aside>
      <div className="primary-content">
        <Switch>
          <Route path={match.path} exact component={BrowseUsersPage} />
          <Route path={`${match.path}/:userId`} component={UserProfilePage} />
        </Switch>
      </div>
    </div>
  )
}

ES2015 概念:match在组件函数的参数级别被解构。这意味着我们可以键入match.path而不是props.match.path

虽然我们还看不到区别,但match.url是浏览器 URL 中的实际路径,而match.path是为路由器编写的路径。这就是它们相同的原因,至少到目前为止是这样。但是,如果我们在UserProfilePage中更深一层执行相同的控制台日志并访问浏览器中的/users/5match.url将为"/users/5",而match.path将为"/users/:userId"

选择哪个?

如果您要使用其中一个来帮助构建路由路径,我建议您选择match.path。使用match.url来构建路由路径最终会导致您不希望出现的场景。以下是我遇到的一个场景。在一个像UserProfilePage这样的组件(当用户访问/users/5时渲染)中,我渲染了如下子组件

const UserComments = ({ match }) => (
  <div>UserId: {match.params.userId}</div>
)

const UserSettings = ({ match }) => (
  <div>UserId: {match.params.userId}</div>
)

const UserProfilePage = ({ match }) => (
  <div>
    User Profile:
    <Route path={`${match.url}/comments`} component={UserComments} />
    <Route path={`${match.path}/settings`} component={UserSettings} />
  </div>
)

为了说明问题,我渲染了两个子组件,一个路由路径来自match.url,另一个来自match.path。以下是访问浏览器中的这些页面时会发生的事情

  • 访问/users/5/comments将渲染“UserId: undefined”。
  • 访问/users/5/settings将渲染“UserId: 5”。

那么为什么match.path可以帮助我们构建路径,而match.url不行呢?答案在于{${match.url}/comments}基本上与我硬编码的{'/users/5/comments'}相同。这样做意味着后续组件将无法正确填充match.params,因为路径中没有参数,只有硬编码的5

直到后来我才看到文档的这一部分,才意识到它有多重要

匹配

  • path – (string) 用于匹配的路径模式。对于构建嵌套的<Route>很有用
  • url – (string) URL 中匹配的部分。对于构建嵌套的<Link>很有用

避免匹配冲突

让我们假设我们正在制作的应用程序是一个仪表盘,因此我们希望能够通过访问/users/add/users/5/edit来添加和编辑用户。但是,在前面的示例中,users/:userId已经指向一个UserProfilePage。那么,这意味着具有users/:userId的路由现在需要指向另一个子子布局来容纳编辑和资料吗?我不这么认为。由于编辑页面和资料页面共享相同的用户子布局,因此这种策略效果很好

const UserSubLayout = ({ match }) => (
  <div className="user-sub-layout">
    <aside>
      <UserNav />
    </aside>
    <div className="primary-content">
      <Switch>
        <Route exact path={props.match.path} component={BrowseUsersPage} />
        <Route path={`${match.path}/add`} component={AddUserPage} />
        <Route path={`${match.path}/:userId/edit`} component={EditUserPage} />
        <Route path={`${match.path}/:userId`} component={UserProfilePage} />
      </Switch>
    </div>
  </div>
)

请注意,添加和编辑路由策略性地位于资料路由之前,以确保正确匹配。如果资料路径位于第一个,访问/users/add将匹配资料(因为“add”将匹配:userId)。

或者,如果我们将资料路由放在第一个,并将路径设置为${match.path}/:userId(\\d+),这将确保:userId必须是一个数字。那么访问/users/add就不会产生冲突。我在path-to-regexp的文档中了解到这个技巧。

授权路由

在应用程序中,根据用户的登录状态限制用户访问某些路由的能力非常普遍。另一个常见的做法是为未经授权的页面(如“登录”和“忘记密码”)提供与已授权页面(应用程序的主要部分)不同的“外观和感觉”。为了解决这些需求,请考虑此应用程序的主要入口点

class App extends React.Component {
  render() {
    return (
      <Provider store={store}>
        <BrowserRouter>
          <Switch>
            <Route path="/auth" component={UnauthorizedLayout} />
            <AuthorizedRoute path="/app" component={PrimaryLayout} />
          </Switch>
        </BrowserRouter>
      </Provider>
    )
  }
}

使用react-redux与 React Router v4 的工作方式与之前非常相似,只需将<BrowserRouter>包装在<Provider>中,一切就都设置好了。

这种方法有一些要点。首先,我根据应用程序的哪个部分来选择两个顶级布局。访问像/auth/login/auth/forgot-password这样的路径将使用UnauthorizedLayout——一个适合这些上下文的外观。当用户登录后,我们将确保所有路径都有一个/app前缀,该前缀使用AuthorizedRoute来确定用户是否登录。如果用户尝试访问以/app开头的页面,但他们没有登录,他们将被重定向到登录页面。

AuthorizedRoute不是 v4 的一部分。我在v4 文档的帮助下自己创建了它。v4 中一个很棒的新功能是能够创建自己的路由以用于特殊目的。不是将component属性传递给<Route>,而是传递render回调

class AuthorizedRoute extends React.Component {
  componentWillMount() {
    getLoggedUser()
  }

  render() {
    const { component: Component, pending, logged, ...rest } = this.props
    return (
      <Route {...rest} render={props => {
        if (pending) return <div>Loading...</div>
        return logged
          ? <Component {...this.props} />
          : <Redirect to="/auth/login" />
      }} />
    )
  }
}

const stateToProps = ({ loggedUserState }) => ({
  pending: loggedUserState.pending,
  logged: loggedUserState.logged
})

export default connect(stateToProps)(AuthorizedRoute)

虽然您的登录策略可能与我的不同,但我使用网络请求来getLoggedUser()并将pendinglogged插入 Redux 状态。pending只是意味着请求仍在路由中。

单击此处查看CodePen 上的完整工作身份验证示例

其他提及

React Router v4 还有很多其他很酷的功能。不过,为了总结,让我们确保提及一些小细节,以免它们让您措手不及。

<Link> vs <NavLink>

在 v4 中,有两种方法可以将锚点标签与路由器集成:<Link><NavLink>

<NavLink> 的工作方式与 <Link> 相同,但根据 <NavLink> 是否与浏览器的 URL 匹配,它提供了一些额外的样式功能。例如,在 示例应用程序 中,有一个名为 <PrimaryHeader> 的组件,看起来像这样

const PrimaryHeader = () => (
  <header className="primary-header">
    <h1>Welcome to our app!</h1>
    <nav>
      <NavLink to="/app" exact activeClassName="active">Home</NavLink>
      <NavLink to="/app/users" activeClassName="active">Users</NavLink>
      <NavLink to="/app/products" activeClassName="active">Products</NavLink>
    </nav>
  </header>
)

使用 <NavLink> 使我能够将 active 类设置为任何活动的链接。但是,还要注意,我也可以在这些链接上使用 exact。如果不用 exact,当访问 `/app/users` 时,主页链接将会处于活动状态,因为 v4 的匹配策略是包含式的。根据我的个人经验,<NavLink>exact 选项组合起来,比 v3 中的 <Link> 等效项更稳定。

URL 查询字符串

在 React Router v4 中,不再有从 URL 获取查询字符串的方法。在我看来,做出这个决定 的原因是,没有关于如何处理复杂查询字符串的标准。因此,v4 没有在模块中加入自己的看法,而是选择让开发人员自己决定如何处理查询字符串。这是一个好事情。

我个人使用的是 query-string,它是由一直很出色的 sindresorhus 开发的。

动态路由

v4 最棒的特性之一是,几乎所有内容(包括 <Route>)都只是一个 React 组件。路由不再是神奇的东西。我们可以在任何时候有条件地渲染它们。想象一下,当满足某些条件时,你的应用程序的整个部分都可供路由。当这些条件不满足时,我们可以移除路由。我们甚至可以做一些非常酷的 递归路由 东西。

React Router 4 更加简单,因为它 Just Components™