本章将讨论 React 的路由:通过路由,你可以制作出“多页面”的系统。

React 是典型的SPA(单页面应用),这里所说的“多页面”是指逻辑上的,或者说是从访客的感观上的。

react-router 应该是 React 生态中最受欢迎的路由组件了,在开始使用它之前,需要先把它安装到当前项目里:

yarn add react-router-dom

本教程使用的是 react-router 6 ,你从其它途径搜索或学习到的大概率会是旧版的 react-router 5。两个版本差异非常大,请注意甄别。

常用的路由组件

组件/hook说明
BrowserRouter使用直观的 URL 将当前 location 存储在浏览器的地址栏中,并使用浏览器的内置历史堆栈进行导航。
HashRouter将当前localtion存储在当前 URL 的Hash部分中(#),因此它永远不会发送到服务器。
Routes匹配当前 location的一组子路由
Route路由系统最重要的部分。将 URL 段耦合到组件中
Link允许用户通过单击或点击它导航到另一个页面。类似于 HTML 中的 <a> 标签
NavLinkLink 的特例,会自动加上 active 类,以便高亮显示与当前 location 匹配的链接。主要用于导航栏、面包屑等场景。
Navigate改变当前 location,主要用于跳转。
Outlet用于嵌套路由中渲染子路由对应的组件。
useNavigate以编程方式使用 Navigate 组件,即以编程方式实现跳转
useParams以编程方式获取动态路由中的参数,比如 /user/:userid 中的 userid 参数
useSearchParams以编程方式获取路由中的 URL 参数,比如 /users?page=1&sort=desc中的 pagesort参数
useRoutes它的功能和Routes 组件一样。但它使用 JavaScript 的简单对象来代替 Route 元素的定义。

BrowserRouter 还是 HashRouter

首先我想用最通俗的方式介绍 BrowserRouterHashRouter。假设 location/user/profile,那么:

  • BrowserRouter长成这样:https://axum.rs/user/profile
  • HashRouter长成这样:https://axum.rs/#/user/profile
BrowserRouterHashRouter
直观性一般
SEO有利基本不支持
浏览器支持度主流浏览器都支持基本上所有浏览器都支持
部署需要 Web 服务器做一些简单设置基本上所有环境都能部署,包括 Github Page、Cloudflare Page 等
服务端是否能接收到URL不能。它永远不会发送到服务端

本教程将使用 BrowserRouter,原因如下:

  • 哪怕是最受诟病的 Windows 平台也很少人用老掉牙的 IE 浏览器,而是使用 Chrome(及其衍生品)等主流浏览器
  • 由于后面章节会讲到SEO和服务端渲染,HashRouter 根本做不到
  • 虽然说部署时需要做一些设置,但这不是问题。使用 Nginx 部署,只要加一条指令即可。

基于以下原因,你可能不得不使用 HashRouter:

  • 面向的访客主要以老旧的操作系统为主,他们无法使用现代的、主流的浏览器(题外话:如果真是要面向 WIN XP 这种客户,还是安心用你的 <table>布局吧,本专题你都可以不用学了,本专题的 React、NextJS 和 TailwindCSS 都需要现代的主流浏览器)
  • 不关心SEO和服务端渲染
  • 需要部署到 Github Page 或 Cloudflare Page 等平台

react-router 要求每个路由组件都要包裹在一个 Router(抽象类,BrowserRouterHashRouter是它的具体实现。注意,不要和后面的 Route 组件混淆,一个字母的不同,造就了两个完全不一样的组件) 中,所以我们在入口文件中,加上 BrowserRouter

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; // 引入 BrowserRouter
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')).render(
  <React.StrictMode>
    {/* 使用 BrowserRouter 包裹主组件 */}
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

案例

下面开始以案例驱动学习的方式,开始对 react-roter 进行学习。

“页面”组件路由说明
Home/首页
News/news新闻列表
NewsDetail/news-detail/:id新闻详情
About/about关于我们

在“多页面”应用中,我们通常把“页面”放在 pages 目录下(还记得之前章节说过,组件放在 components 目录下吗),所以一个典型的 react 应用的目录结构应该是:

- src
  - components
    - Foo.jsx
    - Bar
      - index.jsx
      - Foobar.jsx
  - pages
    - Page1.jsx
    - PageN
      - index.jsx
      - SubPage.jsx
  - App.jsx

开始动手之前,别忘了将 BrowserRouter 添加到入口文件,并包裹整个 App 主组件。往上翻你就能看到代码

创建页面组件

src 目录下创建 pages 目录,然后分别在其中创建页面组件:

首页: src/pages/Home.jsx

import React from 'react';

export default function Home() {
  return <div>主页</div>;
}

新闻列表: src/pages/News/index.jsx

import React from 'react';

export default function News() {
  return <div>新闻列表</div>;
}

新闻详情: src/pages/News/Detail.jsx

import React from 'react';

export default function NewsDetail() {
  return <div>新闻详情</div>;
}

关于我们: src/pages/Home.jsx

import React from 'react';

export default function About() {
  return <div>关于我们</div>;
}

本小节代码:axum-rs-react-router-1-init

创建导航栏

为了演示 NavLinkLink 的区别,本案例我们将分别用这两个组件创建两个不同版本的导航栏组件。在实际开发中完全没有这个必要——直接使用 NavLink 就好了。在后续案例中,我们也只会使用 NavLink 来创建导航栏组件。

使用 LinkNavBarUsingLink

// src/compontents/NavBar/UsingLink.jsx
import React from 'react';
import { Link } from 'react-router-dom';

export default function NavBarUsingLink() {
  return (
    <nav>
      <Link to="/">首页</Link>
      <Link to="/news">新闻</Link>
      <Link to="/about">关于我们</Link>
    </nav>
  );
}

使用 NavLinkNavBarUsingNavLink

// src/compontents/NavBar/UsingNavLink.jsx

import React from 'react';
import { NavLink } from 'react-router-dom';

export default function NavBarUsingLink() {
  return (
    <nav>
      <NavLink to="/">首页</NavLink>
      <NavLink to="/news">新闻</NavLink>
      <NavLink to="/about">关于我们</NavLink>
    </nav>
  );
}

可以看到,LinkNavLink 的 API 基本一样, 这是自然的,NavLink 只是 Link 的一种特例,也就是一个子类,它们拥有相同的某些属性,比如:

  • to:指定要跳转的目标地址。这和 HTML 的 <a href=""> 非常类似

简单 CSS

为了让导航栏看起来更直观,同时为了 NavLink ,我们在 index.css 上加了简单的 CSS:

nav {
  display: flex;
  gap: 0.25rem;
}

.active {
  color: red;
}

在主组件上添加导航栏

import React from 'react';
import NavBarUsingLink from './components/NavBar/UsingLink';

function App() {
  return (
    <div>
      <NavBarUsingLink />
      <h1>你好,axum中文网 - 来自 react 的问候</h1>
    </div>
  );
}

export default App;

很好,导航栏出现了,但也只是出现了而已。让我们换成用 NavLink 实现的试试:

import React from 'react';
// import NavBarUsingLink from './components/NavBar/UsingLink';
import NavBarUsingNavLink from './components/NavBar/UsingNavLink';

function App() {
  return (
    <div>
      <NavBarUsingNavLink />
      <h1>你好,axum中文网 - 来自 react 的问候</h1>
    </div>
  );
}

export default App;

哦吼,奇迹出现了,导航栏中的“首页”竟然变成了红色。

  • 这个红色怎么来的?此刻麻烦你把 index.css 中的 .active 的颜色改一下,比如:

    你会发现,“首页”的颜色跟着你的CSS改动而发生变化,由此可知,此时“首页”的样式应该是受.active 这个 CSS 类控制的。

  • NavLink 是一种特殊的 Link,它会自动将那个和当前URL匹配的项,应用 .active 样式

  • 如果使用原始的 Link,要实现这种效果,就要写一大堆代码了

继续点击导航栏中其它链接,它们也都会出现高亮,但页面内容却没变,我们将在下一小节解决这个问题。

本小节代码:axum-rs-react-router-1-navbar

让页面动起来:路由匹配

本小节我们将让页面动起来:点击导航栏不同的链接,页面显示对应的内容。我们只需要在 App 组件中需要显示页面内容的区域加上路由匹配就行了。就是说,只要把上例中的 <h1>你好,axum中文网 - 来自 react 的问候</h1> 这部分内容替换为对应页面的内容即可。

import React from 'react';
// import NavBarUsingLink from './components/NavBar/UsingLink';
import NavBarUsingNavLink from './components/NavBar/UsingNavLink';
import { Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import News from './pages/News';
import About from './pages/About';
import NewsDetail from './pages/News/Detail';

function App() {
  return (
    <div>
      <NavBarUsingNavLink />
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/news" element={<News />} />
        <Route path="/news-detail/:id" element={<NewsDetail />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </div>
  );
}

export default App;

我们重点关注这段代码:

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/news" element={<News />} />
  <Route path="/news-detail/:id" element={<NewsDetail />} />
  <Route path="/about" element={<About />} />
</Routes>
  • <Routes>:这里就是定义路由匹配了,它只是提供了一种“容器”,告诉 React,这个部分要匹配路由啦。具体的匹配规则由它那些 <Route> 子组件定义。
  • <Route>:定义具体的路由匹配规则:
    • path:指明要匹配的路由。你可以指定多种路由:
      • 静态路由:像 //news/about 这种,直接以字面量的形式指定的称为静态路由
      • 动态路由:像 /news-detail/:id 这种的称为动态路由,因为它包含一个参数 :id
    • element:指定路由对应的组件。第一眼看上去,你对它的写法可能有点迷惑,让我们以 <Route path="/" element={<Home />} />为例进行说明。
      • element={} 是什么意思?
        • 首先,element 是一个 props
        • 其次,因为用了 {} ,所以它需要传递一个 JS 表达式
      • <Home /> 是什么?
        • 它是 JSX
        • JSX 是什么?它是 React.createElement() 的语法糖
        • React.createElement() 是什么?它是一个函数(你也可以称其为方法)。我们在讲组件通讯时说过,JS 里万物皆对象,React.createElement() 自然可以作为表达式传递给 element 这个 props

很好,目前为止至少让页面动起来了。

你会注意到,”新闻详情“并没有在导航栏上,这是因为要进到”新闻列表“里才会出现,因为它的URL是由”新闻列表“在渲染时,根据ID动态生成的。

下一小节我们将实现”新闻列表“和”新闻详情“

本小节代码:axum-rs-react-router-1-match-routes

实现“新闻列表”和“新闻详情”

首先,我们实现“新闻列表”。我们使用模拟数据来做一个列表:

import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';

export default function News() {
  const [newsList, setNewsList] = useState([]); // 第5行

  useEffect(() => { // 第7行
    // 模拟从后端获取数据
    setNewsList([
      { id: 1, title: '新闻1' },
      { id: 2, title: '新闻2' },
      { id: 3, title: '新闻3' },
      { id: 4, title: '新闻4' }, 
      { id: 5, title: '新闻5' },
      { id: 6, title: '新闻6' },
      { id: 7, title: '新闻7' },
      { id: 8, title: '新闻8' },
      { id: 9, title: '新闻9' },
      { id: 10, title: '新闻10' },
    ]);
  }, []); // 第21 行
  return (
    <div>
      <ul>
        {newsList.map((news) => (
          <li key={news.id}>
            <Link to={`/news-detail/${news.id}`}>{news.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}
  • 第7~21行:创建了模拟数据

  • 第25~29行:遍历这个模拟数据

    {newsList.map((news) => (
      <li key={news.id}>
        <Link to={`/news-detail/${news.id}`}>{news.title}</Link>
      </li>
    ))}
    
    • <Link>to 属性:使用 JS 的字符串模板功能,将当前新闻的ID拼接成一个完整的 URL

那么,“新闻详情”如何能接收到传递过来的 :id 参数呢?答案是使用 usePramas hook:

import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
export default function NewsDetail() {
  const { id } = useParams(); // 第4行
  const [detail, setDetail] = useState('');

  useEffect(() => {
    // 模拟从后端获取数据
    setDetail(`ID为${id}的新闻详情`);
  }, []);

  return <div>{detail}</div>;
}

第4行:通过 useParams hook 获取到传递过来的 :id 参数:

  • useParams 会将动态路由里定义的所有参数打包成一个 JS 普通对象,类似这样:

    {
      id: 1,
      foo: 'bar',
    }
    
  • 通过解构,可以很方便的获取到其中某个参数:const {id} = useParams(),如果不使用解构操作,你可能需要这样:

    const ps = useParams();
    const id = ps.id;
    
  • useParams 在将动态路由的参数打包时,会将参数名作为key,所以你要使用和参数同名变量进行解构。比如:/news-detail/:newsid 这样的定义,你就要 const { newsid } = useParams()才能得到正确的结果

看上去很OK了,但这里有个严重的问题:“新闻详情”应该是和“新闻列表”一起组成了一个完整的“新闻”,它们应该使用相同的 URL 前缀。并且,在进入“新闻详情”时,导航栏中的“新闻”应该保持actvie状态,但目前并没有。下一节我们将通过嵌套路由解决这个问题。

本小节代码:axum-rs-react-router-1-news

使用嵌套路由实现完整的“新闻”

我们只需要把 App 中对应的路由匹配部分进行修改就可以了:

//...

function App() {
  return (
    <div>
      <NavBarUsingNavLink />
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/news">
          <Route path="" element={<News />} />
          <Route path="detail/:id" element={<NewsDetail />} />
        </Route>
        <Route path="/about" element={<About />} />
      </Routes>
    </div>
  );
}
// ...

重点来看:

<Route path="/news">
  <Route path="" element={<News />} />
  <Route path="detail/:id" element={<NewsDetail />} />
</Route>
  • <Route path="/news">,声明一个匹配 /news 的规则,作为前缀,它不需要指定 element
  • 然后在它里面声明了两个子路由
    • <Route path="" element={<News />} />,这里的 path 为空字符串,所以它实际匹配的就是 /news
    • <Route path="detail/:id" element={<NewsDetail />} />,它实际匹配的是 /news/detail/:id

这里和 axum 有所不同,在 axum 里,声明子路由时可以使用绝对路径的形式,axum 会正确处理,但 react-router不行:

{/* 错误示例 */}
<Route path="/news">
  <Route path="/" element={<News />} />
  <Route path="/detail/:id" element={<NewsDetail />} />
</Route>

当然,“新闻列表”里指向“新闻详情”的链接也要做出修改:

//...

export default function News() {
 //...
  return (
    <div>
      <ul>
        {newsList.map((news) => (
          <li key={news.id}>
            <Link to={`/news/detail/${news.id}`}>{news.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

Linkto 由原来的 /news-detail 改成了 /news/detail

至此,我们使用 react-router 实现了一个“多页面”应用。恭喜恭喜,撒花撒花 🎉🎉。但作为一个无私奉献的站长,我还想补充两个知识点。继续下一小节吧/

本节代码:axum-rs-react-router-1-nested

获取查询参数

虽然现在大家越来越习惯使用 Path 参数(就是我们这个案例中的“新闻详情”这种动态路由),但 URL 查询参数也还是需要的。利用 react-router 的 useSearchParams hook就能获取到传递过来的 URL 查询参数。

//...
export default function News() {
  const [newsList, setNewsList] = useState([]);
  const [searchParams, setSearchParams] = useSearchParams({
    page: 0,
    keyword: '',
  });
  const page = searchParams.get('page') || 0;
  const keyword = searchParams.get('keyword') || '';

	// ...
  return (
    <div>
      <div>
        当前页码:{page}, 搜索关键字:
        {keyword || '(无)'}
      </div>
      <hr />
      <ul>
        {newsList.map((news) => (
          <li key={news.id}>
            <Link to={`/news/detail/${news.id}`}>{news.title}</Link>
          </li>
        ))}
      </ul>
      <hr />
      <ul>
        <li>
          <Link to="/news?page=5&keyword=aaa">第5页-aaa</Link>
        </li>
        <li>
          <Link to="/news?page=10&keyword=bbb">第10页-bbb</Link>
        </li>
        <li>
          <button
            onClick={() => setSearchParams({ page: 20, keyword: 'axum中文网' })}
          >
            编程设置:第20页,axum中文网
          </button>
        </li>
      </ul>
    </div>
  );
}

本节代码:axum-rs-react-router-1-search-params

  • 使用 useSearchParams hook

     const [searchParams, setSearchParams] = useSearchParams({
        page: 0,
        keyword: '',
      });
    
    • 它的返回值是一个数组,分别是:维护所有参数的变量名,以及设置参数的函数
    • 它的参数是可选的,可以通过该参数设置URL参数的默认值
  • 获取 URL 参数

      const page = searchParams.get('page') || 0;
      const keyword = searchParams.get('keyword') || '';
    
    • 使用 get() 方法获取到指定的 URL 参数。通过 || 运算符给定默认值是一种非常好的编程技巧(此方法适用于很多 C 语言系的编程语言,包括 JavaScript、PHP、Bash等)。当然,由于我们 useSearchParams 时已经给了默认值,所以此处的 || 并不是那么有必要。
  • 使用 URL 参数

     <div>
      当前页码:{page}, 搜索关键字:
      {keyword || '(无)'}
    </div>
    
    • 将获取到的 URL 参数显示出来,没什么好讲的。
  • 设置 URL 参数有很多种方法:

    • 通过拼接 URL:<Link to="/news?page=5&keyword=aaa">第5页-aaa</Link>

    • 通过调用 setSearchParams

      • 它允许使用 Javascript 的简单对象
      • 它会自动对中文、特殊字符进行 URL 编码
       <button
         onClick={() => setSearchParams({ page: 20, keyword: 'axum中文网' })}
         >
        编程设置:第20页,axum中文网
      </button>
      
    • 还有一种是通过 method="get" 的表单进行提交,本案例没有使用这种方法,你自己可以尝试

    • 我们不建议使用拼接URL的方式:

      • 需要写成 ?key=value&key=value 的形式
      • 更重要的是,对于中文和特殊字符,需要使用 URL 编码后的内容进行拼接
      • setSearchParams 和 表单提交的方式显然更智能一些

本小节代码:axum-rs-react-router-1-search-params

使用路由表统一管理路由规则

路由表允许我们将路由的规则使用一个Javascript的简单对象来定义,方便统一管理和维护。

在与antd等UI库集成时,路由表会非常有用。

目前,我们的路由匹配规则写在 App 里的,让我们回顾一下:

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/news">
    <Route path="" element={<News />} />
    <Route path="detail/:id" element={<NewsDetail />} />
  </Route>
  <Route path="/about" element={<About />} />
</Routes>

现在,我们要把这些规则用 JavaScript 的普通对象来表示,然后通过 useRoutes 把它们加载到 App 组件中。

编写路由表

// src/routeTable.jsx 

import Home from './pages/Home';
import News from './pages/News';
import About from './pages/About';
import NewsDetail from './pages/News/Detail';

const routeTable = [
  {
    path: '/',
    element: <Home />,
  },
  {
    path: '/news',
    children: [
      {
        path: '',
        element: <News />,
      },
      {
        path: 'detail/:id',
        element: <NewsDetail />,
      },
    ],
  },
  {
    path: '/about',
    element: <About />,
  },
];

export default routeTable;

  • 整个路由表是一个数组,用于代替 <Routes>
  • 每个路由规则是一个对象,用于代替 <Route>
  • 如果是嵌套路由,则将子路由放在 children

非常直观,和之前的直接以标签形式写的几乎是一一对应。

使用路由表

要想使用路由表,必须借助 useRoutes hook:

// src/App.jsx

import React from 'react';
// import NavBarUsingLink from './components/NavBar/UsingLink';
import NavBarUsingNavLink from './components/NavBar/UsingNavLink';
import { useRoutes } from 'react-router-dom';
import routeTable from './routeTable';

function App() {
  const routes = useRoutes(routeTable);

  return (
    <div>
      <NavBarUsingNavLink />
      {routes}
    </div>
  );
}

export default App;
  • import routeTable from './routeTable'; 导入刚刚定义的路由表
  • const routes = useRoutes(routeTable);将路由表进行转换,并赋值给 routes 变量
  • 在需要匹配路由的地方直接使用该变量:{routes}

本小节代码:axum-rs-react-router-1-route-table