路由与导航 —— 构建多页面应用
传统网站每点击一个链接,浏览器就向服务器发一次请求、刷一次页面,就像翻一本纸质书——翻到第几页就是第几页,但每翻一页都得”重新装订”。而现代单页应用(SPA)更像一台电子阅读器:所有内容早已加载进设备,切换章节只是屏幕上的即时刷新,不需要重新下载整本书。路由系统就是这台电子阅读器的”目录导航功能”——它告诉应用:“当用户点击某个链接时,应该展示哪段内容”。本章将带你从路由原理开始,一步步用 React Router v6 搭建出一个带鉴权守卫的完整路由系统。
开篇自测:你已经知道多少?
- 浏览器地址栏的 URL 变了,但页面没有刷新——这背后的技术原理是什么?
- React Router v6 中的
<Outlet />组件有什么作用?它和 Vue Router 的哪个概念对应?- 如果用户未登录就访问
/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);
});
需要特别注意一个容易混淆的点:pushState 和 replaceState 本身不会触发 popstate 事件。只有浏览器的前进、后退操作(或者调用 history.go()、history.back()、history.forward())才会触发。这意味着 React Router 在内部调用 pushState 后,需要自己主动通知 React 重新渲染,而不是依赖 popstate 事件。
1.4 两种模式对比
| 特性 | Hash 模式 | History 模式 |
|---|---|---|
| URL 样式 | example.com/#/about | example.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(
Routes、Route、Link、useParams、useNavigate等)在 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 专用的超链接”。
2.3 Link vs NavLink
<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 会被渲染,而子路由 users、orders 匹配的组件则会出现在 <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 需要换用 createBrowserRouter 和 RouterProvider 的写法:
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. 守卫组件采用”包装”模式。 RequireAuth 和 RequireRole 不是在路由配置之外拦截,而是把受保护的组件包裹起来。这种模式的好处是声明式、可组合——你可以在任意层级嵌套不同的守卫。
3. 重定向时保存来源路径。 这是一个重要的用户体验细节。用户被拦截到登录页后,登录成功应该自动回到他原本想访问的页面,而不是千篇一律地跳到首页。
4. 子路由 path 使用相对路径。 在父路由 path="/admin" 下面,子路由只需写 path="users",不需要写 path="/admin/users"。这样如果将来要把整个后台模块挪到 /dashboard 下面,只需修改父路由的 path 即可。
掌握度自测
回顾本章内容,试着回答以下问题:
-
Hash 模式和 History 模式在实现原理上的核心区别是什么?History 模式在生产环境中需要做哪些额外配置?
-
<Routes>组件的匹配规则是什么?它和 React Router v5 中的<Switch>有什么不同? -
useParams、useSearchParams、useLocation这三个 Hook 分别用于获取 URL 中的哪一部分数据?请各举一个实际使用场景。 -
请描述嵌套路由的工作流程:当用户访问
/admin/users/42时,React Router 如何逐层匹配路由并决定渲染哪些组件?<Outlet />在其中扮演什么角色? -
如果你需要实现一个”自动登录过期跳转”的功能——用户在后台操作时 token 过期,下一次路由跳转时自动弹回登录页——你会如何设计?提示:考虑在哪个环节做 token 有效性检查。
自我评估
- 能回答 1-2 题:你已经理解了 SPA 路由的基本概念,可以搭建简单的路由应用。建议多动手写嵌套路由和守卫的例子。
- 能回答 3-4 题:你对 React Router 的核心 API 有了扎实的掌握,可以应对大多数实际项目的路由需求。
- 能回答全部 5 题:你已经具备了独立设计复杂路由架构的能力,可以开始研究 React Router 的源码实现原理,了解它是如何利用 Context 和 History API 来驱动视图更新的。
购买课程解锁全部内容
从组件到架构:12 章系统掌握现代 React
¥29.90