跳到主要内容
预计阅读 67 分钟

路由与导航 —— 构建多页面应用

传统网站每点击一个链接,浏览器就向服务器发一次请求、刷一次页面,就像翻一本纸质书——翻到第几页就是第几页,但每翻一页都得”重新装订”。而现代单页应用(SPA)更像一台电子阅读器:所有内容早已加载进设备,切换章节只是屏幕上的即时刷新,不需要重新下载整本书。路由系统就是这台电子阅读器的”目录导航功能”——它告诉应用:“当用户点击某个链接时,应该展示哪段内容”。本章将带你从路由原理开始,一步步用 React Router v6 搭建出一个带鉴权守卫的完整路由系统。

开篇自测:你已经知道多少?

  1. 浏览器地址栏的 URL 变了,但页面没有刷新——这背后的技术原理是什么?
  2. React Router v6 中的 <Outlet /> 组件有什么作用?它和 Vue Router 的哪个概念对应?
  3. 如果用户未登录就访问 /dashboard,你会如何在路由层面将其重定向到登录页?

一、SPA 路由原理 —— 为什么单页应用需要路由

1.1 传统多页应用 vs 单页应用

在传统的多页应用(MPA)中,每个页面对应服务器上的一个 HTML 文件。用户点击导航链接,浏览器向服务器发送请求、接收新的 HTML、销毁旧页面、渲染新页面。这个过程伴随着白屏和资源重新加载。

而 React 构建的应用是典型的单页应用。整个应用只有一个 index.html,所有页面切换都是 JavaScript 在同一个页面内动态替换组件的过程。地址栏的 URL 变化了,但浏览器并没有真正发起页面跳转请求。

这就引出了一个核心问题:如果页面从来不刷新,那 URL 变化是怎么实现的? 答案藏在浏览器提供的两套机制中。

1.2 Hash 模式 —— URL 中的 #

最早的前端路由方案是利用 URL 中的 hash(锚点)。你一定见过这样的地址:

https://example.com/#/home
https://example.com/#/about

# 后面的部分叫做 hash 值。它的特殊之处在于:修改 hash 不会触发页面刷新,也不会向服务器发送请求。浏览器提供了 hashchange 事件来监听 hash 的变化:

// Hash 模式的核心原理
window.addEventListener('hashchange', (event) => {
  console.log('路由变化了!');
  console.log('旧地址:', event.oldURL);
  console.log('新地址:', event.newURL);
  console.log('当前 hash:', window.location.hash); // 例如 "#/about"
});

// 修改 hash(不会刷新页面)
window.location.hash = '#/about';

Hash 模式简单可靠,不需要服务器配合,但缺点也很明显:URL 中永远带着一个 # 号,不够优雅,而且 hash 本来的语义是页面内锚点定位,用来做路由属于”借道使用”。

1.3 History 模式 —— 现代浏览器的正统方案

HTML5 引入了 History API,提供了两个关键方法:

// 添加一条新的浏览记录(地址栏变化,但页面不刷新)
history.pushState({ page: 'about' }, '', '/about');

// 替换当前的浏览记录(地址栏变化,但页面不刷新)
history.replaceState({ page: 'home' }, '', '/home');

pushState 就像在浏览器的历史记录本上新写了一页;replaceState 则是用橡皮擦掉当前这页的内容,重新写上新内容。两者都会改变地址栏显示的 URL,但都不会触发页面刷新。

当用户点击浏览器的前进/后退按钮时,会触发 popstate 事件:

window.addEventListener('popstate', (event) => {
  console.log('用户点击了前进或后退');
  console.log('state:', event.state);
  console.log('当前路径:', window.location.pathname);
});

需要特别注意一个容易混淆的点:pushStatereplaceState 本身不会触发 popstate 事件。只有浏览器的前进、后退操作(或者调用 history.go()history.back()history.forward())才会触发。这意味着 React Router 在内部调用 pushState 后,需要自己主动通知 React 重新渲染,而不是依赖 popstate 事件。

1.4 两种模式对比

特性Hash 模式History 模式
URL 样式example.com/#/aboutexample.com/about
服务器配置不需要需要配置回退到 index.html
SEO 友好度较差较好
原理hashchange 事件History API + popstate 事件

React Router 默认推荐使用 History 模式(BrowserRouter),同时也提供了 Hash 模式(HashRouter)作为备选。在实际项目中,除非你的应用部署环境无法配置服务器(例如纯静态托管在 GitHub Pages),否则一律推荐使用 History 模式。

📌 版本说明 本章内容基于 React Router v6 讲解。React Router v7 已于 2024 年 11 月发布,v6 到 v7 的升级是非破坏性的,本章涉及的核心 API(RoutesRouteLinkuseParamsuseNavigate 等)在 v7 中完全兼容。v7 的主要变化是引入了 “framework mode”(整合了原 Remix 的 loader/action 能力),如果你启动新项目,建议直接使用 npm install react-router-dom@7

想一想 当你在 History 模式的 SPA 中直接在浏览器地址栏输入 example.com/about 并回车时,浏览器会向服务器请求 /about 这个路径。如果服务器没有做特殊配置,会返回 404。你知道 Nginx、Vite dev server 分别该如何配置来解决这个问题吗?


二、React Router v6 基础 —— 你的第一个路由应用

2.1 安装与基本结构

首先安装 React Router:

npm install react-router-dom

React Router v6 的核心概念可以用一句话总结:<Routes> 包裹一组 <Route>,每个 <Route> 定义一条路径到组件的映射规则,由最外层的 <BrowserRouter> 提供路由上下文。

来看一个最小的路由应用:

// main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);
// App.jsx
import { Routes, Route, Link } from 'react-router-dom';

function Home() {
  return <h2>首页 —— 欢迎来到我的应用</h2>;
}

function About() {
  return <h2>关于我们</h2>;
}

function NotFound() {
  return <h2>404 —— 页面不存在</h2>;
}

export default function App() {
  return (
    <div>
      {/* 导航区域 */}
      <nav style={{ display: 'flex', gap: '16px', padding: '16px', borderBottom: '1px solid #eee' }}>
        <Link to="/">首页</Link>
        <Link to="/about">关于</Link>
      </nav>

      {/* 路由出口 —— 匹配到的组件在这里渲染 */}
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
    </div>
  );
}

2.2 核心组件拆解

<BrowserRouter> 路由系统的”发动机”。它在内部创建了一个 history 对象,监听 URL 变化,并通过 React Context 将路由状态传递给所有后代组件。整个应用只需要一个。

<Routes> 路由规则的”选择器”。它会遍历所有子 <Route>,找到第一个与当前 URL 匹配的规则,然后渲染对应的组件。类似于 JavaScript 中的 switch...case 语句——匹配到一条就停下来。

<Route> 单条路由规则。path 定义匹配的路径模式,element 定义匹配后渲染的组件。path="*" 是通配符,当所有其他路由都不匹配时,它会”兜底”。

<Link> 声明式导航组件。最终会渲染成一个 <a> 标签,但点击时不会触发浏览器的默认跳转行为,而是通过 history.pushState 来更新 URL。你可以把它理解为”SPA 专用的超链接”。

<NavLink><Link> 的增强版本,它会在匹配当前路由时自动添加 active 类名(或自定义样式),非常适合用来做导航高亮:

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

function Navbar() {
  return (
    <nav>
      <NavLink
        to="/"
        className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
      >
        首页
      </NavLink>
      <NavLink
        to="/about"
        style={({ isActive }) => ({ color: isActive ? '#1890ff' : '#333' })}
      >
        关于
      </NavLink>
    </nav>
  );
}

三、动态路由与参数 —— 让 URL 携带信息

3.1 路径参数:useParams

很多页面的 URL 中会携带动态信息,比如 /user/42 中的 42 是用户 ID,/post/react-router-guide 中的 react-router-guide 是文章的 slug。这种场景就需要动态路由。

import { Routes, Route, Link, useParams } from 'react-router-dom';

// 文章列表
function PostList() {
  const posts = [
    { id: 1, title: 'React 入门指南' },
    { id: 2, title: '深入理解 Hooks' },
    { id: 3, title: '路由与导航实战' },
  ];

  return (
    <div>
      <h2>文章列表</h2>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <Link to={`/post/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

// 文章详情 —— 通过 useParams 获取动态参数
function PostDetail() {
  const { id } = useParams();  // 从 URL 中提取 :id 的值

  return (
    <div>
      <h2>文章详情</h2>
      <p>当前文章 ID:{id}</p>
      <Link to="/posts">返回列表</Link>
    </div>
  );
}

// 路由配置
function App() {
  return (
    <Routes>
      <Route path="/posts" element={<PostList />} />
      <Route path="/post/:id" element={<PostDetail />} />
    </Routes>
  );
}

:id 是路径参数的占位符,React Router 会把 URL 中对应位置的值提取出来,放入 useParams() 返回的对象中。如果路由定义是 /post/:id,用户访问 /post/42,那么 useParams() 返回 { id: '42' }。注意:参数值始终是字符串,如果你需要数字,要手动转换。

3.2 查询参数:useSearchParams

除了路径参数,URL 中还经常携带查询参数(?key=value),常见于搜索、筛选、分页等场景。React Router 提供了 useSearchParams 来处理它们:

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

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();

  // 读取查询参数
  const category = searchParams.get('category') || '全部';
  const page = Number(searchParams.get('page')) || 1;

  // 修改查询参数(不会刷新页面)
  const handleCategoryChange = (newCategory) => {
    setSearchParams({ category: newCategory, page: '1' });
  };

  const handlePageChange = (newPage) => {
    setSearchParams({ category, page: String(newPage) });
  };

  return (
    <div>
      <h2>商品列表 —— 分类:{category},第 {page} 页</h2>
      <div>
        <button onClick={() => handleCategoryChange('电子产品')}>电子产品</button>
        <button onClick={() => handleCategoryChange('图书')}>图书</button>
        <button onClick={() => handleCategoryChange('服装')}>服装</button>
      </div>
      <div>
        <button onClick={() => handlePageChange(page - 1)} disabled={page <= 1}>上一页</button>
        <span> 第 {page} 页 </span>
        <button onClick={() => handlePageChange(page + 1)}>下一页</button>
      </div>
    </div>
  );
}

useSearchParams 的用法和 useState 很像,但它操作的是 URL 中 ? 后面的查询字符串。当你调用 setSearchParams 时,地址栏会变成类似 /products?category=图书&page=2 的样子,同时组件会重新渲染。


四、嵌套路由与 Outlet —— 构建多层级页面结构

4.1 什么是嵌套路由

真实应用的页面结构通常不是扁平的。比如一个后台管理系统,整体有顶部导航栏和侧边栏,这是外层”壳”;而”壳”里面的内容区域会根据路由切换——点击”用户管理”显示用户列表,点击”订单管理”显示订单列表。这就是嵌套路由的典型场景。

你可以把嵌套路由想象成俄罗斯套娃:外层路由渲染一个”大娃”(布局组件),大娃身上有一个洞(<Outlet />),子路由匹配的组件就从这个洞里探出来。

4.2 实现嵌套路由

import { Routes, Route, Link, Outlet } from 'react-router-dom';

// 后台布局组件 —— "大娃"
function AdminLayout() {
  return (
    <div style={{ display: 'flex', minHeight: '100vh' }}>
      {/* 侧边栏 */}
      <aside style={{ width: '200px', background: '#001529', padding: '16px' }}>
        <nav>
          <Link to="/admin/dashboard" style={{ color: '#fff', display: 'block', margin: '8px 0' }}>
            仪表盘
          </Link>
          <Link to="/admin/users" style={{ color: '#fff', display: 'block', margin: '8px 0' }}>
            用户管理
          </Link>
          <Link to="/admin/orders" style={{ color: '#fff', display: 'block', margin: '8px 0' }}>
            订单管理
          </Link>
        </nav>
      </aside>

      {/* 内容区域 —— 子路由在这里渲染 */}
      <main style={{ flex: 1, padding: '24px' }}>
        <Outlet />
      </main>
    </div>
  );
}

function Dashboard() {
  return <h2>仪表盘 —— 今日数据概览</h2>;
}

function UserList() {
  return <h2>用户管理 —— 共 128 位用户</h2>;
}

function OrderList() {
  return <h2>订单管理 —— 共 56 笔待处理</h2>;
}

// 路由配置
export default function App() {
  return (
    <Routes>
      <Route path="/admin" element={<AdminLayout />}>
        {/* index 路由:当访问 /admin 时默认渲染 Dashboard */}
        <Route index element={<Dashboard />} />
        <Route path="users" element={<UserList />} />
        <Route path="orders" element={<OrderList />} />
      </Route>
    </Routes>
  );
}

4.3 关键细节

<Outlet /> 是嵌套路由的灵魂。它的作用是”给子路由留位置”。当父路由 /admin 匹配时,AdminLayout 会被渲染,而子路由 usersorders 匹配的组件则会出现在 <Outlet /> 的位置。如果没有 <Outlet />,子路由的组件将无处渲染。

<Route index> 是默认子路由。当用户访问 /admin(而不是 /admin/users/admin/orders)时,index 路由会匹配并渲染。它不需要 path 属性。

子路由的 path 是相对路径。 在父路由 path="/admin" 下,子路由写 path="users" 即可,React Router 会自动拼接成 /admin/users。不需要写成 /admin/users

想一想 如果你的应用有三层嵌套路由(比如”后台 > 设置 > 个人资料”),每一层都需要 <Outlet /> 吗?每一层的布局组件可以不同吗?


五、编程式导航 —— useNavigate

5.1 为什么需要编程式导航

<Link> 组件适合用在模板中做声明式导航,但有些场景下我们需要在 JavaScript 逻辑中触发跳转:表单提交成功后跳转到列表页、登录成功后跳转到首页、定时器到期后跳转到超时页面。这时候就需要 useNavigate

5.2 基本用法

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

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

  const handleSubmit = async (e) => {
    e.preventDefault();
    const formData = new FormData(e.target);

    try {
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify({
          username: formData.get('username'),
          password: formData.get('password'),
        }),
        headers: { 'Content-Type': 'application/json' },
      });

      if (response.ok) {
        const data = await response.json();
        localStorage.setItem('token', data.token);

        // 登录成功 —— 跳转到首页
        navigate('/dashboard');
      }
    } catch (error) {
      console.error('登录失败:', error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="username" placeholder="用户名" />
      <input name="password" type="password" placeholder="密码" />
      <button type="submit">登录</button>
    </form>
  );
}

5.3 常用技巧

const navigate = useNavigate();

// 1. 普通跳转(相当于 history.push)
navigate('/about');

// 2. 替换当前记录(相当于 history.replace,用户点后退不会回到当前页)
navigate('/login', { replace: true });

// 3. 携带 state(不会显示在 URL 中,适合传递临时数据)
navigate('/order/confirm', { state: { from: '/cart', items: [1, 2, 3] } });

// 4. 前进/后退(相当于 history.go)
navigate(-1);  // 后退一步
navigate(-2);  // 后退两步
navigate(1);   // 前进一步

replace: true 在实际开发中非常有用。典型场景:用户登录成功后跳转到首页时使用 replace,这样用户在首页点击浏览器”后退”按钮不会回到登录页(因为登录页的历史记录已经被替换掉了)。

5.4 获取来源页携带的 state

在目标页面中,可以通过 useLocation 获取导航时传递的 state:

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

function OrderConfirm() {
  const location = useLocation();
  const { from, items } = location.state || {};

  return (
    <div>
      <h2>订单确认</h2>
      <p>来自页面:{from}</p>
      <p>商品数量:{items?.length}</p>
    </div>
  );
}

六、路由守卫 —— 实现登录鉴权保护

6.1 什么是路由守卫

在实际应用中,不是所有页面都可以随意访问。后台管理页面需要登录后才能进入,管理员页面需要特定角色才能访问。路由守卫就是在路由切换时执行的”安检程序”——检查用户是否有权访问目标页面,没有权限就拦截并重定向。

React Router 没有内置路由守卫的 API(不像 Vue Router 有 beforeEach),但我们可以通过包装组件的方式优雅地实现。

6.2 实现一个通用的鉴权守卫

import { Navigate, useLocation } from 'react-router-dom';

// 模拟鉴权逻辑 —— 实际项目中可以从 Context/Store 中获取
function useAuth() {
  const token = localStorage.getItem('token');
  return { isAuthenticated: !!token };
}

// 路由守卫组件
function RequireAuth({ children }) {
  const { isAuthenticated } = useAuth();
  const location = useLocation();

  if (!isAuthenticated) {
    // 未登录 —— 重定向到登录页
    // state 中保存当前路径,登录后可以跳回来
    return <Navigate to="/login" state={{ from: location.pathname }} replace />;
  }

  // 已登录 —— 正常渲染子组件
  return children;
}

6.3 在路由配置中使用守卫

export default function App() {
  return (
    <Routes>
      {/* 公开页面 —— 不需要登录 */}
      <Route path="/login" element={<LoginPage />} />
      <Route path="/" element={<HomePage />} />

      {/* 受保护的页面 —— 需要登录 */}
      <Route
        path="/admin"
        element={
          <RequireAuth>
            <AdminLayout />
          </RequireAuth>
        }
      >
        <Route index element={<Dashboard />} />
        <Route path="users" element={<UserList />} />
        <Route path="settings" element={<Settings />} />
      </Route>
    </Routes>
  );
}

当未登录用户尝试访问 /admin/users 时,RequireAuth 会检测到没有 token,立即通过 <Navigate> 组件重定向到 /login。在重定向时,我们通过 state 保存了用户原本想访问的路径 /admin/users,这样在登录成功后可以自动跳回去:

function LoginPage() {
  const navigate = useNavigate();
  const location = useLocation();

  const handleLogin = async () => {
    // ... 登录逻辑
    localStorage.setItem('token', 'xxx');

    // 登录成功后,跳转到之前想访问的页面,如果没有就跳到首页
    const from = location.state?.from || '/';
    navigate(from, { replace: true });
  };

  return (
    <div>
      <h2>请登录</h2>
      {location.state?.from && (
        <p style={{ color: '#ff4d4f' }}>
          请先登录后再访问 {location.state.from}
        </p>
      )}
      <button onClick={handleLogin}>模拟登录</button>
    </div>
  );
}

6.4 基于角色的权限守卫

如果你的应用有多种角色(普通用户、管理员、超级管理员),可以扩展守卫组件:

function RequireRole({ children, allowedRoles }) {
  const { user, isAuthenticated } = useAuth();
  const location = useLocation();

  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location.pathname }} replace />;
  }

  if (!allowedRoles.includes(user.role)) {
    return <Navigate to="/403" replace />;
  }

  return children;
}

// 使用
<Route
  path="/admin/settings"
  element={
    <RequireRole allowedRoles={['admin', 'superadmin']}>
      <Settings />
    </RequireRole>
  }
/>

想一想 路由守卫是在客户端执行的,用户完全可以通过修改 localStorage 来伪造 token。你认为客户端路由守卫的真正作用是什么?它和服务端的接口鉴权是什么关系?


七、数据加载 —— loader 与 useLoaderData

7.1 传统方式的痛点

在 React Router v6.4 之前,我们通常在组件的 useEffect 中加载数据:

function UserProfile() {
  const { id } = useParams();
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${id}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [id]);

  if (loading) return <div>加载中...</div>;
  return <div>用户名:{user.name}</div>;
}

这种方式有一个核心问题:请求是在组件渲染之后才发起的(“render then fetch”)。用户先看到一个空壳或 loading 状态,然后数据才慢慢填充进来。如果有嵌套路由,还会出现”瀑布流请求”——父组件的数据加载完才能渲染子组件,子组件渲染后才发起自己的请求,一层套一层。

7.2 loader:在路由匹配时预加载数据

React Router v6.4 引入了 loader 机制,将数据加载提前到路由匹配阶段。使用 loader 需要换用 createBrowserRouterRouterProvider 的写法:

import {
  createBrowserRouter,
  RouterProvider,
  useLoaderData,
  Link,
} from 'react-router-dom';

// loader 函数 —— 在路由匹配时自动调用
async function userLoader({ params }) {
  const response = await fetch(`/api/users/${params.id}`);
  if (!response.ok) {
    throw new Response('用户不存在', { status: 404 });
  }
  return response.json();
}

function UserProfile() {
  // 直接拿到 loader 返回的数据,不需要 useState + useEffect
  const user = useLoaderData();

  return (
    <div>
      <h2>{user.name} 的个人主页</h2>
      <p>邮箱:{user.email}</p>
      <p>注册时间:{user.createdAt}</p>
    </div>
  );
}

// 文章列表的 loader
async function postsLoader() {
  const response = await fetch('/api/posts');
  return response.json();
}

function PostList() {
  const posts = useLoaderData();

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <Link to={`/post/${post.id}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

// 使用 createBrowserRouter 配置路由
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      { path: 'posts', element: <PostList />, loader: postsLoader },
      { path: 'user/:id', element: <UserProfile />, loader: userLoader },
    ],
  },
]);

// 挂载路由
function App() {
  return <RouterProvider router={router} />;
}

loader 的执行时机是路由匹配之后、组件渲染之前。这意味着当用户点击链接跳转时,React Router 会先调用目标路由的 loader 获取数据,等数据准备就绪后再一次性渲染组件。对于嵌套路由,父子路由的 loader 会并行执行,彻底消除了瀑布流问题。

7.3 错误处理

当 loader 中抛出错误时,React Router 会渲染最近的 errorElement

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      {
        path: 'user/:id',
        element: <UserProfile />,
        loader: userLoader,
        errorElement: <div>该用户不存在</div>,
      },
    ],
  },
]);

八、实战:搭建一个带鉴权的后台管理路由系统

综合前面学到的所有知识,我们来搭建一个完整的后台管理路由系统。它具备以下功能:登录/登出、路由鉴权守卫、嵌套路由布局、动态路由参数、404 处理。

8.1 项目路由结构设计

/login            → 登录页(公开)
/admin            → 后台首页(需登录)
/admin/users      → 用户列表(需登录)
/admin/users/:id  → 用户详情(需登录)
/admin/settings   → 系统设置(需登录 + 管理员角色)
/403              → 无权限页面
/*                → 404 页面

8.2 完整代码

// auth.jsx —— 鉴权上下文
import { createContext, useContext, useState } from 'react';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [user, setUser] = useState(() => {
    const saved = localStorage.getItem('user');
    return saved ? JSON.parse(saved) : null;
  });

  const login = (userData) => {
    setUser(userData);
    localStorage.setItem('user', JSON.stringify(userData));
  };

  const logout = () => {
    setUser(null);
    localStorage.removeItem('user');
  };

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (!context) throw new Error('useAuth 必须在 AuthProvider 内部使用');
  return context;
}
// guards.jsx —— 路由守卫
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from './auth';

export function RequireAuth({ children }) {
  const { user } = useAuth();
  const location = useLocation();

  if (!user) {
    return <Navigate to="/login" state={{ from: location.pathname }} replace />;
  }
  return children;
}

export function RequireRole({ children, roles }) {
  const { user } = useAuth();
  const location = useLocation();

  if (!user) {
    return <Navigate to="/login" state={{ from: location.pathname }} replace />;
  }
  if (!roles.includes(user.role)) {
    return <Navigate to="/403" replace />;
  }
  return children;
}
// layouts.jsx —— 布局组件
import { Outlet, Link, useNavigate } from 'react-router-dom';
import { useAuth } from './auth';

export function AdminLayout() {
  const { user, logout } = useAuth();
  const navigate = useNavigate();

  const handleLogout = () => {
    logout();
    navigate('/login', { replace: true });
  };

  return (
    <div style={{ display: 'flex', minHeight: '100vh' }}>
      <aside style={{ width: '220px', background: '#001529', color: '#fff', padding: '20px' }}>
        <h3 style={{ color: '#1890ff' }}>管理后台</h3>
        <p style={{ fontSize: '12px', color: '#999' }}>
          {user?.name}({user?.role})
        </p>
        <nav style={{ marginTop: '20px' }}>
          <Link to="/admin" style={linkStyle}>仪表盘</Link>
          <Link to="/admin/users" style={linkStyle}>用户管理</Link>
          <Link to="/admin/settings" style={linkStyle}>系统设置</Link>
        </nav>
        <button onClick={handleLogout} style={logoutBtnStyle}>退出登录</button>
      </aside>
      <main style={{ flex: 1, padding: '24px', background: '#f0f2f5' }}>
        <Outlet />
      </main>
    </div>
  );
}

const linkStyle = { display: 'block', color: '#fff', padding: '10px 0', textDecoration: 'none' };
const logoutBtnStyle = {
  marginTop: '40px', background: 'transparent', color: '#ff4d4f',
  border: '1px solid #ff4d4f', padding: '8px 16px', cursor: 'pointer', borderRadius: '4px',
};
// pages.jsx —— 页面组件
import { useParams, Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from './auth';

export function LoginPage() {
  const { login } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();
  const from = location.state?.from || '/admin';

  const handleLogin = (role) => {
    login({ name: role === 'admin' ? '张管理' : '李用户', role });
    navigate(from, { replace: true });
  };

  return (
    <div style={{ maxWidth: '400px', margin: '100px auto', textAlign: 'center' }}>
      <h2>登录</h2>
      {location.state?.from && (
        <p style={{ color: '#ff4d4f' }}>请先登录后再访问 {location.state.from}</p>
      )}
      <div style={{ display: 'flex', gap: '16px', justifyContent: 'center', marginTop: '24px' }}>
        <button onClick={() => handleLogin('admin')}>以管理员登录</button>
        <button onClick={() => handleLogin('user')}>以普通用户登录</button>
      </div>
    </div>
  );
}

export function Dashboard() {
  return (
    <div>
      <h2>仪表盘</h2>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '16px', marginTop: '16px' }}>
        <div style={cardStyle}><h3>256</h3><p>总用户数</p></div>
        <div style={cardStyle}><h3>1,024</h3><p>今日访问</p></div>
        <div style={cardStyle}><h3>98.5%</h3><p>系统正常率</p></div>
      </div>
    </div>
  );
}

export function UserList() {
  const users = [
    { id: 1, name: '张三', email: 'zhang@example.com' },
    { id: 2, name: '李四', email: 'li@example.com' },
    { id: 3, name: '王五', email: 'wang@example.com' },
  ];

  return (
    <div>
      <h2>用户管理</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse', marginTop: '16px' }}>
        <thead>
          <tr style={{ background: '#fafafa' }}>
            <th style={thStyle}>ID</th>
            <th style={thStyle}>姓名</th>
            <th style={thStyle}>邮箱</th>
            <th style={thStyle}>操作</th>
          </tr>
        </thead>
        <tbody>
          {users.map(user => (
            <tr key={user.id}>
              <td style={tdStyle}>{user.id}</td>
              <td style={tdStyle}>{user.name}</td>
              <td style={tdStyle}>{user.email}</td>
              <td style={tdStyle}>
                <Link to={`/admin/users/${user.id}`}>查看详情</Link>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

export function UserDetail() {
  const { id } = useParams();
  return (
    <div>
      <h2>用户详情 —— ID: {id}</h2>
      <p>这里展示用户 {id} 的详细信息。</p>
      <Link to="/admin/users">返回用户列表</Link>
    </div>
  );
}

export function Settings() {
  return <div><h2>系统设置</h2><p>只有管理员可以看到这个页面。</p></div>;
}

export function Forbidden() {
  return (
    <div style={{ textAlign: 'center', marginTop: '100px' }}>
      <h2>403 —— 无权访问</h2>
      <p>你没有权限访问此页面。</p>
      <Link to="/admin">返回首页</Link>
    </div>
  );
}

export function NotFound() {
  return (
    <div style={{ textAlign: 'center', marginTop: '100px' }}>
      <h2>404 —— 页面不存在</h2>
      <Link to="/">返回首页</Link>
    </div>
  );
}

const cardStyle = {
  background: '#fff', padding: '24px', borderRadius: '8px',
  textAlign: 'center', boxShadow: '0 1px 3px rgba(0,0,0,0.1)',
};
const thStyle = { padding: '12px', textAlign: 'left', borderBottom: '1px solid #eee' };
const tdStyle = { padding: '12px', borderBottom: '1px solid #eee' };
// App.jsx —— 路由总配置
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './auth';
import { RequireAuth, RequireRole } from './guards';
import { AdminLayout } from './layouts';
import {
  LoginPage, Dashboard, UserList, UserDetail,
  Settings, Forbidden, NotFound,
} from './pages';

export default function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          {/* 公开路由 */}
          <Route path="/login" element={<LoginPage />} />
          <Route path="/403" element={<Forbidden />} />

          {/* 需要登录的路由 */}
          <Route
            path="/admin"
            element={
              <RequireAuth>
                <AdminLayout />
              </RequireAuth>
            }
          >
            <Route index element={<Dashboard />} />
            <Route path="users" element={<UserList />} />
            <Route path="users/:id" element={<UserDetail />} />
            <Route
              path="settings"
              element={
                <RequireRole roles={['admin', 'superadmin']}>
                  <Settings />
                </RequireRole>
              }
            />
          </Route>

          {/* 404 兜底 */}
          <Route path="*" element={<NotFound />} />
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  );
}

8.3 核心设计要点

1. AuthProvider 包在 BrowserRouter 外层。 这样鉴权状态可以在路由系统中随处访问,而路由守卫组件也能通过 useAuth 拿到用户信息。

2. 守卫组件采用”包装”模式。 RequireAuthRequireRole 不是在路由配置之外拦截,而是把受保护的组件包裹起来。这种模式的好处是声明式、可组合——你可以在任意层级嵌套不同的守卫。

3. 重定向时保存来源路径。 这是一个重要的用户体验细节。用户被拦截到登录页后,登录成功应该自动回到他原本想访问的页面,而不是千篇一律地跳到首页。

4. 子路由 path 使用相对路径。 在父路由 path="/admin" 下面,子路由只需写 path="users",不需要写 path="/admin/users"。这样如果将来要把整个后台模块挪到 /dashboard 下面,只需修改父路由的 path 即可。


掌握度自测

回顾本章内容,试着回答以下问题:

  1. Hash 模式和 History 模式在实现原理上的核心区别是什么?History 模式在生产环境中需要做哪些额外配置?

  2. <Routes> 组件的匹配规则是什么?它和 React Router v5 中的 <Switch> 有什么不同?

  3. useParamsuseSearchParamsuseLocation 这三个 Hook 分别用于获取 URL 中的哪一部分数据?请各举一个实际使用场景。

  4. 请描述嵌套路由的工作流程:当用户访问 /admin/users/42 时,React Router 如何逐层匹配路由并决定渲染哪些组件?<Outlet /> 在其中扮演什么角色?

  5. 如果你需要实现一个”自动登录过期跳转”的功能——用户在后台操作时 token 过期,下一次路由跳转时自动弹回登录页——你会如何设计?提示:考虑在哪个环节做 token 有效性检查。

自我评估

  • 能回答 1-2 题:你已经理解了 SPA 路由的基本概念,可以搭建简单的路由应用。建议多动手写嵌套路由和守卫的例子。
  • 能回答 3-4 题:你对 React Router 的核心 API 有了扎实的掌握,可以应对大多数实际项目的路由需求。
  • 能回答全部 5 题:你已经具备了独立设计复杂路由架构的能力,可以开始研究 React Router 的源码实现原理,了解它是如何利用 Context 和 History API 来驱动视图更新的。

购买课程解锁全部内容

从组件到架构:12 章系统掌握现代 React

¥29.90