React Router 完全指南:v6 路由模式、嵌套路由、Loader/Action、权限守卫与生产实践

React Router v6 的深度实践:声明式路由、动态参数、嵌套路由与 Outlet、loader/action 数据获取模式、路由守卫与鉴权、滚动恢复、懒加载与代码分割。包含 React Router v6.4+ 数据 API、与 Next.js App Router 的对比和从 v5 迁移指南。

路由是现代单页应用(SPA)的骨架。React Router 是 React 生态中最流行的路由库,从 v6 开始彻底重构了 API,引入了 loader/action、嵌套路由、错误边界等现代模式。本文覆盖从基础到生产级的完整路由实践。


一、为什么 React Router 仍然是 2025 年的标准

特性React Router v6Next.js App RouterTanStack Router
框架绑定无(任何 React 应用)Next.js 专属无(但生态较小)
数据获取✅ loader/action(v6.4+)✅ Server Components✅ loader 类似
嵌套路由✅ 声明式✅ 文件系统约定✅ 声明式
类型安全弱(需插件)✅ 路径类型推导
社区生态最大大(Next.js 内)
React Native✅ React Navigation 模型

结论:如果不使用 Next.js,React Router 是 React SPA 路由的唯一标准选择。如果已在 Next.js 生态中,App Router 的文件系统路由更方便。


二、基础路由配置

2.1 声明式路由

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

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Home /> },
      { path: 'about', element: <About /> },
      { path: 'contact', element: <Contact /> },
    ],
  },
]);

function App() {
  return <RouterProvider router={router} />;
}

createBrowserRouter vs createHashRouter

  • BrowserRouter:使用 HTML5 History API,/users/123,需要服务器配置回退到 index.html
  • HashRouter:使用 #/users/123,不需要服务器配置,但 URL 不美观
  • 生产环境推荐 BrowserRouter,Dev 或 Electron 可用 HashRouter

2.2 路由组件三要素

组件/Hook用途示例
<Link>声明式导航<Link to="/about">About</Link>
<NavLink>带激活状态的导航<NavLink to="/dashboard" className={({ isActive }) => isActive ? 'active' : ''}>
useNavigate()编程式导航const navigate = useNavigate(); navigate('/login')
useParams()读取 URL 参数const { userId } = useParams()
useSearchParams()读取/修改查询参数const [searchParams, setSearchParams] = useSearchParams()
useLocation()获取当前位置对象const { pathname, search, state } = useLocation()

2.3 动态路由与 URL 参数

// 路由定义
{ path: 'users/:userId/posts/:postId', element: <PostDetail /> }

// 组件中读取
function PostDetail() {
  const { userId, postId } = useParams<{ userId: string; postId: string }>();
  // URL: /users/123/posts/456 → userId='123', postId='456'
}

// 可选参数(v6 需显式定义两条路由或统一处理)
{ path: 'products/:category?/:productId?', element: <ProductList /> }

2.4 查询参数管理

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

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

  const category = searchParams.get('category');
  const page = parseInt(searchParams.get('page') || '1', 10);
  const sort = searchParams.get('sort') || 'newest';

  const setFilter = (key: string, value: string) => {
    const newParams = new URLSearchParams(searchParams);
    if (value) {
      newParams.set(key, value);
    } else {
      newParams.delete(key);
    }
    setSearchParams(newParams);
  };

  return (
    <div>
      <button onClick={() => setFilter('category', 'electronics')}>
        Electronics
      </button>
      <button onClick={() => setFilter('page', String(page + 1))}>
        Next Page
      </button>
    </div>
  );
}

推荐做法:URL 作为状态来源。筛选条件、页码、排序等应反映在 URL 上,这样用户刷新页面后状态不丢失,也支持分享链接。


三、嵌套路由与 Outlet

嵌套路由是 React Router v6 的核心设计之一,它解决了传统路由"一个路由组件加载所有子组件"导致的重复渲染问题。

3.1 嵌套结构

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,           // 包含 <Outlet /> 和导航
    errorElement: <ErrorPage />,       // 错误边界
    children: [
      { index: true, element: <Home /> },
      {
        path: 'dashboard',
        element: <DashboardLayout />,  // 嵌套的布局
        children: [
          { index: true, element: <DashboardHome /> },
          { path: 'analytics', element: <Analytics /> },
          { path: 'settings', element: <Settings /> },
        ],
      },
      {
        path: 'users/:userId',
        element: <UserProfile />,
        children: [
          { index: true, element: <UserOverview /> },
          { path: 'posts', element: <UserPosts /> },
          { path: 'settings', element: <UserSettings /> },
        ],
      },
    ],
  },
]);
// RootLayout.tsx —— 根布局
function RootLayout() {
  return (
    <div className="app">
      <NavBar />
      <main>
        <Outlet /> {/* 子路由组件渲染位置 */}
      </main>
      <Footer />
    </div>
  );
}

// DashboardLayout.tsx —— 仪表板布局
function DashboardLayout() {
  return (
    <div className="dashboard">
      <Sidebar />
      <div className="content">
        <Outlet /> {/* 渲染 DashboardHome / Analytics / Settings */}
      </div>
    </div>
  );
}

Outlet 的优势:子路由切换时,父布局(导航栏、侧边栏)不重新挂载。这在传统路由中需要手动用 Switch / Routes + 状态管理实现,v6 原生支持。

3.2 索引路由(Index Routes)

index: true 表示父路径的默认子路由:

  • /dashboard → 渲染 <DashboardLayout> + <DashboardHome>
  • /dashboard/analytics → 渲染 <DashboardLayout> + <Analytics>

四、数据 API:Loader 与 Action(v6.4+)

React Router v6.4 引入了数据 API,让路由与数据获取解耦,支持并行加载、优雅降级和乐观 UI。

4.1 Loader:路由数据预取

// types.ts
interface Post {
  id: string;
  title: string;
  content: string;
  author: { name: string; avatar: string };
}

// api.ts
export async function getPost(postId: string): Promise<Post> {
  const res = await fetch(`/api/posts/${postId}`);
  if (!res.ok) throw new Response('Not Found', { status: 404 });
  return res.json();
}

// route.tsx
import { LoaderFunctionArgs, useLoaderData } from 'react-router-dom';
import { getPost } from './api';

export async function postLoader({ params }: LoaderFunctionArgs) {
  if (!params.postId) throw new Response('Post ID required', { status: 400 });
  return getPost(params.postId);
}

export function PostPage() {
  const post = useLoaderData() as Post;  // loader 返回的数据

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

// router.tsx
{ path: 'posts/:postId', loader: postLoader, element: <PostPage /> }

Loader 的执行时机

  • 用户导航到该路由时并行执行
  • 多个子路由的 loader 同时执行(非串行)
  • 有缓存时不会重复执行

4.2 Action:表单提交与变更

import { ActionFunctionArgs, Form, useActionData, useNavigation } from 'react-router-dom';

export async function loginAction({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

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

  if (!res.ok) {
    return { error: 'Invalid credentials' };
  }

  const { token } = await res.json();
  localStorage.setItem('token', token);
  return redirect('/dashboard');
}

export function LoginPage() {
  const actionData = useActionData<{ error?: string }>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';

  return (
    <Form method="post">
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Login'}
      </button>
      {actionData?.error && <p className="error">{actionData.error}</p>}
    </Form>
  );
}

4.3 错误处理:Error Boundary

// 全局错误页
export function ErrorPage() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>{error.status} {error.statusText}</h1>
        <p>{error.data}</p>
      </div>
    );
  }

  return (
    <div>
      <h1>Oops!</h1>
      <p>{(error as Error).message}</p>
    </div>
  );
}

// router.tsx
{
  path: '/',
  element: <RootLayout />,
  errorElement: <ErrorPage />,   // 全局错误边界
  children: [
    {
      path: 'users/:userId',
      element: <UserProfile />,
      errorElement: <UserNotFound />,  // 局部错误边界
      loader: userLoader,
    },
  ],
}

4.4 数据 API 流程图

用户点击 <Link to="/posts/123">
  ↓
React Router 匹配到 /posts/:postId 路由
  ↓
并行执行该路由及其父路由的 loader 函数
  ↓
显示父布局(过渡状态,已有数据不变)
  ↓
loader 完成后,渲染目标组件
  ↓
用户提交表单 → action 执行 → 成功后重取 loader 数据 / 跳转

五、路由守卫与权限控制

5.1 基础的受保护路由

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

function RequireAuth({ children }: { children: React.ReactNode }) {
  const { user } = useAuth();
  const location = useLocation();

  if (!user) {
    // 保存尝试访问的路径,登录后重定向回来
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return <>{children}</>;
}

// router.tsx
{
  path: 'dashboard',
  element: (
    <RequireAuth>
      <DashboardLayout />
    </RequireAuth>
  ),
},```

### 5.2 角色权限路由

```tsx
function RequireRole({ allowedRoles, children }: {
  allowedRoles: string[];
  children: React.ReactNode;
}) {
  const { user } = useAuth();
  const location = useLocation();

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

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

  return <>{children}</>;
}

// 使用
{
  path: 'admin',
  element: (
    <RequireRole allowedRoles={['admin', 'moderator']}>
      <AdminPanel />
    </RequireRole>
  ),
},

5.3 基于 Loader 的权限检查

export async function adminLoader() {
  const user = await getCurrentUser();

  if (!user) {
    throw redirect('/login');
  }

  if (!['admin', 'moderator'].includes(user.role)) {
    throw redirect('/unauthorized');
  }

  return user;
}

六、高级模式

6.1 懒加载与代码分割

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./pages/Dashboard'));
const Analytics = lazy(() => import('./pages/Analytics'));

{
  path: 'dashboard',
  element: (
    <Suspense fallback={<LoadingSpinner />}>
      <Dashboard />
    </Suspense>
  ),
},

数据 API 与懒加载的结合

// 路由懒加载 + loader 也懒加载
const userRoute = {
  path: 'users/:userId',
  async lazy() {
    const { UserPage, userLoader } = await import('./pages/UserPage');
    return { Component: UserPage, loader: userLoader };
  },
};

6.2 滚动恢复

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

function RootLayout() {
  return (
    <div>
      <ScrollRestoration /> {/* 自动恢复滚动位置 */}
      <Outlet />
    </div>
  );
}

自定义滚动行为

const router = createBrowserRouter(routes, {
  scrollBehavior: 'auto',  // 默认:自动恢复
});

// 或不恢复(SPA 常见)
const router = createBrowserRouter(routes);

6.3 过渡导航(Deferred Data)

import { Await, defer, useLoaderData } from 'react-router-dom';

export async function dashboardLoader() {
  const userPromise = getCurrentUser();  // 立即需要
  const statsPromise = getStats();       // 可以延后
  const notificationsPromise = getNotifications();  // 可以延后

  return defer({
    user: await userPromise,    // 等待(阻塞渲染)
    stats: statsPromise,        // 流式传输(不阻塞)
    notifications: notificationsPromise,
  });
}

export function Dashboard() {
  const { user, stats, notifications } = useLoaderData() as {
    user: User;
    stats: Promise<Stats>;
    notifications: Promise<Notification[]>;
  };

  return (
    <div>
      <h1>Welcome, {user.name}</h1>

      <Suspense fallback={<StatsSkeleton />}>
        <Await resolve={stats}>
          {(statsData) => <StatsWidget data={statsData} />}
        </Await>
      </Suspense>

      <Suspense fallback={<NotificationsSkeleton />}>
        <Await resolve={notifications}>
          {(notifData) => <NotificationList data={notifData} />}
        </Await>
      </Suspense>
    </div>
  );
}

七、从 v5 迁移到 v6 要点

v5 APIv6 等价变化说明
<Switch><Routes>语义更清晰
<Route exact><Route>v6 路由默认精确匹配
<Route component={Home}><Route element={<Home />}>element 接收 JSX
useHistory()useNavigate()新 hook
history.push('/path')navigate('/path')新 API
history.replace('/path')navigate('/path', { replace: true })选项对象
<Redirect to="/" /><Navigate to="/" replace />新组件
useRouteMatch()useMatch()简化
component={Comp}element={<Comp />}不再支持 render/component 属性

常见问题(FAQ)

React Router 和 Next.js App Router 怎么选?

  • 已用 Next.js:选 App Router,文件系统路由、Server Components、流式渲染集成更好
  • 纯 React SPA / Create React App / Vite:React Router 是唯一标准选择
  • 需要同时支持 Web 和 Native:React Router 的声明式模式与 React Navigation 概念相同

路由守卫放在 loader 还是组件里?

  • 简单权限(登录检查):放在组件里(<RequireAuth>),UI 控制更灵活
  • 复杂权限(角色、权限码):放在 loader 里,可以在渲染前就做重定向,用户体验更好

怎么处理 404 Not Found?

// 在路由配置末尾添加通配符路由
{
  path: '*',
  element: <NotFoundPage />,
}

loader 中抛出的错误怎么处理?

使用 errorElement 捕获Loader 抛出的错误会在最近的 errorElement 中捕获。如果是 Response 类型的抛出,会转为 RouteErrorResponse

相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章