本文以 readme.md 为核心主线,配合演示项目的全部源代码与注释,系统阐述前端路由的发展脉络、底层原理以及 React Router 的完整实践。文中特别标注了来自源码注释但 readme.md 未覆盖的深层见解(如"二次处理"困境、"即是配置,又是出现的地方"的双重身份、"召之即来"的 Hooks 哲学等),适合已掌握 React 基础、正在学习路由技术的开发者阅读。
目录
- [一、路由的本质与 RESTful 思想](#一、路由的本质与 RESTful 思想 "#%E4%B8%80%E8%B7%AF%E7%94%B1%E7%9A%84%E6%9C%AC%E8%B4%A8%E4%B8%8E-restful-%E6%80%9D%E6%83%B3")
- 二、前端路由的演进史
- [三、Hash 锚链接的底层原理](#三、Hash 锚链接的底层原理 "#%E4%B8%89hash-%E9%94%9A%E9%93%BE%E6%8E%A5%E7%9A%84%E5%BA%95%E5%B1%82%E5%8E%9F%E7%90%86")
- [四、React 集成前端路由](#四、React 集成前端路由 "#%E5%9B%9Breact-%E9%9B%86%E6%88%90%E5%89%8D%E7%AB%AF%E8%B7%AF%E7%94%B1")
- 五、路由基本配置
- 六、路由懒加载:性能优化的杀手锏
- 七、动态路由:一个组件,无尽内容
- 八、嵌套路由:多级页面结构
- [九、404 Not Found 与路由重定向](#九、404 Not Found 与路由重定向 "#%E4%B9%9D404-not-found-%E4%B8%8E%E8%B7%AF%E7%94%B1%E9%87%8D%E5%AE%9A%E5%90%91")
- 十、项目完整架构与目录规范
- [十一、API 速查表与总结](#十一、API 速查表与总结 "#%E5%8D%81%E4%B8%80api-%E9%80%9F%E6%9F%A5%E8%A1%A8%E4%B8%8E%E6%80%BB%E7%BB%93")
一、路由的本质与 RESTful 思想
路由(Routing)的核心职责可以归纳为一句话:根据 URL 决定显示什么内容。在 Web 开发的世界里,路由不仅是一个技术概念,更承载着一种架构哲学------RESTful。
RESTful:一切皆资源。 每一个 URL 路径代表一种资源(Resource),用户通过访问不同的路径来获取不同的资源。
/users代表用户列表这个资源,/users/123代表 ID 为 123 的用户这个资源,/products代表产品列表这个资源。
这种"以 URL 为中心、以资源为粒度"的思维方式,让 Web 应用的结构变得清晰可预测。前端路由正是这一思想在浏览器端的实现------将 URL 路径映射到对应的页面组件,在不刷新整个页面的前提下完成内容的切换。
二、前端路由的演进史
要真正理解 React Router,必须先理解为什么会有前端路由,以及它解决了什么问题。整个演进分为三个阶段。
2.1 第一阶段:后端路由(传统多页应用)
在 Web 开发的早期(以及许多传统网站至今),路由完全由后端负责。这个模式的工作流程如下:
vbnet
用户在浏览器点击 <a href="/about">关于我们</a>
↓
浏览器向服务器发起全新的 HTTP GET 请求:GET /about
↓
后端路由匹配(例如 Express 的 app.get('/about', handler))
↓
服务器查询数据库、拼接模板,返回一个完整的 HTML 页面
↓
浏览器收到响应,整个页面"白一下"后重新渲染
痛点分析:
- 慢:每次导航都是一次完整的 HTTP 请求-响应循环,需要等待网络传输和服务器处理
- 白屏:页面刷新时出现短暂的白屏闪烁,用户体验割裂
- 浪费:HTML 头部、导航栏、页脚等公共部分每次都要重新传输,造成带宽浪费
- 前后端耦合:前端团队改页面布局需要后端配合,无法独立部署
2.2 第二阶段:前后端分离 + SPA
随着 Ajax 技术的成熟和前端框架的崛起,前后端分离 成为行业趋势。单页应用(Single Page Application,简称 SPA) 应运而生。
SPA 的核心理念:HTML、CSS、JavaScript 只加载一次,之后所有的页面切换完全由前端 JavaScript 控制,不再向服务器请求完整的 HTML 页面。服务器只提供 API 接口(返回 JSON 数据),页面渲染完全交给前端。
javascript
首次访问:浏览器 → 服务器 → 返回 index.html + 全部 JS/CSS
↓
此后所有页面切换都在浏览器端完成
↓
URL 变化 → 前端路由器拦截 → 切换显示对应组件
↓
需要数据时:Ajax 请求 API → 服务器返回 JSON → 更新界面
这种架构带来了质的飞跃:页面切换几乎瞬间完成,用户体验流畅如原生应用。但这也引出了一个新的问题------如何在不刷新页面的前提下改变 URL?
2.3 第三阶段:HashRouter 的诞生
答案藏在 URL 的 Hash 部分(# 后面的内容) 中。浏览器有一个关键的机制:修改 URL 的 hash 部分不会触发页面刷新。
这就给了前端开发者一个完美的"后门"------我们可以利用 hash 来模拟路由的变化:
bash
https://example.com/#/home ← 首页
https://example.com/#/about ← 关于页(页面不刷新!)
https://example.com/#/user/123 ← 用户页(页面不刷新!)
这就是 HashRouter 的底层原理。前端 JavaScript 监听 hash 的变化,当 hash 改变时,根据新的 hash 值渲染对应的页面组件。整个过程中浏览器不向服务器发送任何请求,完全在客户端完成。
三、Hash 锚链接的底层原理
3.1 什么是 Hash
URL 中的 hash 是指 # 符号及其后面的部分。它最初的设计目的是"锚链接"------点击页面内的跳转链接,滚动到指定位置。
bash
完整的 URL 结构:
┌─────────────────────────────────────────────┬────────────┐
│ https://www.example.com:443/path/page?q=1 │ #sectionA │
└─────────────────────────────────────────────┴────────────┘
基础 URL(会引起页面刷新) Hash 部分(不会刷新)
关键特性 :Hash 的改变不会导致浏览器向服务器发起新请求。这是浏览器的核心设计------hash 被视为页面内的导航,而非跨页面导航。
3.2 hashchange 事件
浏览器提供了 hashchange 事件,让开发者可以监听 hash 的变化:
javascript
// 原生 JavaScript 监听 hash 变化
window.addEventListener('hashchange', () => {
console.log('当前 hash:', window.location.hash);
// 根据 hash 值决定渲染哪个"页面"
// 这就是所有 Hash Router 的底层机制
});
React Router 的 HashRouter 组件正是在这个 API 之上封装的------它将 hashchange 事件与 React 的组件渲染机制连接在一起,让你可以用声明式的方式定义路由。
3.3 为什么 Hash 不会刷新页面
从浏览器架构的角度看,这是有意为之的设计选择:
- 语义区分:Hash 被定义为"片段标识符"(Fragment Identifier),属于当前文档的"内部定位",不需要网络请求
- 性能考量:锚链接跳转应该瞬间完成,不应该触发网络开销
- 历史原因:Hash 的这个特性早在 Web 的草创期就已确立,为后来的 SPA 架构埋下了伏笔
正是这个看似简单的机制,支撑起了整个 SPA 时代的路由体系。
四、React 集成前端路由
4.1 React 全家桶体系
React 作为一个 UI 库本身并不提供路由功能。React 社区形成了"全家桶"的开发体系,由三根支柱构成:
| 支柱 | 核心职责 |
|---|---|
| React | 组件化开发、响应式 UI 渲染 |
| react-router-dom | 前端路由管理,给 SPA 添加页面切换能力 |
| zustand / pinia | 全局状态管理,跨组件共享数据 |
其中 react-router-dom 内置了 HashRouter 和 BrowserRouter 两种路由模式。本项目使用 HashRouter(基于 hash 实现),因为它不需要服务端配置即可运行,适合学习和演示场景。
4.2 项目依赖与技术栈
json
// package.json --- 项目依赖配置
{
"dependencies": {
"react": "^19.2.6", // React 核心库
"react-dom": "^19.2.6", // React DOM 渲染器
"react-router-dom": "^7.18.2" // React 路由库(前端路由核心)
},
"devDependencies": {
"vite": "^8.0.12" // Vite 构建工具(极速 HMR)
}
}
项目基于 Vite + React 19 + react-router-dom v7,这是 2024-2026 年 React 项目的主流技术栈。
4.3 react-router-dom 核心组件一览
react-router-dom v7 提供了一套声明式的路由 API,本项目使用了以下核心组件:
| 组件/API | 类型 | 作用 |
|---|---|---|
HashRouter |
容器组件 | 前端路由根容器,基于 location.hash + hashchange 实现 |
Routes |
容器组件 | 路由配置数组,包裹所有 Route |
Route |
配置组件 | 单条路由规则:path(URL 匹配模式)→ element(渲染组件) |
Link |
导航组件 | SPA 声明式导航,替代原生 <a> 标签 |
Navigate |
导航组件 | 声明式路由跳转,渲染即跳转 |
Outlet |
出口组件 | 嵌套路由的子路由渲染占位符 |
useParams |
Hook | 获取 URL 中的动态参数(如 /user/:id 中的 id) |
useNavigate |
Hook | 编程式路由跳转(JS 逻辑中控制跳转) |
lazy |
React API | 组件懒加载声明 |
Suspense |
React API | 懒加载组件的 Loading 状态容器 |
五、路由基本配置
5.1 最小路由骨架
一个最基本的 React Router 配置由三层嵌套构成:HashRouter → Routes → Route。
jsx
// 最简示例(概念演示)
import { HashRouter, Routes, Route } from 'react-router-dom';
function App() {
return (
<HashRouter> {/* 第一层:路由容器 */}
<Routes> {/* 第二层:路由配置集合 */}
<Route path="/" element={<Home />} /> {/* 第三层:具体路由规则 */}
<Route path="/about" element={<About />} />
</Routes>
</HashRouter>
);
}
三层嵌套的职责:
- HashRouter :对浏览器说"这个区域内的路由由我接管",它启动
hashchange监听,维护当前路由状态 - Routes :相当于一个路由表,内部包含多条 Route。它会扫描所有 Route 的
path,有且只有一个 Route 被匹配 - Route :声明一条具体的路由规则。
path是匹配模式(对应location.hash的值),element是匹配成功后渲染的组件
一切皆组件的设计哲学:
源码注释中写道"路由配置数组------都是组件",这揭示了一个重要事实:React Router 的设计哲学与 React 一脉相承------一切皆组件 。HashRouter 是一个组件,Routes 是一个组件(路由配置数组),Route 是一个组件(路由配置项),Link 和 Navigate 也是组件。路由的声明和渲染都通过 JSX 组件标签完成,没有额外的概念需要学习------你会用 React 组件,就会用 React Router。
Routes 的双重身份:
源码注释中点出了一个关键观察:"动态页面切换部分------> 即是配置,又是出现的地方 "。<Routes> 组件承载了两种角色:
- 配置的角色:声明 path 到 element 的映射关系("当 URL 是 X 时显示 Y")
- 渲染的角色 :匹配成功后,当前页面的组件就在 Routes 所在的位置被渲染出来
这意味着你在 JSX 中放置 <Routes> 的位置,就是页面内容切换的位置。理解这个双重身份,你就能灵活控制布局------比如在 <Routes> 上方放一个导航栏,下方放一个页脚,中间的 <Routes> 负责动态切换内容。
5.2 导航组件:为什么用 Link 而不用 a 标签
项目中的 Navigation.jsx 专门说明了这一点:
jsx
// Navigation 组件 --- 全局导航栏
// a 标签点击后会触发完整页面跳转,导致浏览器重新加载
// react-router-dom 提供了更靠谱的 Link 组件------
// 它是专为 SPA 路由跳转设计的组件
import { Link } from 'react-router-dom';
function Navigation() {
return (
<nav>
<ul>
<li><Link to="/">Home</Link></li>
<li><Link to="/about">About</Link></li>
<li><Link to="/user/123">小家</Link></li>
<li><Link to="/products/123">产品详情</Link></li>
<li><Link to="/products/NewProduct">新增产品</Link></li>
</ul>
</nav>
);
}
export default Navigation;
"二次处理"困境------为什么 a 标签在 SPA 中行不通:
源码注释中精准点出:a 点击后会跳转,二次处理。这里的"二次处理 "是理解问题的关键------当用户在 SPA 中使用 <a> 标签时:
- 浏览器先执行第一次处理(默认行为):发起 HTTP 请求,刷新整页
- 开发者被迫做二次处理 :在
onClick中用e.preventDefault()阻止默认行为,再手动操作window.location.hash
jsx
// ❌ 如果非要用 a 标签做 SPA 导航------麻烦且脆弱
<a href="/about" onClick={(e) => {
e.preventDefault(); // 必须手动阻止默认跳转
window.location.hash = '#/about'; // 手动改 hash
}}>关于我们</a>
而 <Link> 组件内部已经帮你完成了这一切------它省掉了"二次处理",开发者只需要声明 to 属性。
<a> vs <Link> 的深入对比:
bash
<a href="/about">关于我们</a>
↓ 用户点击
↓ 浏览器发起 HTTP GET /about(第一次处理)
↓ 服务器返回完整 HTML
↓ 页面刷新,SPA 状态全部丢失
↓ 所有 JS 重新执行,React 重新初始化
↓ ❌ 完全破坏了 SPA 体验
<Link to="/about">关于我们</Link>
↓ 用户点击
↓ Link 拦截点击事件,调用 e.preventDefault()
↓ 仅修改 URL 的 hash 部分:/#/ → /#/about
↓ React Router 监听到 hash 变化
↓ Routes 重新匹配,渲染 <About /> 组件
↓ 不发起任何网络请求
↓ ✅ 完美的 SPA 体验
核心差异 :<a> 是浏览器的"原生行为"(跨文档导航),<Link> 是 React Router 的"SPA 行为"(同文档内的组件切换)。在 SPA 中,几乎所有导航都应该使用 <Link>。
5.3 页面组件约定
项目中所有页面级别的组件都放在 pages 目录下,每个页面占用一个独立目录。这是一种主流的目录约定:
bash
pages/
├── Home/ ← / 首页
├── About/ ← /about 关于页
├── UserProfile/ ← /user/:id 用户页(动态路由)
├── Products/
│ ├── index.jsx ← /products 产品列表(父路由)
│ ├── ProductDetail/ ← /products/:productId(子路由)
│ └── NewProduct/ ← /products/new(子路由)
└── NotFound/ ← 404 兜底页面
目录约定背后的思想:
/pages或/views目录专门存放页面级组件,区分于/components中的可复用组件- 每个页面一个目录,便于后续添加页面专属的样式、测试、子组件
"页面级别组件"的层级概念:
源码注释中明确写道"页面级别组件------级别比单纯的构成页面组件"。这句看似简单的话揭示了 React 应用中一个重要的组件层级划分:
bash
组件层级金字塔:
┌──────────────┐
│ 页面组件 │ ← 最高层:代表一个完整的"页面状态"
│ /pages/* │ 被路由直接匹配,通常由多个 UI 组件组合而成
├──────────────┤
│ UI 组件 │ ← 中间层:可复用的功能模块(导航栏、表单、列表...)
│ /components/* │ 不直接对应路由,被页面组件引用
├──────────────┤
│ 基础组件 │ ← 底层:按钮、输入框、卡片等原子组件
│ /ui/* │ 纯 UI 展示,无业务逻辑
└──────────────┘
页面组件与普通 UI 组件的本质区别:
| 维度 | 页面组件(Page) | UI 组件(Component) |
|---|---|---|
| 级别 | 更高层,"级别"更大 | 底层,"构成页面"的砖瓦 |
| 与路由关系 | 被 Route 直接匹配 | 不被路由直接引用 |
| 职责 | 编排布局、组合子组件 | 单一功能(导航、输入、列表) |
| 目录位置 | /pages 或 /views,每个文件独立目录 |
/components,按功能分组 |
| 典型代码量 | 较少(主要做编排) | 较多(封装具体逻辑) |
这种层级划分不仅利于代码组织,在团队协作中也至关重要------不同层级由不同开发者维护,职责边界清晰。页面级组件的代码通常非常简洁,因为它的职责是编排 而非实现:
jsx
// Home 页面组件 --- 被路由 "/" 匹配
// 页面级别组件------级别高于普通的构成页面组件
// /views /pages 目录下,每个文件都是一个目录
function Home() {
return <>Home</>;
// 实际项目中,这里会编排多个 UI 组件:
// <HomeLayout>
// <Banner />
// <FeatureList />
// <Testimonials />
// </HomeLayout>
}
export default Home;
jsx
// About 页面组件 --- 被路由 "/about" 匹配
function About() {
return <>About</>;
}
export default About;
简单就是美------页面组件的职责是代表一种"页面状态"被路由匹配,具体的内容由内部组合的子组件实现。
5.4 路由渲染机制
Routes 的匹配逻辑是独占匹配(first-match-exclusive),而非遍历所有路由:
ini
用户访问 /#/about
Routes 开始匹配:
匹配 <Route path="/" ...> → path="/" 不匹配,跳过
匹配 <Route path="/about" ...> → ✅ 匹配成功,渲染 <About />
匹配 <Route path="/user/:id" ...> → 不再检查(已有匹配)
匹配 <Route path="*" ...> → 不再检查(已有匹配)
这种机制确保有且只有一个 Route 被渲染 ,避免了多个页面组件同时出现的混乱。<Route path="*"> 必须放在最后------它贪婪匹配所有路径,如果放在前面会导致后面的 Route 永远无法匹配。
六、路由懒加载:性能优化的杀手锏
6.1 问题:首屏加载过重
在一个真实的中大型 SPA 中,可能有几十甚至上百个页面。如果使用静态 import 全部导入:
jsx
// ❌ 静态导入:所有页面在首页一起下载和执行
import Home from './pages/Home';
import About from './pages/About';
import UserProfile from './pages/UserProfile';
import Products from './pages/Products';
import ProductDetail from './pages/Products/ProductDetail';
import NewProduct from './pages/Products/NewProduct';
import Settings from './pages/Settings';
import Dashboard from './pages/Dashboard';
// ... 二十个页面全部导入
这会导致:
- 首屏 JS Bundle 体积巨大,用户等待时间长
- 非当前页面代码也被下载和解析,浪费带宽和 CPU
- 用户可能永远不访问某些页面,但它们仍然被加载了
6.2 解决方案:lazy + Suspense
React 提供了两个关键 API 来解决这个问题:
lazy:将组件声明为"懒加载组件",仅在第一次被渲染时才下载其代码Suspense:在懒加载组件尚未下载完成时,展示一个 fallback UI(Loading 状态)
6.3 原理:动态 import()
懒加载的底层机制是 JavaScript 的动态 import():
javascript
// 静态导入(编译时确定,打包在一起)
import Home from './pages/Home';
// 动态导入(运行时按需加载,代码分割成独立 chunk)
const Home = lazy(() => import('./pages/Home'));
// ^^^^^^ import() 返回 Promise<Module>
// ^^^^ lazy() 将 Promise 包装为可被 Suspense 识别的组件
Vite/Webpack 在构建时会识别 import('./pages/Home') 这种动态导入语法,将 Home 页面的代码单独打包成一个 chunk 文件。只有当用户导航到首页时,浏览器才下载这个 chunk。
6.4 完整实现代码
jsx
// App 根组件 --- 懒加载的完整实现
import { lazy, Suspense } from 'react';
import {
HashRouter as Router,
Routes,
Route,
Navigate
} from 'react-router-dom';
import Navigation from './compontents/Navigation';
// ============================================================
// SPA 动态切换多个页面
// 如果使用静态 import,所有页面都会在首页下载和执行,
// 严重影响首页的加载速度。
// 我们只需要加载当前页面就好------其他页面按需加载。
// ============================================================
// import 函数------动态导入,每个页面独立打包为 chunk
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 Products = lazy(() => import('./pages/Products'));
const ProductDetail = lazy(() => import('./pages/Products/ProductDetail'));
const NewProduct = lazy(() => import('./pages/Products/NewProduct'));
const App = () => {
return (
<>
<Router> {/* 前端路由接管一切 */}
<Suspense fallback={<div>Loading...</div>}>
{/* Suspense 一定要包裹在 Router 中 */}
<Navigation />
<div id="container">
<Routes>
{/* 动态页面切换部分------既是路由配置,也是组件渲染的位置 */}
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/user/:id" element={<UserProfile />} />
{/* ↓ 多级路由:嵌套路由 */}
<Route path="products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
<Route path="/old-path" element={<Navigate replace to="/new-path" />} />
{/* ↓ * 贪婪匹配所有,放在最后作为 404 兜底 */}
<Route path="*" element={<NotFound />} />
</Routes>
</div>
</Suspense>
</Router>
</>
);
};
export default App;
代码执行流程示意:
bash
用户首次访问 /#/(首页):
→ Router 初始化,解析 hash → "/"
→ 只下载 Home 页面的 chunk + Navigation 组件
→ About、Products 等页面不会下载
→ 首屏加载速度大幅提升 ✅
用户点击导航到 /#/about:
→ hash 变化 → Router 匹配到 "/about"
→ 浏览器动态下载 About 页面的 chunk
→ Suspense 显示 fallback: <div>Loading...</div>
→ chunk 下载完成 → About 组件渲染,替换 Loading 状态
七、动态路由:一个组件,无尽内容
7.1 动态参数占位符
RESTful 思想要求"一个资源类型对应一种 URL 模式"。比如所有用户的详情页共享同一个 URL 模式 /user/:id,其中 :id 是动态参数占位符。
bash
/user/123 → UserProfile 组件,id = 123,显示用户 123 的信息
/user/456 → UserProfile 组件,id = 456,显示用户 456 的信息
/user/789 → UserProfile 组件,id = 789,显示用户 789 的信息
一个组件,适配无数数据------这就是动态路由的核心价值。不需要为每个用户创建独立的页面和路由配置。
7.2 useParams Hook
React Router 通过 useParams Hook 让组件获取 URL 中的动态参数:
jsx
// UserProfile 页面组件 --- 动态路由示例
import { useParams } from 'react-router-dom';
function UserProfile() {
// 怎么去拿 params?
// Hooks 思想------召之即来,挥之即去
let { id } = useParams();
// ^^ 解构出来的变量名必须与路由配置中的参数名一致
// 路由配置:<Route path="/user/:id" element={<UserProfile />} />
// ^^ 参数名
console.log(id); // 打印当前 URL 中的 id 值
return (
<>
<h2>User Profile {id}</h2>
</>
);
}
export default UserProfile;
Hooks 的设计哲学------"召之即来":
源码注释中概括 Hooks 思想为四个字:"召之即来"。这背后是 React Hooks 区别于传统编程范式的根本特征。传统的编程方式中,获取数据通常需要三个步骤:
markdown
传统方式: Hooks 方式:
1. 声明变量 ↓
2. 订阅数据源 const { id } = useParams();
3. 在合适的时机获取数据 ↑
4. 在合适的时机取消订阅 一行代码完成所有事
5. 清理资源
useParams() 体现的正是这种"召之即来"的哲学------你只需要在组件函数顶层调用它,它就会自动返回当前路由的参数。你不需要:
- 手动订阅路由变化事件
- 手动解析 URL 字符串
- 手动在组件卸载时取消订阅
Hooks 内部封装了这一切。你在组件函数的顶层"召之即来",React Router 在底层处理了订阅、更新和清理。整个 React Router 的 API 都贯彻了这种思想------useParams 如此,useNavigate 如此,useLocation 也是如此。
同样,产品详情页也使用动态路由:
jsx
// ProductDetail 页面组件 --- 嵌套路由子组件
import { useParams } from 'react-router-dom';
const ProductDetail = () => {
const { productId } = useParams();
return (
<>
<h1>产品详情 {productId}</h1>
</>
);
};
export default ProductDetail;
// 路由配置:<Route path=":productId" element={<ProductDetail />} />
// 注意这里是子路由,完整路径为 /products/:productId
7.3 实战示例
在实际项目中,useParams 获取到参数后,通常会用来请求后端 API 获取具体数据:
jsx
// 实际应用中的典型模式(概念演示)
import { useParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
function UserProfile() {
let { id } = useParams();
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${id}`) // 用动态参数请求 API
.then(res => res.json())
.then(data => setUser(data));
}, [id]); // id 变化时重新请求
if (!user) return <div>Loading...</div>;
return <h2>{user.name} 的个人主页</h2>;
}
八、嵌套路由:多级页面结构
8.1 什么是嵌套路由
现实中的页面结构往往有层级关系。比如产品模块:
bash
/products ← 产品列表页(父路由)
/products/123 ← 产品详情页(子路由)
/products/new ← 新增产品页(子路由)
这三个页面共享一个共同的"产品模块"的布局(比如侧边栏、顶部的产品分类导航等),但各自的内容区域不同。嵌套路由正是用来实现这种共享布局 + 子内容切换的需求。
8.2 Outlet:子路由的渲染出口
Outlet 组件是嵌套路由的关键------它标记了"子路由组件应该渲染在哪个位置":
jsx
// Products 页面组件 --- 嵌套路由的父路由
import { Outlet } from 'react-router-dom';
// ^^^^^^ 二级路由出口------子路由的组件渲染在这里
const Products = () => {
return (
<>
<h1>产品列表</h1> {/* 父路由的固定内容,所有子页面共享 */}
<Outlet /> {/* 子路由组件在这个位置渲染 */}
{/* ↑
当访问 /products/123 时,这里渲染 <ProductDetail />
当访问 /products/new 时,这里渲染 <NewProduct />
当访问 /products 时,这里什么都不渲染(无匹配子路由) */}
</>
);
};
export default Products;
8.3 完整嵌套示例
嵌套路由的配置方式和渲染效果:
jsx
// 路由配置(父路由中嵌套子路由)
<Route path="products" element={<Products />}>
{/* ^^^^^^^^ 父路由:提供共享布局(标题 + Outlet) */}
<Route path=":productId" element={<ProductDetail />} />
{/* ^^^^^^^^^^^^ 子路由:渲染在 <Outlet /> 位置 */}
<Route path="new" element={<NewProduct />} />
{/* ^^^^^^^ 子路由:渲染在 <Outlet /> 位置 */}
</Route>
访问不同 URL 时的渲染效果:
bash
访问 /#/products:
┌─────────────────────┐
│ <h1>产品列表</h1> │ ← Products 父组件
│ (Outlet 为空) │ ← 无匹配子路由
└─────────────────────┘
访问 /#/products/123:
┌─────────────────────┐
│ <h1>产品列表</h1> │ ← Products 父组件
│ 产品详情 123 │ ← ProductDetail 子组件(在 Outlet 位置)
└─────────────────────┘
访问 /#/products/new:
┌─────────────────────┐
│ <h1>产品列表</h1> │ ← Products 父组件
│ NewProduct │ ← NewProduct 子组件(在 Outlet 位置)
└─────────────────────┘
嵌套路由的设计优势:
- 共享布局复用:导航、侧边栏等公共部分写在父组件中,不需要在每个子页面重复
- 层级化路由结构:URL 的层级与组件树的层级对应,逻辑清晰
- 独立加载:子路由页面可以各自懒加载,不影响其他子页面
九、404 Not Found 与路由重定向
9.1 通配符 * 兜底匹配
当用户访问一个不存在的路径时(例如 /#/nonexistent-page),需要有优雅的降级处理。<Route path="*"> 使用通配符 * 进行贪婪匹配------它能匹配所有未被前述 Route 匹配的路径。
关键原则 :path="*" 的 Route 必须放在所有 Route 的最后。如果放在前面,它贪婪匹配所有路径,导致后面的 Route 永远不会被匹配到。
jsx
// NotFound 页面组件 --- 404 兜底页面
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
// ^^^^^^^^^^^ 编程式路由跳转 Hook
const NotFound = () => {
let navigate = useNavigate();
useEffect(() => {
setTimeout(() => {
// ⚠️ 旧方式(被注释掉):
// window.location.href = '/';
// ↑ 传统做法:直接给 window.location 赋值
// 会导致浏览器发起整页刷新,SPA 状态全丢,所有 JS 重新初始化
//
// ✅ 新方式(React Router 方式):
navigate('/');
// ↑ SPA 内部跳转,不触发整页刷新,不丢失应用状态
}, 3000);
}, []);
return <>404 Not Found</>;
};
window.location.href vs navigate()------两种跳转方式的本质区别:
源码中保留了一行被注释掉的 window.location.href='/',这行代码是过去的遗迹,也正好诠释了前端路由的价值。两种跳转方式在底层行为上有根本差异:
markdown
window.location.href = '/' navigate('/')
─────────────────────────────────────────────────────
浏览器层面: React Router 层面:
1. 发起 HTTP GET / 1. 修改 URL hash
2. 服务器返回 index.html 2. 触发 hashchange 事件
3. 解析 HTML、下载 CSS/JS 3. Routes 重新匹配
4. 执行所有 JS、初始化 React 4. 渲染匹配的组件
5. 重新创建整个组件树 5. 保留现有组件树中不变的部分
─────────────────────────────────────────────────────
结果:整页刷新 结果:局部更新
耗时:数百毫秒到数秒 耗时:毫秒级
SPA 状态:全部丢失 SPA 状态:完整保留
用户体验:白屏闪烁 用户体验:丝滑切换
这条注释的存在本身就说明了技术的演进 ------曾经开发者只能用 window.location.href 做跳转(体验差),现在用 navigate() 做 SPA 内跳转(体验好)。在 React Router 项目中,永远优先使用 navigate() 而非 window.location.href,除非你明确需要离开这个 SPA 应用。
用户体验设计:404 页面在 3 秒后自动跳转回首页,给用户足够时间意识到访问了错误地址,同时主动引导回正常页面。
9.2 编程式导航:useNavigate
useNavigate 是 React Router v6+ 引入的编程式导航 Hook,取代了旧版的 useHistory。它适用于需要在 JavaScript 逻辑中控制跳转的场景,而非用户点击链接。
jsx
import { useNavigate } from 'react-router-dom';
function MyComponent() {
let navigate = useNavigate();
const handleSubmit = async () => {
await saveData(); // 保存数据
navigate('/success'); // 保存成功后跳转
};
const handleTimeout = () => {
navigate('/', { replace: true }); // replace 替换当前历史记录
};
return (/* ... */);
}
navigate 的第二个参数是一个可选的配置对象,常用选项:
| 选项 | 类型 | 说明 |
|---|---|---|
replace |
boolean |
true 时替换当前历史记录条目(用户无法后退回来) |
state |
any |
传递状态给目标页面(可通过 useLocation 获取) |
9.3 声明式重定向:Navigate 组件
除了编程式的 useNavigate,react-router-dom 还提供了声明式的 <Navigate> 组件------渲染即跳转。
jsx
// <Navigate> 组件:一旦渲染,立即触发路由跳转
<Route path="/old-path" element={<Navigate replace to="/new-path" />} />
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// 渲染 <Navigate> 时,立刻导航到 /new-path
// replace 表示替换(不保留旧路径的历史记录)
useNavigate vs <Navigate> 的选择:
| 场景 | 使用 |
|---|---|
| 用户点击按钮 → 跳转 | <Link to="..."> |
| JS 逻辑中条件判断 → 跳转 | useNavigate() |
| 路由配置中 A 路径 → B 路径 | <Navigate> |
| 定时器、异步回调 → 跳转 | useNavigate() |
十、项目完整架构与目录规范
10.1 目录结构
arduino
项目根目录/
├── readme.md ← 路由知识总结
├── package.json ← 项目配置(React 19 + react-router-dom v7)
├── index.html ← 入口 HTML(<div id="root">)
├── vite.config.js ← Vite 构建配置
├── eslint.config.js ← ESLint 代码规范
├── public/
│ ├── favicon.svg
│ └── icons.svg
└── src/
├── main.jsx ← 应用入口(createRoot + render)
├── App.jsx ← 根组件(Router + 全部路由配置)
├── App.css
├── index.css
├── compontents/
│ └── Navigation.jsx ← 全局导航栏组件(Link 跳转)
└── pages/ ← 页面级组件目录
├── Home/ ← 首页
├── About/ ← 关于页
├── UserProfile/ ← 用户详情(动态路由示例)
├── Products/
│ ├── index.jsx ← 产品列表(嵌套路由父组件)
│ ├── ProductDetail/ ← 产品详情(嵌套路由子组件)
│ └── NewProduct/ ← 新增产品(嵌套路由子组件)
└── NotFound/ ← 404 页面(通配符兜底)
10.2 路由配置总览
将项目中所有路由配置汇总在一起,形成完整的路由表:
bash
┌──────────┬──────────────────────┬───────────────────────┬──────────────────┐
│ URL Hash │ 路由类型 │ 渲染组件 │ 说明 │
├──────────┼──────────────────────┼───────────────────────┼──────────────────┤
│ / │ 基础路由 │ <Home /> │ 首页 │
│ /about │ 基础路由 │ <About /> │ 关于页 │
│ /user/:id│ 动态路由 │ <UserProfile /> │ 用户详情(懒加载)│
│ /products│ 嵌套路由(父) │ <Products /> + Outlet │ 产品模块布局 │
│ /products/:productId│ 嵌套路由(子)│ <ProductDetail /> │ 产品详情(懒加载)│
│ /products/new│ 嵌套路由(子) │ <NewProduct /> │ 新增产品(懒加载)│
│ /old-path│ Navigate 重定向 │ → /new-path │ 旧路径跳转到新路径│
│ * │ 通配符匹配(404兜底) │ <NotFound /> │ 未匹配路径统一处理│
└──────────┴──────────────────────┴───────────────────────┴──────────────────┘
十一、API 速查表与总结
11.1 核心 API 速查表
| API | 来源模块 | 类型 | 用途描述 |
|---|---|---|---|
HashRouter |
react-router-dom | 容器组件 | SPA 路由根节点,基于 location.hash 实现 |
Routes |
react-router-dom | 容器组件 | 路由配置集合,包裹所有 <Route> |
Route |
react-router-dom | 配置组件 | 单条路由规则:path 匹配 URL,element 渲染组件 |
Link |
react-router-dom | 导航组件 | SPA 声明式导航(自动阻止默认跳转行为) |
Navigate |
react-router-dom | 导航组件 | 声明式路由跳转,渲染即触发跳转 |
Outlet |
react-router-dom | 出口组件 | 嵌套路由中子路由的渲染占位符 |
useParams |
react-router-dom | Hook | 获取动态路由参数(如 :id 的值) |
useNavigate |
react-router-dom | Hook | 编程式路由跳转函数 |
lazy |
react | API | 声明组件为懒加载(接受 () => import()) |
Suspense |
react | 容器组件 | 懒加载组件的 Loading 状态管理 |
11.2 设计原则回顾
- RESTful 一切皆资源:URL 即资源的标识,前端路由是资源切换的机制
- Hash 不刷新页面:HashRouter 利用的是浏览器核心特性------hash 变化不发起网络请求
- 一切皆组件:React Router 的设计哲学------Routes 是组件,Route 是组件,Link 和 Navigate 也是组件,没有额外的概念需要学习
- "即是配置,又是出现的地方" :
<Routes>具有双重身份------既声明路由映射关系,又是页面组件渲染的实际位置 - 懒加载优先:非当前页面代码不加载,保证首屏速度(性能优化的基本准则)
- "召之即来"的 Hooks 哲学 :
useParams、useNavigate等 Hook 封装了订阅、更新、清理的全过程,一行代码完成所有事 - 页面组件与 UI 组件的层级区分:页面组件"级别更高",负责编排布局;UI 组件负责具体功能实现
- 避免"二次处理" :
<Link>替代<a>标签,navigate()替代window.location.href,消除不必要的中间处理步骤 - 一个组件匹配无尽内容:动态路由让同一组件适配不同的参数
- 层级对应:嵌套路由让 URL 层级与组件树层级一一对应
- 404 兜底 :通配符
*必须放在最后,确保所有未匹配路径有优雅降级
11.3 路由学习路径建议
bash
第一阶段:理解路由概念
├── 什么是路由?为什么需要路由?
├── RESTful 资源思想
└── 前端路由 vs 后端路由的区别
第二阶段:掌握 Hash 原理
├── URL 结构(协议、域名、路径、hash)
├── hashchange 事件
└── 为什么 hash 不会刷新页面
第三阶段:React Router 基础
├── HashRouter → Routes → Route 三层结构
├── Link 替代 a 标签
└── 基础页面跳转
第四阶段:进阶特性
├── 路由懒加载(lazy + Suspense)
├── 动态路由(:id + useParams)
├── 嵌套路由(Outlet)
└── 404 + Navigate 重定向
第五阶段:生产实践
├── 路由守卫(权限控制)
├── BrowserRouter vs HashRouter
├── 路由级代码分割策略
└── 面包屑导航与路由元信息
本文基于 readme.md 的核心内容编写,所有代码均来自演示项目。每个代码片段都保留了原始注释,以准确传达编写者的设计意图。