React Router 与路由设计

React Router v6 完整路由体系:声明式路由、嵌套路由、路由参数、编程式导航、路由守卫、延迟加载,以及 SPA 路由的实现原理。

React Router 与路由设计

为什么前端需要路由

在传统的多页面应用(MPA)里,浏览器会根据 URL 请求不同的 HTML 文件。服务器知道每个 URL 对应什么页面,浏览器只负责展示。

单页面应用(SPA)改变了这个模式。整个应用运行在一个 HTML 文件里,页面切换不需要浏览器去服务器请求新的 HTML——前端 JavaScript 根据路由规则决定渲染哪个组件。URL 变化只是浏览器地址栏变了,DOM 上的内容是 JavaScript 动态替换的。

这带来了一个问题:浏览器的「前进」「后退」按钮还能正常工作吗? 还有「用户直接访问 /product/123 这个 URL 时,服务器只返回了空 HTML,JavaScript 还没加载完,浏览器拿到的是空白页面怎么办?」

React Router 解决的是这两个问题:让前端路由拥有和传统多页面应用一样的导航体验,同时提供一套声明式的 API 来管理路由配置


React Router 的核心概念

声明式路由

React Router 的核心是 <Routes><Route> 组件,它们让你用 JSX 声明路由结构:

import { Routes, Route, Link } from 'react-router-dom';

function App() {
  return (
    <div>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/product/123">商品详情</Link>
      </nav>

      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/product/:id" element={<ProductDetail />} />
        <Route path="/user/*" element={<UserLayout />}>
          <Route index element={<UserDashboard />} />
          <Route path="orders" element={<UserOrders />} />
          <Route path="settings" element={<UserSettings />} />
        </Route>
        <Route path="*" element={<NotFound />} />
      </Routes>
    </div>
  );
}

路由路径可以包含动态段:id 这样的参数)和通配符*,匹配任意字符)。

嵌套路由

嵌套路由是 React Router 的强大特性。外层 Route 可以包含子 Route,子 Route 的内容渲染在外层组件的 <Outlet /> 位置:

function UserLayout() {
  return (
    <div>
      <UserSidebar />
      <Outlet />  {/* 子路由的内容在这里渲染 */}
    </div>
  );
}

<Route path="/user" element={<UserLayout />}>
  <Route path="orders" element={<UserOrders />} />
  <Route path="settings" element={<UserSettings />} />
</Route>

这个模式天然适合「侧边栏 + 主内容区」的布局。每个子路由复用同一个侧边栏,但主内容区根据路由变化。这比在每个页面组件里重复写侧边栏要清晰得多。

路由参数

URL 里的动态参数通过 useParams 获取:

function ProductDetail() {
  const { id } = useParams();  // 匹配 /product/:id 中的 :id

  const [product, setProduct] = useState(null);

  useEffect(() => {
    fetchProduct(id).then(setProduct);
  }, [id]);

  return product ? (
    <div>
      <h1>{product.name}</h1>
      <p>ID: {id}</p>
    </div>
  ) : <Loading />;
}

编程式导航

有时候需要代码里主动跳转,而不是用户点击 Link。useNavigate 提供了这个能力:

function LoginForm() {
  const navigate = useNavigate();

  async function handleSubmit(credentials) {
    await login(credentials);
    navigate('/dashboard');        // 跳转并替换当前历史记录
    // navigate('/dashboard', { replace: true });  // 显式替换
  }

  return <form onSubmit={handleSubmit}>...</form>;
}

navigate 还可以接受 delta 参数实现前进后退:

const navigate = useNavigate();
navigate(-1);  // 后退一页
navigate(2);   // 前进两页

路由匹配与优先级

React Router 按声明顺序匹配 Route。第一个匹配到的 Route 会被渲染,后面的不再尝试。

这在有多个可能匹配的路由时很重要。比如 /user/orders 既可以匹配 path="*"(catch-all),也可以匹配 path="orders"(更具体的路径)。React Router 会优先匹配更具体的路径。

但这不意味着你要靠顺序来控制匹配——React Router 的匹配算法本身就倾向于更具体的路径。真正需要小心的是可选参数和通配符的组合


懒加载与路由级代码分割

应用体积变大时,把整个应用打包成一个 JS 文件会导致首屏加载变慢。路由级懒加载让每个路由页面只在访问时才加载:

import { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

// 访问到这个路由时才加载对应的 JS chunk
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
const UserOrders = lazy(() => import('./pages/UserOrders'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <Routes>
        <Route path="/product/:id" element={<ProductDetail />} />
        <Route path="/user/orders" element={<UserOrders />} />
      </Routes>
    </Suspense>
  );
}

lazy() 返回的组件在首次渲染时触发加载,显示 fallback(通常是一个 loading 指示器)。加载完成后 React 自动用真实组件替换。

Vite 或 webpack 的打包工具会自动把每个 lazy() 入口拆分成独立的 chunk 文件,实现真正的按需加载。

预加载策略

有时候你想提前预加载用户可能访问的路由:

import { lazy, useNavigate } from 'react';

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

function ProductCard({ product }) {
  const navigate = useNavigate();

  function handleMouseEnter() {
    // 用户悬停时预加载,而不是等到点击
    ProductDetail.preload();
  }

  return (
    <div onMouseEnter={handleMouseEnter}>
      <h3 onClick={() => navigate(`/product/${product.id}`)}>
        {product.name}
      </h3>
    </div>
  );
}

这让页面切换几乎是即时的——用户点击时对应的 JS 已经加载好了。


路由守卫:权限控制

React Router 没有内置的「路由守卫」概念,你需要自己实现。常见做法是包装 Route 组件:

function ProtectedRoute({ children, isAuthenticated }) {
  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }
  return children;
}

function App() {
  const { isAuthenticated } = useAuth();

  return (
    <Routes>
      <Route path="/login" element={<Login />} />
      <Route
        path="/dashboard"
        element={
          <ProtectedRoute isAuthenticated={isAuthenticated}>
            <Dashboard />
          </ProtectedRoute>
        }
      />
    </Routes>
  );
}

Navigate 组件在渲染时跳转到指定路径,replace 属性决定是否替换当前历史记录(登录后通常应该 replace,这样用户点后退不会回到登录页)。


数据获取与路由的配合

路由变化时通常意味着页面内容要变。React Router 没有规定数据获取方式,但有几个常见模式。

在 useEffect 里用 useParams 获取数据:

function ProductPage() {
  const { id } = useParams();
  const [product, setProduct] = useState(null);

  useEffect(() => {
    fetchProduct(id).then(setProduct);
  }, [id]);

  // ...
}

用框架的数据层(如 TanStack Query)配合路由:

function ProductPage() {
  const { id } = useParams();
  const { data: product } = useQuery(['product', id], () => fetchProduct(id));

  // ...
}

这种模式下,路由参数变化会触发新的 query key,TanStack Query 自动重新获取数据。这比手动在 useEffect 里写数据获取逻辑要简洁得多。


React Router 的实现原理

理解 React Router 的底层实现有助于调试疑难问题。

React Router 有三种模式:Web(浏览器 History API)、Hashwindow.location.hash)、Memory(内存中的历史堆栈,不读写 URL)。

浏览器 History API 模式是最常用的:

// 点击 Link 时,React Router 调用这些 API
history.pushState(state, title, path);  // 导航,不触发页面刷新
window.onpopstate = () => { ... };       // 浏览器前进/后退时触发
history.replaceState(state, title, path); // 替换当前记录

核心原理是:监听 popstate 事件(浏览器前进后退)和 Link 点击事件(阻止默认跳转,调用 pushState),维护一个组件树状态与 URL 的对应关系。URL 变化时,React Router 重新匹配路由树,找到对应组件并渲染。

这解释了为什么直接修改 URL(或者在浏览器地址栏回车)有时候行为和点击 Link 不一样——如果是浏览器触发的导航,会走 popstate;如果是 pushState,则由 React Router 自己处理。


常见问题与解决

刷新页面丢失状态

在 React Router 里,页面刷新会重新加载 JS、应用重新初始化、组件重新挂载。依赖组件内部 state 的数据(比如用户输入了一半的表单)会丢失。解决方案是把状态放到 Context、URL search params(?search=xxx)或者 localStorage 里。

相对路径在嵌套路由里的行为

React Router v6 里,<Link to="sibling"> 这样的相对路径是相对于当前路由路径的,而不是绝对路径。这在嵌套路由里特别有用——写一次可以在多个父路由下复用。

useLocation 的实际用途

useLocation 返回当前 URL 信息(pathname、search、hash、state)。一个实用场景是监听 URL 变化来触发动画或者重新请求数据:

function ScrollToTop() {
  const { pathname } = useLocation();

  useEffect(() => {
    window.scrollTo(0, 0);
  }, [pathname]);

  return null;
}

// 放在路由顶层,每次路由变化自动滚动到顶部

面试中的表达

展示深度的 React Router 回答应该从 SPA 路由的问题出发,然后解释 React Router 提供的解决方案:

React Router 本质上是浏览器 History API 的 React 封装。它通过监听 popstate 事件处理浏览器前进后退,通过 pushState/replaceState 处理编程式导航,而路由树结构通过 <Routes><Route 的声明式配置维护。React Router 的路由匹配是按声明顺序优先匹配更具体的路径,所以嵌套路由的写法天然支持「layout + outlet」模式,子路由渲染在父路由的 <Outlet> 位置。路由级代码分割是 SPA 性能优化的重要手段,lazy()Suspense 配合让每个路由页面按需加载,结合预加载策略可以在用户hover时就提前加载目标页面 JS。


延展阅读