Chapter 06

React Router 6:路由与导航

客户端路由原理、嵌套路由、动态参数、受保护路由与数据加载

1. 什么是客户端路由

传统的多页应用(MPA)中,每次点击链接都会向服务器发送请求,服务器返回一个全新的 HTML 页面,浏览器刷新。而单页应用(SPA)只加载一次 HTML,之后所有"页面切换"都在浏览器内部完成,不发起完整页面请求。

🌐
MPA(多页应用)每次导航都向服务器请求新 HTML,白屏刷新,但 SEO 友好,首屏快。
SPA(单页应用)只请求一次 HTML,路由切换无刷新,交互流畅,但需要客户端路由库。

浏览器 History API

客户端路由的核心是浏览器的 History API。它允许 JavaScript 在不刷新页面的情况下修改地址栏 URL。

// 浏览器原生 History API
// 推入新历史记录(地址栏变化,页面不刷新)
window.history.pushState({}, '', '/about');

// 替换当前历史记录
window.history.replaceState({}, '', '/home');

// 监听前进/后退按钮
window.addEventListener('popstate', (event) => {
  // 根据 location.pathname 渲染对应组件
  renderPage(window.location.pathname);
});
💡
React Router 对 History API 进行了封装,让你不需要手动操作它,只需声明路由规则和使用 Hook 即可。

2. 安装与基础配置

# 安装 React Router DOM(Web 端版本)
npm install react-router-dom

# TypeScript 类型声明已内置,无需额外安装 @types/

BrowserRouter vs HashRouter

特性BrowserRouterHashRouter
URL 形式/about/#/about
需要服务器配置是(需要 fallback 到 index.html)否(# 后的内容不发送到服务器)
SEO 友好度更好较差(搜索引擎忽略 hash)
推荐场景生产环境、有服务器控制权时静态文件托管、无法配置服务器时

现代用法:createBrowserRouter + RouterProvider

React Router 6.4+ 推荐使用 createBrowserRouter 定义路由配置,然后通过 RouterProvider 提供给应用。这种方式支持数据加载(loader)等高级特性。

// main.tsx — 应用入口
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import App from './App';

const router = createBrowserRouter([
  { path: '/', element: <App /> },
  { path: '/about', element: <About /> },
]);

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>
);

3. 基础路由定义

createBrowserRouter 接收一个路由配置数组,每个路由对象至少包含 path(路径)和 element(渲染的组件)。

import { createBrowserRouter } from 'react-router-dom';
import HomePage from './pages/HomePage';
import AboutPage from './pages/AboutPage';
import NotFoundPage from './pages/NotFoundPage';

const router = createBrowserRouter([
  {
    path: '/',
    element: <HomePage />,
  },
  {
    path: '/about',
    element: <AboutPage />,
  },
  {
    path: '*',          // 通配符:匹配所有未命中的路由
    element: <NotFoundPage />,
  },
]);

4. Link 和 NavLink

在 React Router 中不应使用 <a href="..."> 跳转,因为 href 会触发完整页面刷新。应使用 LinkNavLink 组件,它们内部调用 History API,实现无刷新导航。

import { Link, NavLink } from 'react-router-dom';

// Link:普通导航链接
function Header() {
  return (
    <nav>
      <Link to="/">首页</Link>
      <Link to="/about">关于</Link>

      {/* NavLink:自动为当前激活路由添加 active 类 */}
      <NavLink
        to="/dashboard"
        className={({ isActive }) =>
          isActive ? 'nav-link active' : 'nav-link'
        }
      >
        控制台
      </NavLink>
    </nav>
  );
}

useNavigate — 编程式导航

在事件处理函数、异步操作完成后或条件判断中,需要用代码触发导航,此时使用 useNavigate Hook。

import { useNavigate } from 'react-router-dom';

function LoginForm() {
  const navigate = useNavigate();

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    await login(credentials);
    navigate('/dashboard');         // 登录成功后跳转
    // navigate(-1)                  // 返回上一页
    // navigate('/login', { replace: true }) // 替换历史记录(防止后退回登录页)
  }

  return <form onSubmit={handleSubmit}>...</form>;
}

5. 动态路由参数

路径中以冒号开头的部分是动态参数,如 /user/:id 中的 :id。访问 /user/42 时,id 的值就是 "42"

// 路由定义
const router = createBrowserRouter([
  { path: '/user/:id', element: <UserPage /> },
  { path: '/post/:postId/comment/:commentId', element: <CommentPage /> },
]);
// UserPage.tsx — 使用 useParams 获取参数
import { useParams } from 'react-router-dom';

// 定义参数类型,确保类型安全
type UserParams = {
  id: string;  // URL 参数始终是字符串
};

function UserPage() {
  const { id } = useParams<UserParams>();
  const userId = Number(id);  // 需要手动转换类型

  return <div>用户 ID:{id}</div>;
}

6. 嵌套路由与 Outlet

嵌套路由允许子路由在父路由的界面内渲染。Outlet 是一个占位组件,告诉父路由"在这里渲染当前匹配的子路由"。

最常见的用途是共享布局(Layout):导航栏、侧边栏等在父路由中定义,各页面内容通过子路由渲染到 Outlet 位置。

// 路由配置:children 数组定义子路由
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,   // 父路由:共享布局
    children: [
      { index: true, element: <HomePage /> },  // index 路由(见下节)
      { path: 'about', element: <AboutPage /> },
      {
        path: 'dashboard',
        element: <DashboardLayout />,  // 可以继续嵌套
        children: [
          { path: 'stats', element: <StatsPage /> },
          { path: 'settings', element: <SettingsPage /> },
        ],
      },
    ],
  },
]);
// RootLayout.tsx — 父路由组件
import { Outlet, Link } from 'react-router-dom';

function RootLayout() {
  return (
    <div>
      <header>
        <nav>
          <Link to="/">首页</Link>
          <Link to="/about">关于</Link>
          <Link to="/dashboard/stats">统计</Link>
        </nav>
      </header>

      <main>
        <Outlet />  {/* 子路由渲染到这里 */}
      </main>

      <footer>版权所有 © 2024</footer>
    </div>
  );
}

7. Index 路由

当用户访问父路由的精确路径时(如 /dashboard),没有匹配的子路由,此时会渲染 index 路由。Index 路由用 index: true 标记,没有自己的 path

const router = createBrowserRouter([
  {
    path: '/dashboard',
    element: <DashboardLayout />,
    children: [
      {
        index: true,              // 访问 /dashboard 时渲染此组件
        element: <DashboardHome />,
      },
      {
        path: 'users',            // 访问 /dashboard/users 时渲染
        element: <UsersPage />,
      },
    ],
  },
]);

8. Loader 数据加载(React Router 6.4+)

传统做法是在组件的 useEffect 中获取数据,这会导致"先渲染空状态,再加载数据"的闪烁问题(waterfall)。React Router 6.4 引入了 loader 函数,在路由匹配时就开始获取数据,数据准备好了再渲染组件。

🚀
Loader 的优势:路由切换时并行加载数据,避免组件挂载后再请求的延迟;数据加载失败可以通过 errorElement 统一处理。
// 定义 loader 函数
import { type LoaderFunctionArgs } from 'react-router-dom';

// loader 函数接收 { params, request },返回任意数据
export async function userLoader({ params }: LoaderFunctionArgs) {
  const response = await fetch(`/api/users/${params.id}`);
  if (!response.ok) {
    throw new Response('用户不存在', { status: 404 });
  }
  return response.json();  // 返回的数据可以在组件中用 useLoaderData 获取
}

// 路由配置中添加 loader
const router = createBrowserRouter([
  {
    path: '/user/:id',
    element: <UserPage />,
    loader: userLoader,
    errorElement: <ErrorPage />,  // loader 抛出错误时渲染
  },
]);
// UserPage.tsx — 用 useLoaderData 获取 loader 返回的数据
import { useLoaderData } from 'react-router-dom';

type User = { id: number; name: string; email: string };

function UserPage() {
  // loader 返回什么,这里就拿到什么
  const user = useLoaderData() as User;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

// ErrorPage.tsx — loader 出错时渲染
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';

function ErrorPage() {
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    return <h1>{error.status} {error.statusText}</h1>;
  }
  return <h1>未知错误</h1>;
}

9. 受保护路由(Protected Routes)

受保护路由指需要登录才能访问的页面。实现方式:创建一个包裹组件,检查登录状态,未登录时重定向到登录页。

// components/ProtectedRoute.tsx
import { Navigate, Outlet } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';

function ProtectedRoute() {
  const isLoggedIn = useAuthStore((s) => s.isLoggedIn);

  if (!isLoggedIn) {
    // Navigate 组件触发重定向
    // state 传递来源路径,登录后可跳回
    return <Navigate to="/login" replace />;
  }

  return <Outlet />;  // 已登录,渲染子路由
}

// 路由配置:用 ProtectedRoute 包裹需要保护的路由
const router = createBrowserRouter([
  { path: '/login', element: <LoginPage /> },
  {
    element: <ProtectedRoute />,  // 没有 path,只是包裹
    children: [
      { path: '/dashboard', element: <Dashboard /> },
      { path: '/profile', element: <Profile /> },
      { path: '/settings', element: <Settings /> },
    ],
  },
]);

10. useSearchParams — URL 查询参数

查询参数(Query String)是 URL 中 ? 后面的部分,如 /search?q=react&page=2useSearchParams 让你像操作 useState 一样读写查询参数,且参数变化会体现在 URL 中(支持分享链接)。

import { useSearchParams } from 'react-router-dom';

function SearchPage() {
  // 类似 useState,但数据存储在 URL 中
  const [searchParams, setSearchParams] = useSearchParams();

  // 读取参数
  const query = searchParams.get('q') ?? '';
  const page = Number(searchParams.get('page') ?? '1');

  // 更新参数(地址栏自动更新)
  function handleSearch(newQuery: string) {
    setSearchParams({ q: newQuery, page: '1' });
  }

  function goToPage(pageNum: number) {
    setSearchParams((prev) => {
      prev.set('page', String(pageNum));
      return prev;
    });
  }

  return (
    <div>
      <input
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="搜索..."
      />
      <p>第 {page} 页</p>
    </div>
  );
}
最佳实践:搜索关键词、分页、筛选条件等"可分享状态"应存储在 URL 查询参数中,这样用户可以复制链接分享给他人,刷新页面状态也不丢失。