React Router 从入门到实战:动态路由、嵌套路由与懒加载一次讲透

React Router 完全指南:从零搭建一个可扩展的路由系统

上篇我们用原生 JavaScript 手写了一个 HashRouter,解决的是一个最核心的问题:URL 变了,页面该显示什么内容?

到了 React 项目,这件事不该再靠自己监听 hashchange、手动替换 DOM。react-router-dom 已经把这些底层工作封装好了,我们只需要声明:什么 URL 对应什么组件。

本文基于 React Router v6+,从最基础的路由配置开始,一路写到动态参数、嵌套路由、重定向、404 和路由懒加载。读完后,你能搭起一个结构清晰、后续好扩展的路由骨架。

你会掌握:

  • HashRouterRoutesRoute 各自做什么
  • 如何用 LinkuseNavigate 完成导航
  • 如何读取 /user/:id 这类动态参数
  • 如何用 Outlet 搭建二级页面
  • 如何处理首页重定向、404 和旧地址迁移
  • 如何用 lazy + Suspense 做按路由拆包

本文选用 HashRouter,因为它无需服务器额外配置,适合学习、演示和纯静态部署。真实生产项目如果能配置服务端回退,通常更推荐 BrowserRouter,URL 会更干净。

一、先建立直觉:React Router 到底做了什么?

一句话:把"URL -> 页面组件"的映射关系声明出来,并在 URL 改变时重新匹配、重新渲染。

以访问 #/products/123 为例:路由器从 # 后取到 /products/123,匹配到产品模块和详情页,再把详情页放进产品模块预留的 Outlet 位置。

HashRouter 关心的是 # 后面的路径。例如下面这个地址中,真正参与匹配的是 /about

text 复制代码
http://localhost:5173/#/about
                          ^^^^^^ 路由路径

改变 hash 不会向服务器发起新的 HTML 文档请求,所以浏览器不会整页刷新。React Router 监听到地址变化后,只更新需要展示的 React 组件。

二、项目结构:先让页面职责清楚

示例项目可以按下面的方式组织:

text 复制代码
src/
├── App.jsx                  # 路由配置中心
├── main.jsx                 # 应用入口
├── components/
│   └── Navigation.jsx       # 全局导航栏
└── pages/
    ├── Home/                # 首页
    ├── About/               # 关于页
    ├── UserProfile/         # 用户详情页
    ├── NotFound/            # 404 页面
    └── Products/            # 产品模块
        ├── index.jsx        # 产品布局页
        ├── List.jsx         # 产品列表
        ├── Detail.jsx       # 产品详情
        └── New.jsx          # 新增产品

这里有一个容易被忽略的点:Products/index.jsx 不只是"产品列表页",它更适合作为产品模块的布局页 。它负责提供模块标题、侧边栏或 tabs,并通过 Outlet 给子页面留出内容插槽。

三、路由三件套:HashRouter + Routes + Route

先安装依赖:

bash 复制代码
npm install react-router-dom

最小可运行配置如下:

jsx 复制代码
import { HashRouter, Route, Routes } from "react-router-dom";
import Home from "./pages/Home";
import About from "./pages/About";

export default function App() {
  return (
    <HashRouter>
      <Routes>
        <Route path="/home" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </HashRouter>
  );
}
组件 职责
HashRouter 读取并监听 URL 的 hash 路径,给后代提供路由上下文
Routes 在全部 Route 中挑选最匹配当前地址的一组路由
Route 描述一条规则,path 匹配路径,element 指定渲染内容

注意,Routes 不是按代码书写顺序"从上到下碰运气"地匹配。React Router v6 会对候选路由进行评分,通常更具体的静态路径优先于动态参数路径。即使如此,路由配置依然要写得直观,避免让同级规则产生不必要的歧义。

四、页面跳转:为什么用 Link,而不是 a 标签?

在 SPA 内部跳转时,不要直接写:

html 复制代码
<a href="/about">About</a>

它会把浏览器带去请求 /about 这个新文档。对于 HashRouter,写成普通链接还可能丢掉 # 路径。

应该使用 Link

jsx 复制代码
import { Link, NavLink } from "react-router-dom";

export default function Navigation() {
  return (
    <nav>
      <Link to="/home">首页</Link>
      <Link to="/about">关于</Link>
      <Link to="/user/123">用户 123</Link>

      <NavLink
        to="/products"
        className={({ isActive }) => (isActive ? "active" : "")}
      >
        产品
      </NavLink>
    </nav>
  );
}

Link 最终也是一个 <a>,因此保留了可访问性、右键新标签页打开等浏览器行为;但对普通左键点击,它会交给 React Router 的导航机制处理,更新 hash 并渲染匹配到的组件,不需要重新加载整个 HTML 页面。

NavLink 是带"当前是否激活"能力的 Link,特别适合导航栏高亮。

五、动态路由:useParams 读取 URL 参数

用户详情、商品详情这类页面,通常用路径参数携带 ID:

jsx 复制代码
// App.jsx
<Route path="/user/:id" element={<UserProfile />} />

:id 是动态参数占位符。访问 #/user/123 时,组件里能取到 { id: "123" }

jsx 复制代码
import { useParams } from "react-router-dom";

export default function UserProfile() {
  const { id } = useParams();

  return <h2>用户详情:{id}</h2>;
}

这里的 id 永远是字符串。如果接口或业务逻辑需要数字,应该显式转换并校验,而不是默认它一定合法:

jsx 复制代码
const userId = Number(id);
const isValidUserId = Number.isInteger(userId) && userId > 0;

另外,路径参数适合标识资源;筛选、排序、分页这类可选状态,更适合放在查询参数里,例如 #/products?category=phone&page=2,可通过 useSearchParams() 读取。

六、嵌套路由:父路由 + Outlet

产品模块常见的结构是:产品页有一套固定布局,列表、详情、新增只是其中的内容区域发生变化。

路由配置:

jsx 复制代码
import { Route } from "react-router-dom";

<Route path="/products" element={<ProductsLayout />}>
  <Route index element={<ProductList />} />
  <Route path="new" element={<NewProduct />} />
  <Route path=":productId" element={<ProductDetail />} />
</Route>;

子路由的 path 是相对路径,所以不需要重复写 /products。其中有两个细节很重要:

  • index 表示父路径的默认子页面。因此访问 #/products 会显示 ProductList
  • new:productId 是同级规则。React Router 会优先匹配更具体的静态片段,因此 #/products/new 会进入新增页,而不是把 new 当作 productId

父组件必须渲染 Outlet,否则子路由虽然匹配成功,页面上也没有位置显示:

jsx 复制代码
import { Outlet } from "react-router-dom";

export default function ProductsLayout() {
  return (
    <section>
      <h1>产品管理</h1>
      <Outlet />
    </section>
  );
}

访问效果如下:

访问路径 页面内容
#/products 产品管理布局 + 产品列表
#/products/123 产品管理布局 + 产品 123 的详情
#/products/new 产品管理布局 + 新增产品表单

七、重定向与 404:处理"走错路"的用户

根路径一般不直接承载页面,可以重定向到首页:

jsx 复制代码
import { Navigate, Route } from "react-router-dom";

<Route path="/" element={<Navigate to="/home" replace />} />

// 旧地址迁移到新地址
<Route path="/old-products" element={<Navigate to="/products" replace />} />

replace 会替换当前这条历史记录。它适合自动跳转和旧地址迁移,避免用户点"后退"后又回到即将被跳走的页面。

找不到路由时,用 * 做兜底:

jsx 复制代码
<Route path="*" element={<NotFound />} />

* 表示"未被其他规则匹配的路径"。在 v6 中不必依赖"必须写在最后"才能正确工作,但把 404 放在路由表最后依然最利于人阅读。

404 页面里如果需要倒计时跳转,一定要清理定时器,避免用户已经离开页面后定时器还在执行:

jsx 复制代码
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";

export default function NotFound() {
  const navigate = useNavigate();

  useEffect(() => {
    const timerId = window.setTimeout(() => {
      navigate("/home", { replace: true });
    }, 3000);

    return () => window.clearTimeout(timerId);
  }, [navigate]);

  return <p>页面不存在,3 秒后返回首页。</p>;
}

useNavigate() 返回导航函数,适合在表单提交成功、登录状态失效、权限校验失败等 JavaScript 逻辑里跳转:

jsx 复制代码
const navigate = useNavigate();

async function handleSubmit() {
  await saveProduct();
  navigate("/products", { replace: true });
}

八、路由懒加载:不要把所有页面塞进首屏

如果所有页面都是静态 import,构建工具往往会把它们打进初始 JavaScript 包。用户刚打开首页,就被迫下载自己尚未访问的详情页、后台页代码。

lazySuspense 可以按路由拆分代码:

jsx 复制代码
import { lazy, Suspense } from "react";

const Home = lazy(() => import("./pages/Home"));
const About = lazy(() => import("./pages/About"));
const UserProfile = lazy(() => import("./pages/UserProfile"));
const NotFound = lazy(() => import("./pages/NotFound"));
const ProductsLayout = lazy(() => import("./pages/Products"));
const ProductList = lazy(() => import("./pages/Products/List"));
const ProductDetail = lazy(() => import("./pages/Products/Detail"));
const NewProduct = lazy(() => import("./pages/Products/New"));

export default function App() {
  return (
    <HashRouter>
      <Suspense fallback={<p>页面加载中...</p>}>
        <Navigation />
        <Routes>{/* 路由配置 */}</Routes>
      </Suspense>
    </HashRouter>
  );
}
API 作用
lazy(() => import(...)) 声明一个异步加载的组件;构建工具会据此拆分代码块
<Suspense fallback={...}> 异步组件尚未加载完成时显示备用 UI

有一个前提:lazy 默认读取模块的 default export。如果页面文件只导出了具名组件,需要在 import() 后转换成默认导出,或者直接把页面组件改为默认导出。

懒加载不是"越多越好"。首页首屏必需的小组件、切换频繁且体积很小的组件,过度拆分反而可能增加请求和加载闪烁。优先从体积大、访问概率低的页面开始拆。

九、完整路由配置

把前面的能力组合起来,App.jsx 可以这样写:

jsx 复制代码
import { lazy, Suspense } from "react";
import {
  HashRouter,
  Navigate,
  Route,
  Routes,
} from "react-router-dom";
import Navigation from "./components/Navigation";

const Home = lazy(() => import("./pages/Home"));
const About = lazy(() => import("./pages/About"));
const UserProfile = lazy(() => import("./pages/UserProfile"));
const NotFound = lazy(() => import("./pages/NotFound"));
const ProductsLayout = lazy(() => import("./pages/Products"));
const ProductList = lazy(() => import("./pages/Products/List"));
const ProductDetail = lazy(() => import("./pages/Products/Detail"));
const NewProduct = lazy(() => import("./pages/Products/New"));

export default function App() {
  return (
    <HashRouter>
      <Suspense fallback={<p>页面加载中...</p>}>
        <Navigation />
        <Routes>
          <Route path="/" element={<Navigate to="/home" replace />} />
          <Route path="/home" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/user/:id" element={<UserProfile />} />
          <Route path="/old-products" element={<Navigate to="/products" replace />} />

          <Route path="/products" element={<ProductsLayout />}>
            <Route index element={<ProductList />} />
            <Route path="new" element={<NewProduct />} />
            <Route path=":productId" element={<ProductDetail />} />
          </Route>

          <Route path="*" element={<NotFound />} />
        </Routes>
      </Suspense>
    </HashRouter>
  );
}

十、常见误区与面试回答

1. HashRouter 和 BrowserRouter 有什么区别?

HashRouter 把路由状态放在 URL 的 # 后面,hash 不会发送给服务器,因此刷新或直达页面时不依赖服务端做回退配置。代价是 URL 带 #

BrowserRouter 使用 History API,URL 更像普通网站,例如 /products/123。但用户刷新这个深层地址时,服务器需要始终回退到应用入口 HTML,否则会收到服务器的 404。

Link 仍然渲染为 <a>,但会在符合条件的内部跳转时交给路由器处理,因此能保持 SPA 的无刷新体验;普通 <a href> 则按照浏览器默认导航行为加载新的文档。外部网站链接、下载链接仍然应该使用普通 <a>

3. 什么是前端路由?

前端路由是在单页应用里建立"URL 与 UI 状态"的映射。当 URL 变化时,客户端路由器匹配对应规则、渲染相应组件,并与浏览器历史记录协作,使前进、后退和复制链接都能工作。它不等同于后端路由:后端路由决定服务器返回什么资源,前端路由决定已加载应用里显示什么界面。

十一、总结

需求 API 记忆方式
路由容器 HashRouter 从 hash 读取路由状态
路由规则 Routes + Route path 匹配,element 渲染
页面跳转 Link / NavLink 声明式导航,导航栏可高亮
动态参数 :param + useParams() URL 传 ID,组件读取 ID
嵌套路由 Route + Outlet 父布局固定,子页面换内容
默认子页 index 路由 匹配父路径本身
重定向 <Navigate /> 自动跳转,replace 控制历史记录
JS 中跳转 useNavigate() 提交、鉴权等逻辑后导航
404 path="*" 未匹配路径的兜底页面
按需加载 lazy() + Suspense 按路由拆包,降低初始包体积

React Router 的本质并不复杂:把 URL 当作应用状态的一部分,然后用声明式配置描述"状态对应什么 UI"。理解了 RouteOutlet 和导航历史这三件事,后续接入权限路由、面包屑、动态菜单或数据加载路由,都会有清晰的落点。

相关推荐
程序员cxuan15 小时前
速度太快了!本地可以跑 DeepSeek-V4-Flash 了
人工智能·后端·程序员
AlloyTeamZy18 小时前
我发现,一个人做小游戏最难的,根本不是写代码
前端·人工智能·程序员
dong_junshuai19 小时前
每天一个开源项目#61 3.9K Stars:Cloudflare 给 Agent 一台持久云电脑
程序员·github
橙序员小站20 小时前
我把公众号生产链做成了全本地的 AI 工作台 🛠️
程序员·github
董员外1 天前
RAG 系统进化论(九):RAG 评测,怎样证明系统真的变好了?
人工智能·设计模式·程序员
西安小哥1 天前
AI时代技术工程师的"道法术器势":学习图谱、成长规划与角色重塑
人工智能·设计模式·程序员
SimonKing1 天前
开源免费+AI运维,Netcatty快速上手教程
java·后端·程序员
CodeSheep1 天前
FFmpeg 9.0正式发布:代号“Lei”,以纪念中国开发者雷霄骅
前端·后端·程序员
前端开发张小七2 天前
Java 学习笔记 · 第二课:面向对象核心(封装、继承、多态)及接口与异常
java·后端·程序员