Skip to content

深入理解 React 路由

React Router v7、TanStack Router 基础使用、Loader/Action 模式、类型安全路由与数据预加载

什么是前端路由?

定义:前端路由是在不刷新页面的情况下,根据 URL 变化渲染不同组件的机制。React 生态中主流路由方案是 React Router(v7 已与 Remix 合并)和 TanStack Router(类型安全优先)。

涉及场景

  • SPA 页面导航:用户点击链接切换页面,不触发整页刷新
  • 嵌套路由:侧边栏布局、标签页切换、多级导航
  • 数据预加载:路由切换前预取数据(Loader),减少白屏时间
  • 表单提交:路由级 Action 处理表单(POST/PUT/DELETE)
  • 权限守卫:根据登录状态重定向到登录页
  • URL 状态:搜索条件、分页等序列化到 URL(可分享、可后退)

作用

  1. 用户体验:即时导航、浏览器前进/后退正常工作
  2. 数据与路由绑定:Loader/Action 模式消除了组件内手动请求的样板代码
  3. 代码分割:按路由懒加载,减小首屏 bundle
  4. 面试考点:路由原理(History API)、嵌套路由、数据加载策略是常见题目

一、React Router v7

React Router v7 是 React Router 与 Remix 合并后的版本,同时支持 SPA 模式和全栈模式。

基础使用

bash
npm install react-router
jsx
import { createBrowserRouter, RouterProvider, Link, Outlet } from 'react-router';

// 定义路由
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <Home /> },
      {
        path: 'posts',
        element: <PostsLayout />,
        children: [
          { index: true, element: <PostList /> },
          { path: ':postId', element: <PostDetail /> },
          { path: 'new', element: <NewPost /> },
        ],
      },
      { path: 'about', element: <About /> },
      { path: '*', element: <NotFound /> },
    ],
  },
]);

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

// 布局组件
function RootLayout() {
  return (
    <div>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/posts">文章</Link>
        <Link to="/about">关于</Link>
      </nav>
      <main>
        <Outlet /> {/* 子路由在此渲染 */}
      </main>
    </div>
  );
}

Loader —— 路由级数据加载

jsx
const router = createBrowserRouter([
  {
    path: 'posts',
    element: <PostList />,
    // Loader 在路由匹配时自动执行(导航前/并行加载)
    loader: async () => {
      const res = await fetch('/api/posts');
      if (!res.ok) throw new Response('加载失败', { status: res.status });
      return res.json();
    },
  },
  {
    path: 'posts/:postId',
    element: <PostDetail />,
    loader: async ({ params }) => {
      const res = await fetch(`/api/posts/${params.postId}`);
      if (!res.ok) throw new Response('文章不存在', { status: 404 });
      return res.json();
    },
  },
]);

// 组件中获取 Loader 数据
import { useLoaderData } from 'react-router';

function PostList() {
  const posts = useLoaderData(); // 类型:loader 的返回值
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          <Link to={`/posts/${post.id}`}>{post.title}</Link>
        </li>
      ))}
    </ul>
  );
}

Action —— 路由级数据变更

jsx
const router = createBrowserRouter([
  {
    path: 'posts/new',
    element: <NewPost />,
    action: async ({ request }) => {
      const formData = await request.formData();
      const title = formData.get('title');
      const content = formData.get('content');

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

      if (!res.ok) {
        return { error: '创建失败' }; // 返回错误给组件
      }

      return redirect('/posts'); // 成功后跳转
    },
  },
]);

// 组件中使用 Form(自动触发 action)
import { Form, useActionData, useNavigation } from 'react-router';

function NewPost() {
  const actionData = useActionData(); // action 返回的数据
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';

  return (
    <Form method="post">
      <input name="title" required />
      <textarea name="content" required />
      {actionData?.error && <p className="error">{actionData.error}</p>}
      <button disabled={isSubmitting}>
        {isSubmitting ? '提交中...' : '发布文章'}
      </button>
    </Form>
  );
}

常用 Hooks

jsx
import {
  useParams,        // 获取路由参数 { postId: '123' }
  useSearchParams,  // 获取/设置查询参数
  useNavigate,      // 编程式导航
  useLocation,      // 获取当前 location
  useNavigation,    // 获取导航状态(loading/submitting/idle)
  useMatches,       // 获取匹配的路由层级
  useRouteError,    // 在 errorElement 中获取错误
} from 'react-router';

// 查询参数
function SearchPage() {
  const [searchParams, setSearchParams] = useSearchParams();
  const query = searchParams.get('q') || '';
  const page = Number(searchParams.get('page')) || 1;

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setSearchParams({ q: e.target.value, page: '1' })}
      />
      <button onClick={() => setSearchParams({ q: query, page: String(page + 1) })}>
        下一页
      </button>
    </div>
  );
}

// 编程式导航
function LogoutButton() {
  const navigate = useNavigate();
  const handleLogout = async () => {
    await logout();
    navigate('/login', { replace: true }); // replace 不留历史记录
  };
  return <button onClick={handleLogout}>退出</button>;
}

错误处理

jsx
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <RootError />, // 捕获子路由的所有错误
    children: [
      {
        path: 'posts/:id',
        element: <PostDetail />,
        errorElement: <PostError />, // 局部错误边界
        loader: async ({ params }) => {
          const res = await fetch(`/api/posts/${params.id}`);
          if (!res.ok) throw new Response('Not Found', { status: 404 });
          return res.json();
        },
      },
    ],
  },
]);

function PostError() {
  const error = useRouteError();
  if (error instanceof Response && error.status === 404) {
    return <div>文章不存在</div>;
  }
  return <div>加载出错了</div>;
}

二、TanStack Router

TanStack Router 是以类型安全为核心的路由方案,所有路由参数、搜索参数、Loader 数据都有完整的 TypeScript 类型推断。

基础使用

bash
npm install @tanstack/react-router
tsx
import {
  createRootRoute,
  createRoute,
  createRouter,
  RouterProvider,
  Link,
  Outlet,
} from '@tanstack/react-router';

// 根路由
const rootRoute = createRootRoute({
  component: () => (
    <div>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/posts">文章</Link>
      </nav>
      <Outlet />
    </div>
  ),
});

// 子路由
const indexRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  component: () => <h1>首页</h1>,
});

const postsRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/posts',
  component: PostList,
  loader: async () => {
    const posts = await fetchPosts();
    return { posts };
  },
});

const postRoute = createRoute({
  getParentRoute: () => postsRoute,
  path: '$postId', // $ 前缀表示动态参数
  component: PostDetail,
  loader: async ({ params }) => {
    // params.postId 自动推断为 string 类型
    const post = await fetchPost(params.postId);
    return { post };
  },
});

// 创建路由树
const routeTree = rootRoute.addChildren([
  indexRoute,
  postsRoute.addChildren([postRoute]),
]);

// 创建 router
const router = createRouter({ routeTree });

// 类型声明(让全局类型推断生效)
declare module '@tanstack/react-router' {
  interface Register {
    router: typeof router;
  }
}

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

类型安全的搜索参数

tsx
import { z } from 'zod';

const postsRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/posts',
  // 搜索参数的 schema 验证
  validateSearch: z.object({
    page: z.number().default(1),
    sort: z.enum(['date', 'title']).default('date'),
    q: z.string().optional(),
  }),
  component: PostList,
});

function PostList() {
  // search 的类型自动推断:{ page: number; sort: 'date' | 'title'; q?: string }
  const { page, sort, q } = postsRoute.useSearch();
  const navigate = postsRoute.useNavigate();

  return (
    <div>
      <input
        value={q ?? ''}
        onChange={(e) => navigate({ search: { q: e.target.value, page: 1, sort } })}
      />
      <button onClick={() => navigate({ search: { page: page + 1, sort, q } })}>
        下一页(当前第 {page} 页)
      </button>
    </div>
  );
}

三、路由原理

History API

javascript
// 前端路由基于 History API
history.pushState(state, '', '/new-url');    // 添加历史记录
history.replaceState(state, '', '/new-url'); // 替换当前记录
history.back();                               // 后退
history.forward();                            // 前进

// 监听前进/后退
window.addEventListener('popstate', (event) => {
  console.log('URL changed to:', location.pathname);
  // 根据新 URL 渲染对应组件
});

两种路由模式

BrowserRouter(推荐):
  URL: https://example.com/posts/123
  基于 History API
  需要服务端配置(所有路径返回 index.html)

HashRouter:
  URL: https://example.com/#/posts/123
  基于 hash 变化(hashchange 事件)
  无需服务端配置(hash 部分不发送到服务器)
  SEO 不友好

四、React Router v7 vs TanStack Router

维度React Router v7TanStack Router
类型安全基础完整推断
搜索参数字符串手动解析Schema 验证+类型推断
文件路由支持(框架模式)支持(插件)
Loader/Action内置内置
SSR完善支持
生态成熟度非常成熟快速增长
学习曲线中(需要理解类型系统)
适合大多数项目类型安全要求高的项目

五、路由最佳实践

路由级代码分割

jsx
import { lazy } from 'react';

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

const router = createBrowserRouter([
  {
    path: 'dashboard',
    element: (
      <Suspense fallback={<PageSkeleton />}>
        <Dashboard />
      </Suspense>
    ),
    loader: dashboardLoader, // loader 和组件并行加载
  },
]);

权限守卫

jsx
// 方式1:Loader 中检查权限
{
  path: 'admin',
  loader: async () => {
    const user = await getUser();
    if (!user || user.role !== 'admin') {
      throw redirect('/login');
    }
    return { user };
  },
  element: <AdminPanel />,
}

// 方式2:包裹组件
function RequireAuth({ children }) {
  const { user } = useAuth();
  const location = useLocation();

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

面包屑导航

jsx
import { useMatches } from 'react-router';

function Breadcrumbs() {
  const matches = useMatches();
  const crumbs = matches
    .filter(match => match.handle?.crumb)
    .map(match => match.handle.crumb(match.data));

  return (
    <nav>
      {crumbs.map((crumb, i) => (
        <span key={i}>{i > 0 && ' > '}{crumb}</span>
      ))}
    </nav>
  );
}

// 路由定义中
{
  path: 'posts/:id',
  handle: {
    crumb: (data) => data.post.title,
  },
}

面试高频题

Q: React Router 的 Loader 和 useEffect 获取数据有什么区别?

  • Loader:在路由匹配时并行执行(与组件加载同时进行),导航完成前数据已就绪,无白屏
  • useEffect:组件渲染后才发起请求,产生"渲染 → 请求 → 等待 → 再渲染"的瀑布流
  • Loader 还支持表单 Action 提交后自动重新执行,保持数据最新

Q: 前端路由的两种模式?

  • History 模式:基于 history.pushState,URL 无 #,需服务端配合(所有路径返回 index.html)
  • Hash 模式:基于 URL 的 # 部分,浏览器不发送 hash 到服务器,无需服务端配置,但 SEO 不友好

Q: 嵌套路由的好处?

  1. 布局复用:共享 Header/Sidebar/Footer,只切换内容区域
  2. 数据并行加载:父子路由的 Loader 同时执行,不产生瀑布流
  3. 局部错误处理:子路由出错不影响父路由的 UI
  4. 渐进式加载:父路由先渲染,子路由加载中显示 Suspense fallback