React Router 完全指南:从零搭建一个可扩展的路由系统
上篇我们用原生 JavaScript 手写了一个 HashRouter,解决的是一个最核心的问题:URL 变了,页面该显示什么内容?
到了 React 项目,这件事不该再靠自己监听 hashchange、手动替换 DOM。react-router-dom 已经把这些底层工作封装好了,我们只需要声明:什么 URL 对应什么组件。
本文基于 React Router v6+,从最基础的路由配置开始,一路写到动态参数、嵌套路由、重定向、404 和路由懒加载。读完后,你能搭起一个结构清晰、后续好扩展的路由骨架。
你会掌握:
HashRouter、Routes、Route各自做什么- 如何用
Link和useNavigate完成导航 - 如何读取
/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 包。用户刚打开首页,就被迫下载自己尚未访问的详情页、后台页代码。
用 lazy 和 Suspense 可以按路由拆分代码:
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。
2. Link 和 a 标签有什么区别?
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"。理解了 Route、Outlet 和导航历史这三件事,后续接入权限路由、面包屑、动态菜单或数据加载路由,都会有清晰的落点。