1. 什么是客户端路由
传统的多页应用(MPA)中,每次点击链接都会向服务器发送请求,服务器返回一个全新的 HTML 页面,浏览器刷新。而单页应用(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);
});
2. 安装与基础配置
# 安装 React Router DOM(Web 端版本)
npm install react-router-dom
# TypeScript 类型声明已内置,无需额外安装 @types/
BrowserRouter vs HashRouter
| 特性 | BrowserRouter | HashRouter |
|---|---|---|
| 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 会触发完整页面刷新。应使用 Link 或 NavLink 组件,它们内部调用 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 函数,在路由匹配时就开始获取数据,数据准备好了再渲染组件。
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=2。useSearchParams 让你像操作 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>
);
}