React Router:让单页应用有多页的体验
URL 不只是地址栏里的文字,它是用户和应用之间的契约
开篇:为什么需要路由
在前几篇里,我们已经知道:
- React 组件会根据 props 和 state 渲染 UI
- 用户操作会触发事件,事件会更新状态
- 状态变化会让 React 重新渲染
但学到这里,你会开始遇到一个新的问题:你的应用只有一个页面。
什么意思?就是不管你点什么、做什么,URL 都是 http://localhost:3000/,不会变。
这会带来几个实际问题:
- 用户想分享某个页面,但 URL 永远是同一个
- 用户想用浏览器的后退按钮,但点了没反应
- 用户想收藏某个页面,但收藏的是整个应用,不是具体内容
- 搜索引擎不知道你的应用有哪些页面
这些问题的根源是:单页应用(SPA)只有一个 HTML 文件,所有内容都是 JavaScript 动态切换的。
传统网站是"多页应用"------每个页面对应一个 HTML 文件,URL 自然就不同。
但单页应用把所有逻辑都放在一个文件里,URL 就"失联"了。
React Router 就是为了解决这个问题。它让 URL 和 UI 保持同步------URL 变了,UI 就跟着变;UI 变了,URL 也跟着变。
本文会覆盖什么
- React Router 核心概念
- 基础路由配置
- 嵌套路由
- 动态路由参数
- 编程式导航
- 404 页面处理
- 实战案例
学完之后你应该能做到什么
如果你认真跟着本文走完,应该能掌握这些能力:
- 能配置基础路由,让不同 URL 显示不同组件
- 能用嵌套路由组织复杂的页面结构
- 能用动态参数处理用户详情、文章详情这类页面
- 能用编程式导航在代码里控制跳转
- 能处理 404 页面和重定向
Part 1: 单页应用的路由问题
传统多页应用 vs 单页应用
先搞清楚两种应用模式的区别:
| 传统多页应用 | 单页应用(SPA) | |
|---|---|---|
| 页面加载 | 每次跳转都加载新 HTML | 只加载一次,之后动态切换 |
| URL 变化 | 浏览器自动处理 | 需要 JavaScript 处理 |
| 用户体验 | 有白屏,但 URL 总是对的 | 流畅,但 URL 可能"失联" |
| 典型技术 | PHP、JSP、传统前端 | React、Vue、Angular |
传统多页应用的 URL 是"真的"------每个 URL 对应一个真实的 HTML 文件。
浏览器会自动处理 URL 变化、后退按钮、收藏夹。
单页应用的 URL 是"假的"------只有一个 HTML 文件,所有内容都是 JavaScript 动态生成的。
浏览器不知道你"换了页面",它只知道你一直在同一个页面上。
URL 的重要性
URL 不只是地址栏里的文字,它是用户和应用之间的契约:
- 分享:用户想把某个页面发给朋友,需要一个唯一的 URL
- 后退:用户想回到上一个页面,需要浏览器的后退按钮能正常工作
- 收藏:用户想收藏某个页面,需要一个稳定的 URL
- SEO:搜索引擎需要 URL 来索引你的页面
如果 URL 不工作,用户会觉得你的应用"坏了"。
History API 简介
浏览器提供了 History API,让 JavaScript 可以"假装"在切换页面:
csharp
// 添加一条历史记录,URL 变成 /about
history.pushState(null, '', '/about');
// 替换当前历史记录,URL 变成 /home
history.replaceState(null, '', '/home');
// 后退
history.back();
// 前进
history.forward();
这些 API 让 JavaScript 可以控制 URL,而不需要真的加载新页面。
React Router 如何解决这些问题
React Router 封装了 History API,提供了声明式的路由配置:
javascript
// 声明:当 URL 是 /about 时,显示 About 组件
<Route path="/about" element={<About />} />
// 声明:点击这个链接,跳转到 /about
<Link to="/about">关于</Link>
你不需要手动调用 history.pushState,只需要声明"什么 URL 显示什么组件",React Router 会帮你处理剩下的事情。
Part 2: React Router 核心概念
BrowserRouter vs HashRouter
React Router 提供了两种路由器:
| BrowserRouter | HashRouter | |
|---|---|---|
| URL 格式 | /about |
/#/about |
| 原理 | 使用 History API | 使用 URL hash |
| 服务器配置 | 需要配置 | 不需要 |
| 适合场景 | 有服务器控制权 | 静态托管(如 GitHub Pages) |
BrowserRouter 是更常见的选择,URL 更干净。但它需要服务器配合------当用户直接访问 /about 时,服务器需要返回 index.html,而不是 404。
HashRouter 不需要服务器配合,因为 hash 部分不会发送给服务器。但 URL 里会有一个 #,看起来不太美观。
个人建议:如果你有服务器控制权,用 BrowserRouter;如果是纯静态托管(比如 GitHub Pages),用 HashRouter。
Route 组件:路径和组件的映射
Route 是 React Router 的核心,它声明了"什么 URL 显示什么组件":
ini
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
path:URL 路径element:要渲染的组件
Link 组件:声明式导航
Link 是 React Router 提供的导航组件,替代 HTML 的 <a> 标签:
ini
<Link to="/about">关于</Link>
<Link to="/contact">联系我们</Link>
❌/✅ 代码对比:用 a 标签 vs 用 Link
ini
// ❌ 用 a 标签:会导致整页刷新
<a href="/about">关于</a>
// ✅ 用 Link:只切换组件,不刷新页面
<Link to="/about">关于</Link>
为什么不能用 <a> 标签?因为 <a> 会让浏览器重新加载整个页面,失去单页应用的流畅体验。Link 组件会阻止默认行为,只切换组件。
Part 3: 基础路由配置
安装和设置
npm install react-router-dom
然后在应用的入口文件(通常是 main.jsx 或 App.jsx)配置路由:
javascript
import { BrowserRouter, Routes, Route } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</BrowserRouter>
);
}
Routes 和 Route 的用法
Routes 是路由容器,Route 是路由规则:
xml
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
React Router 会根据当前 URL,找到匹配的 Route,渲染对应的组件。
完整的基础示例
javascript
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
function Home() {
return <h1>首页</h1>;
}
function About() {
return <h1>关于我们</h1>;
}
function Contact() {
return <h1>联系我们</h1>;
}
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">首页</Link>
<Link to="/about">关于</Link>
<Link to="/contact">联系我们</Link>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
</BrowserRouter>
);
}
这样,点击不同的链接,URL 会变,显示的组件也会变,但页面不会刷新。
Part 4: 嵌套路由
为什么需要嵌套路由
很多页面有"布局"------比如后台管理系统,通常有:
- 一个顶部导航栏
- 一个侧边栏
- 一个主内容区
当用户切换页面时,顶部导航栏和侧边栏不变,只有主内容区变化。
如果不用嵌套路由,你可能需要在每个页面组件里都写一遍导航栏和侧边栏:
javascript
// ❌ 每个页面都重复布局
function Dashboard() {
return (
<div>
<Header />
<Sidebar />
<main>Dashboard 内容</main>
</div>
);
}
function Settings() {
return (
<div>
<Header />
<Sidebar />
<main>设置页面</main>
</div>
);
}
这显然很蠢。嵌套路由让你只写一次布局:
javascript
// ✅ 布局只写一次
function Layout() {
return (
<div>
<Header />
<Sidebar />
<Outlet /> {/* 这里会渲染子路由 */}
</div>
);
}
Outlet 组件
Outlet 是 React Router 提供的"占位符",它会渲染匹配的子路由:
javascript
import { Outlet } from 'react-router-dom';
function Layout() {
return (
<div>
<Header />
<Sidebar />
<main>
<Outlet />
</main>
</div>
);
}
布局组件模式
把布局和路由结合起来:
xml
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="contact" element={<Contact />} />
</Route>
</Routes>
注意这里:
- 父路由
path="/"用Layout作为布局 - 子路由
index表示默认页面 - 子路由的
path不需要写/,它会自动拼接
实战:Dashboard 案例
javascript
import { BrowserRouter, Routes, Route, Link, Outlet } from 'react-router-dom';
function DashboardLayout() {
return (
<div className="dashboard">
<nav className="sidebar">
<Link to="/dashboard">概览</Link>
<Link to="/dashboard/users">用户管理</Link>
<Link to="/dashboard/settings">设置</Link>
</nav>
<main className="content">
<Outlet />
</main>
</div>
);
}
function DashboardHome() {
return <h2>Dashboard 概览</h2>;
}
function Users() {
return <h2>用户管理</h2>;
}
function Settings() {
return <h2>设置</h2>;
}
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardHome />} />
<Route path="users" element={<Users />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>
);
}
这样,切换页面时,侧边栏不会重新渲染,只有主内容区变化。
Part 5: 动态路由参数
URL 参数(:id)
很多页面需要根据 URL 参数显示不同内容,比如:
/users/1显示用户 1 的信息/users/2显示用户 2 的信息/posts/123显示文章 123 的内容
React Router 用 :paramName 定义动态参数:
ini
<Route path="/users/:id" element={<UserDetail />} />
这里的 :id 就是动态参数,它可以匹配任何值。
useParams Hook
在组件里用 useParams 获取 URL 参数:
javascript
import { useParams } from 'react-router-dom';
function UserDetail() {
const { id } = useParams();
return <h1>用户 {id} 的详情</h1>;
}
当 URL 是 /users/1 时,id 就是 "1";当 URL 是 /users/2 时,id 就是 "2"。
实战:用户详情页
javascript
import { useParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
function UserDetail() {
const { id } = useParams();
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${id}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [id]); // id 变化时重新获取
if (loading) return <p>加载中...</p>;
if (!user) return <p>用户不存在</p>;
return (
<div>
<h1>{user.name}</h1>
<p>邮箱:{user.email}</p>
</div>
);
}
注意 useEffect 的依赖数组是 [id]------当 id 变化时,重新获取用户数据。
查询参数(?key=value)
除了路径参数,URL 还可以有查询参数,比如 /search?q=react&page=2。
React Router 用 useSearchParams 处理查询参数:
javascript
import { useSearchParams } from 'react-router-dom';
function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q');
const page = searchParams.get('page') || '1';
return (
<div>
<p>搜索:{query}</p>
<p>第 {page} 页</p>
<button onClick={() => setSearchParams({ q: query, page: '2' })}>
下一页
</button>
</div>
);
}
useSearchParams 返回一个数组:
searchParams:当前的查询参数对象setSearchParams:更新查询参数的函数
路径参数 vs 查询参数
| 路径参数 | 查询参数 | |
|---|---|---|
| URL 格式 | /users/123 |
/users?id=123 |
| 用途 | 资源标识(必须的) | 可选的过滤、排序、分页 |
| 获取方式 | useParams() |
useSearchParams() |
| 典型场景 | 用户详情、文章详情 | 搜索、筛选、分页 |
个人建议:如果参数是资源的唯一标识(比如用户 ID),用路径参数;如果是可选的过滤条件(比如搜索关键词),用查询参数。
Part 6: 编程式导航
useNavigate Hook
有时候你需要在代码里控制跳转,比如:
- 用户登录成功后,跳转到首页
- 表单提交成功后,跳转到列表页
- 用户没有权限时,跳转到登录页
React Router 用 useNavigate Hook 实现编程式导航:
javascript
import { useNavigate } from 'react-router-dom';
function LoginPage() {
const navigate = useNavigate();
async function handleLogin(e) {
e.preventDefault();
const success = await login(username, password);
if (success) {
navigate('/'); // 跳转到首页
}
}
return (
<form onSubmit={handleLogin}>
{/* ... */}
</form>
);
}
导航到指定页面
scss
const navigate = useNavigate();
// 跳转到 /about
navigate('/about');
// 跳转到 /users/123
navigate('/users/123');
// 跳转到 /search?q=react&page=2
navigate('/search?q=react&page=2');
后退和前进
scss
const navigate = useNavigate();
// 后退
navigate(-1);
// 前进
navigate(1);
// 后退两步
navigate(-2);
导航时传递状态
有时候你想在跳转时传递一些数据,但不想放在 URL 里。可以用 state:
php
const navigate = useNavigate();
// 跳转时传递状态
navigate('/dashboard', { state: { from: 'login' } });
在目标页面读取状态:
javascript
import { useLocation } from 'react-router-dom';
function Dashboard() {
const location = useLocation();
const from = location.state?.from;
return <p>你从 {from} 页面来</p>;
}
这个状态不会出现在 URL 里,但可以在页面间传递。适合传递一些临时数据,比如"从哪个页面跳过来的"。
Part 7: 404 页面和重定向
通配符路由
当用户访问一个不存在的 URL 时,你需要显示 404 页面。用通配符 * 匹配所有路径:
xml
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} /> {/* 404 */}
</Routes>
React Router 会按顺序匹配路由,如果前面的都不匹配,就会匹配 *。
Navigate 组件
有时候你需要把用户重定向到另一个页面,比如:
- 用户访问
/old-page,自动跳转到/new-page - 用户没有登录,自动跳转到
/login
React Router 用 Navigate 组件实现重定向:
javascript
import { Navigate } from 'react-router-dom';
function OldPage() {
return <Navigate to="/new-page" replace />;
}
replace 表示替换当前历史记录,而不是添加一条新记录。这样用户后退时不会回到旧页面。
实战:登录重定向
javascript
import { Navigate } from 'react-router-dom';
function ProtectedRoute({ children }) {
const isLoggedIn = checkAuth(); // 检查是否登录
if (!isLoggedIn) {
return <Navigate to="/login" replace />;
}
return children;
}
// 使用
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
这样,如果用户没有登录就访问 /dashboard,会被自动重定向到 /login。
Part 8: 实战:完整的路由示例
把前面所有知识点整合起来,做一个小型博客系统的路由设计:
javascript
import { BrowserRouter, Routes, Route, Link, Outlet, useParams, useNavigate } from 'react-router-dom';
// 布局组件
function Layout() {
return (
<div>
<header>
<nav>
<Link to="/">首页</Link>
<Link to="/posts">文章</Link>
<Link to="/about">关于</Link>
</nav>
</header>
<main>
<Outlet />
</main>
</div>
);
}
// 首页
function Home() {
return <h1>欢迎来到我的博客</h1>;
}
// 文章列表
function PostList() {
const posts = [
{ id: 1, title: '第一篇文章' },
{ id: 2, title: '第二篇文章' },
];
return (
<div>
<h2>文章列表</h2>
<ul>
{posts.map(post => (
<li key={post.id}>
<Link to={`/posts/${post.id}`}>{post.title}</Link>
</li>
))}
</ul>
</div>
);
}
// 文章详情
function PostDetail() {
const { id } = useParams();
const navigate = useNavigate();
return (
<div>
<h2>文章 {id} 的详情</h2>
<button onClick={() => navigate('/posts')}>返回列表</button>
</div>
);
}
// 关于页面
function About() {
return <h1>关于我</h1>;
}
// 404 页面
function NotFound() {
return <h1>页面不存在</h1>;
}
// App
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="posts" element={<PostList />} />
<Route path="posts/:id" element={<PostDetail />} />
<Route path="about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
);
}
这个例子展示了:
- 布局组件(Layout)+ 嵌套路由
- 动态参数(
/posts/:id) - 编程式导航(
navigate('/posts')) - 404 页面(
*通配符)
总结
- 路由的本质:让 URL 和 UI 保持同步,解决单页应用的 URL "失联"问题
- BrowserRouter:使用 History API,URL 更干净,需要服务器配合
- Route:声明"什么 URL 显示什么组件"
- Link :声明式导航,替代
<a>标签 - 嵌套路由 :用
Outlet实现布局复用 - 动态参数 :用
:paramName定义路径参数,用useParams获取 - 编程式导航 :用
useNavigate在代码里控制跳转 - 404 页面 :用
*通配符匹配所有未定义的路径
路由是单页应用的基础架构。没有路由,你的应用就只是一个"大组件";有了路由,它才真正变成一个"网站"。
相关资源
本文基于 React Router v6+ 版本。