
一、核心结论(金字塔塔尖)
React Router 的本质是用 JavaScript 接管浏览器的 URL 变化,在不刷新页面的前提下,实现多个"页面"之间的无缝切换------这就是 SPA(单页应用)的前端路由机制。它将传统后端路由的"URL → 服务端渲染新页面"模式,转变为"URL → 客户端匹配组件 → 局部更新 DOM"模式,从而消除页面白屏,带来接 近原生应用的流畅体验。


本项目基于 Vite 8 + React 19 + react-router-dom v7.18 ,从一个 create-vite 脚手架项目开始,逐步集成前端路由,完整实现了八大核心能力:Hash 路由策略、声明式路由配置表、动态路由参数、嵌套路由(含二级子路由)、路由重定向、404 通配兜底、React.lazy() 懒加载优化、以及 useParams 参数传递。下面自顶向下,从"为什么会有前端路由"这个根本问题出发,到每一行代码的细节,逐层拆解。
二、历史演进:为什么需要前端路由?(金字塔第二层------背景与动机)
2.1 传统后端路由模式(多页应用 MPA)
在 Ajax 和前端框架兴起之前,Web 应用清一色采用后端路由 架构。所谓后端路由,指的是:URL 路径的解析和页面的渲染全部在服务端完成。
css
用户点击 <a href="/user/123">用户详情</a>
│
▼
浏览器向服务器发起 GET /user/123 请求
│
▼
服务器接收请求 → 解析 URL → 查询数据库 → 渲染 HTML 模板 → 返回完整 HTML 页面
│
▼
浏览器接收到新 HTML → 白屏 → 重新解析 → 重新渲染 → 页面显示
这个过程有三个致命缺陷:
- 白屏问题:每次页面跳转,浏览器都要丢弃当前 DOM 树,重新构建一切。在网速慢或页面复杂时,用户会看到明显的白色闪烁(Flash of White)。
- 重复传输:每个页面的 HTML 都包含相同的头部、导航栏、底部等公共部分,这些冗余数据在每次跳转时都被重新下载。
- 状态丢失:用户在页面 A 上填了一半的表单、展开的菜单、滚动的位置,一点链接跳转到页面 B 再返回,全部丢失(除非手动做持久化)。
这就是 readme.md 中提到的:"以前是要后端路由支持的,传统,慢,白一下,体验不好。"
2.2 RESTful 思想与"一切皆资源"
在讨论前端路由之前,有必要理解 RESTful(Representational State Transfer)架构风格。readme.md 第一行就指出:"restful 一切皆资源"。
RESTful 的核心主张是:URL 不应该是"动词",而应该是"名词"------每个 URL 代表一个资源(Resource),HTTP 方法(GET/POST/PUT/DELETE)代表对资源的操作。
bash
❌ 传统 URL 设计(动词导向,面向操作):
/getUser?id=123
/deleteProduct?id=456
/createOrder
✔ RESTful URL 设计(名词导向,面向资源):
GET /user/123 → 获取用户 123 的信息
PUT /user/123 → 更新用户 123 的信息
DELETE /user/123 → 删除用户 123
POST /user → 创建新用户
GET /product/456 → 获取产品 456 的信息
为什么 RESTful 思想和前端路由有密切关系?因为 React Router 的路径参数设计(/user/:id)天然就是在 URL 中表达"资源" 。/user/123 中的 123 不是某个操作名,而是资源的唯一标识符。前端路由的 URL 结构天然就符合 RESTful 规范------每个路径对应一个页面视图,路径参数对应资源的 ID。
2.3 前后端分离与 SPA 的诞生
随着 Ajax 技术的成熟,一种新的架构出现了:前后端分离。
css
传统模式:服务端 = 路由 + 业务逻辑 + HTML 渲染
分离模式:服务端 = 纯数据 API(JSON),前端 = 路由 + UI 渲染
在这个新架构下,浏览器首先加载一个几乎为空的 index.html 和一个打包了所有逻辑的 app.js。之后的一切页面切换,全部由 JavaScript 在客户端完成。这就是 SPA(Single Page Application,单页应用)------只有一张 HTML 页面,所有"页面切换"本质上都是 JavaScript 对 DOM 的局部替换。
SPA 的核心技术就是前端路由。
2.4 Hash 路由的原理:锚链接 + hashchange 事件
readme.md 中写道:"hash 锚链接,改变 url hash 部分不会刷新页面,hashchange"。这十八个字,精确概括了 Hash 路由的底层原理。
让我们一步步拆解:
第一步:理解 URL 中 # 的原始用途
在 HTML 设计之初,# 用于"页内锚点"(anchor)。例如:
html
<a href="#section2">跳到第二节</a>
...
<h2 id="section2">第二节</h2>
点击这个链接,浏览器不会向服务器发送任何请求,而是直接在当前页面内滚动到 id="section2" 的元素位置。URL 的 # 及之后的部分(称为 fragment/hash)永远不会被发送到服务器。
验证一下:在浏览器控制台执行 location.hash:
bash
当前 URL: http://localhost:5173/#/user/123
执行 location.hash → "#/user/123"
执行 location.href → "http://localhost:5173/#/user/123"
发送给服务器的请求 → GET http://localhost:5173/ (# 后面全部被浏览器截断)
第二步:hashchange 事件
HTML5 提供了 hashchange 事件------当 URL 的 hash 部分发生变化时,浏览器会触发这个事件。关键点:hash 变化不会导致页面刷新。
javascript
// 纯原生 JS 的最小 Hash 路由实现(共 6 行)
window.addEventListener('hashchange', () => {
const path = location.hash.slice(1) || '/'; // 去掉开头的 #
console.log('当前路由:', path);
// 根据 path 决定显示哪个"页面"
});
// 触发路由变化
location.hash = '#/about'; // URL 变为 /#/about,页面不刷新,触发 hashchange
第三步:React Router 的封装
HashRouter 在底层做的就是上面这件事,但它将原生的 hashchange 事件封装进了 React 的响应式系统中:
javascript
// HashRouter 内部简化伪代码(react-router-dom 源码的核心逻辑)
function HashRouter({ children }) {
const [location, setLocation] = useState(() => ({
pathname: window.location.hash.slice(1) || '/',
}));
useEffect(() => {
const handleHashChange = () => {
setLocation({
pathname: window.location.hash.slice(1) || '/',
});
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
// 将 location 通过 Context 向下传递
return (
<RouterContext.Provider value={{ location, navigate }}>
{children}
</RouterContext.Provider>
);
}
核心链路总结:
ini
location.hash = '#/about'
│
▼
浏览器触发 hashchange 事件
│
▼
HashRouter 的 useEffect 中的 handleHashChange 执行
│
▼
调用 setLocation() 更新 React state
│
▼
React 重新渲染 → Routes 组件用新的 pathname 重新匹配
│
▼
匹配到 <Route path="/about" element={<About />} /> → 渲染 <About />
│
▼
DOM 局部更新,页面没有刷新!
2.5 Hash 路由 vs History 路由的完整对比
| 维度 | HashRouter (#/path) |
BrowserRouter (/path) |
|---|---|---|
| URL 形式 | a.com/#/user/123 |
a.com/user/123 |
| 底层 API | location.hash + hashchange 事件 |
HTML5 History API:pushState() / replaceState() + popstate 事件 |
| 服务器请求 | # 后面永远不发往服务器,刷新安全 |
刷新时浏览器会把完整路径发给服务器,需服务端配置 fallback |
| 服务端配置 | 零配置 | 必须配置 rewrite 规则(如 Nginx try_files),否则刷新 404 |
| SEO | 较差(搜索引擎通常忽略 # 后的内容) |
较好(配合 SSR/SSG 可实现完整 SEO) |
| URL 美观度 | 有 # 符号,略显"脏" |
干净,接近传统多页应用的 URL |
| 兼容性 | 极好(IE8+) | IE10+ |
| 适用场景 | 后台管理系统、内部工具、Demo 项目 | 面向用户的 C 端产品(配合 SSR) |
本项目选用 HashRouter 的原因 :作为 Demo 教学项目,Hash 路由无需任何服务端配置,npm run dev 后即可正常工作,且天然防止刷新 404。在生产环境中,面向用户的产品通常会切换到 BrowserRouter。
三、架构全景(金字塔第三层------系统设计)
3.1 三大支柱
整个 React Router 体系由三根支柱撑起,缺一不可:
| 支柱 | 对应组件/API | 职责 |
|---|---|---|
| 路由容器 | HashRouter(本项目)/ BrowserRouter |
监听 URL 变化,管理路由状态,通过 React Context 向下传递 |
| 路由配置表 | Routes + Route |
声明式定义 URL 路径与页面组件的映射关系 |
| 导航系统 | Link / NavLink / Navigate / useNavigate / useParams |
触发路由跳转、传递 URL 参数、获取当前路由信息 |
3.2 项目目录结构与职责划分
ini
react-router/ ← Vite 脚手架生成的根目录
│
├── index.html ← 单页应用的唯一 HTML 文件
│ └── <div id="root"></div> ← React 的挂载点(空壳)
│ └── <script src="/src/main.jsx"> ← 应用入口
│
├── package.json ← 项目元数据 + 依赖声明
│ ├── react: ^19.2.7 ← UI 框架
│ ├── react-dom: ^19.2.7 ← DOM 渲染器
│ ├── react-router-dom: 7.18.2 ← 前端路由库
│ └── vite: ^8.1.1 ← 构建工具
│
├── vite.config.js ← Vite 配置(插件系统)
│ └── plugins: [react()] ← @vitejs/plugin-react(使用 Oxc 编译器)
│
└── src/ ← 所有源代码
├── main.jsx ← 应用启动入口:createRoot + render
├── App.jsx ← ★ 路由配置核心(最重要的文件)
├── App.css ← App 级别样式
├── index.css ← 全局样式(CSS 变量、暗色模式、响应式)
│
├── component/ ← 共享组件
│ └── Navigation.jsx ← 全局导航栏(Link 组件使用示例)
│
├── pages/ ← 页面级别组件(每个路由对应一个)
│ ├── Home/index.jsx ← 首页 → path="/"
│ ├── About/index.jsx ← 关于页 → path="/about"
│ ├── User/index.jsx ← 用户详情页 → path="/user/:id"
│ └── NotFound/index.jsx ← 404 页面 → path="*"
│
└── Products/ ← 产品模块(展示嵌套路由)
├── index.jsx ← 产品列表(父路由组件,含 <Outlet />)
├── ProductDetail.jsx ← 产品详情(子路由) → path=":productId"
├── Detail/index.jsx ← 产品详情(备选实现)
└── New/index.jsx ← 新增产品(子路由) → path="new"
3.3 组件层级树与数据流向
ini
index.html
└── <div id="root">
└── main.jsx: createRoot(root).render(
└── <StrictMode>
└── <App /> ← 一切的起点
└── <HashRouter> ← 路由容器,提供 location context
└── <Suspense fallback="等等我呗..."> ← 懒加载边界
├── <Navigation /> ← 导航栏(Link 组件)
└── <div id="container">
└── <Routes> ← 路由匹配引擎
├── <Route path="/" → <Home />>
├── <Route path="/about" → <About />>
├── <Route path="/user/:id" → <User />>
├── <Route path="/products" → <Products />>
│ ├── <Route path=":productId" → <ProductDetail />>
│ └── <Route path="new" → <NewProduct />>
├── <Route path="/old-path" → <Navigate />>
└── <Route path="*" → <NotFound />>
数据流方向:
- 自上而下(单向数据流) :HashRouter 通过 React Context 向下传递
location、navigate等路由状态 - 自下而上(Hooks 读取) :子组件通过
useParams()、useLocation()等 Hooks 消费路由状态 - 横向触发(事件驱动):用户点击 Link 或调用 navigate() → 修改 URL → hashchange 事件 → HashRouter 更新 state → React 重渲染
四、八大核心机制逐行拆解(金字塔第四层------最详细的代码分析)
4.1 HashRouter:前端路由的根容器
4.1.1 代码
jsx
// App.jsx 第 5-11 行
import {
HashRouter as Router, // 将 HashRouter 取别名为 Router
Routes, // 路由配置数组容器
Route, // 单条路由规则
Navigate, // 重定向组件
} from 'react-router-dom';
这里用了 as Router 别名导入。好处是:如果将来需要从 Hash 模式切换到 History 模式,只需要改一个单词:
jsx
// 从 Hash 模式切换到 History 模式,只改一行
import { BrowserRouter as Router } from 'react-router-dom';
// ^^^^^^^^^^^^^ 其余代码完全不变
jsx
// App.jsx 第 31 行
<Router>
{/* 所有路由相关组件必须放在 Router 内部,否则无法访问路由 Context */}
</Router>
4.1.2 Router 的底层实现细节
前面 2.4 节展示了 HashRouter 的简化伪代码。这里深入补充几个关键细节:
(1)为什么 Router 必须包裹在所有路由组件的最外层?
因为 HashRouter 内部使用了 React Context 来向下传递路由信息。Routes、Route、Link、useParams 等所有 react-router-dom 的组件和 Hooks,都依赖这个 Context。如果把它们放在 Router 外面,会直接报错:
css
Error: useLocation() may be used only in the context of a <Router> component.
(2)HashRouter 传递的 Context 里到底有哪些数据?
javascript
// HashRouter 提供的 Context 数据结构(简化版)
{
location: {
pathname: "/user/123", // 当前路径(去掉 # 号)
search: "?tab=profile", // 查询字符串
hash: "", // hash 中的 hash(极少用)
state: null, // 路由状态(通过 navigate 传递的额外数据)
key: "abc123", // 唯一标识,用于区分同一路径的不同访问
},
navigate: function(to, options) {
// options: { replace, state, ... }
// 内部调用 location.hash = '#/xxx' 或 history.pushState()
},
params: {}, // URL 参数(:id 等,由嵌套的 Route 匹配后填充)
}
(3)初始化时的路由解析
当用户首次打开页面时,HashRouter 的初始化流程:
ini
1. 读取 window.location.hash
例如: "#/user/123" → pathname = "/user/123"
如果 hash 为空 → pathname = "/"
2. 将解析出的 pathname 存入 React state
3. 注册 hashchange 事件监听
4. 首次渲染时,将 { location, navigate } 放入 Context
5. 子组件 Routes 拿到 location.pathname = "/user/123"
开始匹配 <Route path="/user/:id"> → 命中 → 提取参数 { id: "123" }
渲染 <User />,并将 { id: "123" } 传给 useParams()
4.2 Routes + Route:声明式路由配置表(全系统最核心的部分)
4.2.1 完整代码及逐行注释
jsx
// App.jsx 第 36-56 行
<Routes>
{/* ① 根路径 "/" ------ 首页 */}
<Route path="/" element={<Home />} />
{/* ② 静态路径 "/about" ------ 关于页面 */}
<Route path="/about" element={<About />} />
{/* ③ 动态路径 "/user/:id" ------ 用户详情页 */}
{/* 冒号 :id 表示这是一个动态参数段,匹配任意值 */}
<Route path="/user/:id" element={<User />} />
{/* ④ 嵌套路由 "/products" ------ 这是父路由 */}
<Route path="/products" element={<Products />}>
{/* ④a 子路由:/products/123 → 产品详情 */}
{/* 注意:子路由的 path 是相对路径,不需要写 /products/ 前缀 */}
<Route path=":productId" element={<ProductDetail />} />
{/* ④b 子路由:/products/new → 新增产品 */}
<Route path="new" element={<NewProduct />} />
</Route>
{/* ⑤ 重定向路由 "/old-path" ------ 旧 URL 永久跳转到新 URL */}
{/* replace 表示替换历史记录,用户点"后退"不会回到此路径 */}
<Route path="/old-path" element={
<Navigate replace to="/products/new" />
} />
{/* ⑥ 通配路由 "*" ------ 404 兜底,必须放在最后 */}
{/* 星号 * 是贪婪匹配,匹配前面所有路由都未命中的任意路径 */}
<Route path="*" element={<NotFound />} />
</Routes>
4.2.2 Routes 的匹配算法
Routes 内部不只是一个简单的遍历,它有一套完整的匹配优先级:
lua
匹配规则优先级(从高到低):
1. 精确匹配优先
路径 "/about" 只匹配 "/about",不匹配 "/about/"(strict mode)
路径 "/" 只匹配 "/",不匹配 "/about"
2. 静态段优先于动态段
"/products/new" 优先于 "/products/:productId"
当 URL = "/products/new" 时,匹配到 path="new" 而非 path=":productId"
因为 "new" 是静态字符串,":productId" 是通配符
3. 动态段 (:id) 优先于通配符 (*)
"/user/:id" 优先于 "/user/*"
4. 路径深度越深,优先级越高
"/a/b/c" 优先于 "/a/b"
5. * 兜底:只有前面所有路由都不匹配时,才命中 *
实际匹配示例:
ini
URL = "/products/new"
Routes 的匹配过程:
├── path="/" → 不匹配(路径太短)
├── path="/about" → 不匹配(路径不同)
├── path="/user/:id" → 不匹配(前缀不同)
├── path="/products" → 匹配!(前缀匹配)
│ ├── path=":productId" → 不匹配(前缀之后的部分是 "new",但这里先检查)
│ └── path="new" → ✔ 匹配!精确匹配
│ 最终渲染:<Products /> 包裹 <NewProduct />
└── (找到匹配,停止检查后续路由)
4.2.3 Route 的 element 属性详解
jsx
// element 属性接收一个 React 元素(JSX),不是组件本身
<Route path="/" element={<Home />} />
// ^^^^^^^^ 这是 <Home /> JSX 元素,不是 Home 函数
// ❌ 错误写法:
<Route path="/" element={Home} /> // Home 是函数,不是 JSX 元素
<Route path="/" component={Home} /> // react-router v5 的旧语法,v6+ 已移除
// ✔ 正确写法:
<Route path="/" element={<Home />} />
<Route path="/" element={<Home title="首页" />} /> // 可以直接传 props
为什么 v6 改成 element 而不是 v5 的 component?
主要原因是 element 更灵活:你可以直接传一个内联 JSX,不局限于组件;你可以随时传入不同的 props;它更符合 React 的"一切都是 JSX 元素"哲学。
4.3 动态路由参数::id 语法与 useParams Hook
4.3.1 :参数名 语法详解
动态路由参数是前端路由模仿 RESTful 风格的关键设计。在路径中用冒号定义"变量的位置":
bash
路径模式 匹配的 URL 示例 提取的参数
/user/:id /user/123 { id: "123" }
/user/:id /user/xiao { id: "xiao" }
/product/:productId /product/42 { productId: "42" }
/blog/:year/:month /blog/2024/03 { year: "2024", month: "03" }
/user/:id/profile /user/123/profile { id: "123" }
语法细节:
:后的标识符就是参数的键名(key),匹配到的值就是键值(value)- 参数值默认为非空字符串 ,匹配 URL 路径中两个
/之间的部分 - 可以在一个路径中使用多个参数:
/user/:userId/post/:postId - 参数只能匹配路径的一个"段"(segment),即两个
/之间的内容 - 如果需要匹配多个段,使用
*:/files/*匹配/files/a/b/c
4.3.2 useParams 的完整用法
jsx
// src/pages/User/index.jsx(共 14 行)
import { useParams } from 'react-router-dom';
function User() {
const { id } = useParams();
// useParams() 返回一个对象,包含当前 URL 匹配到的所有动态参数
// URL = /user/123 → useParams() = { id: "123" }
// URL = /user/abc → useParams() = { id: "abc" }
return (
<>
<h2>User 用户</h2>
<p>用户 ID: {id}</p>
</>
);
}
export default User;
jsx
// src/Products/ProductDetail.jsx(共 13 行)------ 嵌套路由中的 useParams
import { useParams } from 'react-router-dom';
function ProductDetail() {
const { productId } = useParams();
// URL = /products/42 → useParams() = { productId: "42" }
// 注意:参数名来自 Route 的 path=":productId",不是 path="/products/:productId"
return (
<>
<h3>产品详情</h3>
<p>产品 ID: {productId}</p>
</>
);
}
export default ProductDetail;
4.3.3 useParams 的实现原理
javascript
// useParams 的简化源码逻辑
function useParams() {
// 从最近的 Route Context 中读取匹配结果
const match = useContext(RouteContext);
// match.params 是在路由匹配阶段,由路径匹配器填充的
// 例如 URL "/user/123" 匹配到 path="/user/:id"
// 匹配器通过正则提取出 id="123",存入 match.params = { id: "123" }
return match.params;
}
关键点 :useParams 读取的是离当前组件最近的 <Route> 匹配结果。在嵌套路由场景中,子组件读取的是子 Route 的 params,父组件读取的是父 Route 的 params,互不干扰。
jsx
// 在 Products 组件内部(父路由组件)
function Products() {
const params = useParams(); // {}
// 父路由的 path 是 "/products",没有动态参数,所以是空对象
}
// 在 ProductDetail 组件内部(子路由组件)
function ProductDetail() {
const params = useParams(); // { productId: "42" }
// 子路由的 path 是 ":productId",匹配到了参数
}
4.3.4 动态参数与实际业务场景
在实际项目中,useParams 通常结合 useEffect 来根据 URL 参数请求数据:
jsx
// 实际业务中的常见模式(本项目未实现,但值得了解)
import { useParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
function UserProfile() {
const { id } = useParams();
const [user, setUser] = useState(null);
useEffect(() => {
// 当 URL 中的 id 变化时(例如从 /user/123 导航到 /user/456)
// useEffect 会重新执行,请求新用户的数据
fetch(`/api/user/${id}`)
.then(res => res.json())
.then(setUser);
}, [id]); // id 是依赖项,id 变了就重新请求
if (!user) return <div>加载中...</div>;
return <div>{user.name}</div>;
}
4.4 嵌套路由:父路由框架 + 子路由内容
4.4.1 为什么需要嵌套路由?
考虑一个产品管理模块的 UI 布局:
arduino
┌────────────────────────────────────────────┐
│ 产品管理 ← 标题栏(所有子页面共享) │
│ [产品列表] [新增产品] [详情] ← 导航 Tab(共享) │
├────────────────────────────────────────────┤
│ │
│ 这里是变化的内容区: │
│ - 点击"产品列表" → 显示产品表格 │
│ - 点击"新增产品" → 显示新增表单 │
│ - 点击"详情" → 显示产品详情 │
│ │
└────────────────────────────────────────────┘
如果没有嵌套路由,你需要在三个页面组件中各自写一遍标题栏和导航 Tab。有了嵌套路由,公共部分只写一次(在父组件中),变化部分由 <Outlet /> 动态替换。
4.4.2 完整代码实现
步骤一:父组件定义布局框架 + <Outlet /> 占位
jsx
// src/Products/index.jsx(共 16 行)
import { Outlet } from 'react-router-dom';
// Outlet 是 "出口/插座" 的意思,子路由匹配到的组件会渲染在这里
const Products = () => {
return (
<>
<h2>Products 产品列表</h2>
<p>这是产品列表内容</p>
<Outlet />
{/* ↑↑↑ 关键!子路由的内容就像插头一样插在这里 */}
</>
);
};
export default Products;
步骤二:父 Route 包裹子 Route
jsx
// App.jsx 第 43-47 行
<Route path="/products" element={<Products />}>
{/* 这两个子 Route 是 Products 组件 Route 的 children */}
{/* 它们的 path 都是相对于 /products 的 */}
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
步骤三:渲染效果对照
ini
URL = /products(没有子路由匹配时):
┌──────────────────────────────┐
│ Products 产品列表 │
│ 这是产品列表内容 │
│ <Outlet /> → 空(不渲染任何内容)│
└──────────────────────────────┘
URL = /products/new:
┌──────────────────────────────┐
│ Products 产品列表 │
│ 这是产品列表内容 │
│ ┌──────────────────────────┐ │
│ │ NewProduct 新产品 │ │ ← 这就是 <Outlet /> 渲染的内容
│ │ 这是新产品内容 │ │
│ └──────────────────────────┘ │
└──────────────────────────────┘
URL = /products/42:
┌──────────────────────────────┐
│ Products 产品列表 │
│ 这是产品列表内容 │
│ ┌──────────────────────────┐ │
│ │ 产品详情: 42 │ │ ← ProductDetail 组件渲染于此
│ └──────────────────────────┘ │
└──────────────────────────────┘
4.4.3 嵌套路由的匹配细节
ini
URL = "/products/new"
Routes 匹配过程:
├── path="/" → 路径是 "/products/new",不匹配
├── path="/about" → 不匹配
├── path="/user/:id" → 不匹配
├── path="/products" → ✔ 前缀匹配!路径以 "/products" 开头
│ ├── path=":productId" → "new" 虽然能匹配 :productId(任意字符串),
│ │ 但是 path="new" 是静态路径,精确匹配级别更高
│ │ 所以跳过此路由
│ └── path="new" → ✔ 完全匹配!渲染 <NewProduct />
│
└── 渲染结果:<Products><NewProduct /></Products>
Products 先渲染自己的 <h2> 和 <p>,然后在 <Outlet /> 处渲染 NewProduct
4.4.4 多层嵌套
嵌套路由可以不止两层。例如一个更复杂的场景:
jsx
<Route path="/dashboard" element={<Dashboard />}>
<Route path="settings" element={<Settings />}>
<Route path="profile" element={<ProfileSettings />} />
<Route path="security" element={<SecuritySettings />} />
</Route>
</Route>
javascript
URL: /dashboard/settings/profile
渲染层级:
<Dashboard>
<Settings>
<ProfileSettings /> ← 最终渲染在最内层 Outlet
</Settings>
</Dashboard>
每一层都只需在自己的组件中放置一个 <Outlet />,React Router 会自动处理层级关系。
4.5 路由重定向:<Navigate> 组件
4.5.1 代码与语法
jsx
// App.jsx 第 49-52 行
<Route path="/old-path" element={
<Navigate replace to="/products/new" />
} />
<Navigate> 是一个组件形式的指令。当它被渲染时,会立即触发路由跳转。它本质上等同于:
javascript
// Navigate 内部简化实现
function Navigate({ to, replace }) {
const navigate = useNavigate(); // 获取路由的 navigate 函数
useEffect(() => {
navigate(to, { replace }); // 执行跳转
}, [to, replace]);
return null; // 自身不渲染任何 DOM
}
4.5.2 replace 参数的含义深度解析
这是最容易被误解的参数。它涉及浏览器历史记录栈(History Stack)的概念:
csharp
场景:用户从 首页 点击链接到 /old-path,然后自动重定向到 /products/new
不使用 replace(push 模式)的历史栈:
[0] 首页
[1] /old-path ← 这一条被推入历史栈
[2] /products/new ← 重定向后又被推入一条
用户点"后退" → 回到 /old-path → 再次触发重定向 → 页面闪一下回到 /products/new
用户再次点"后退" → 回到首页
(用户被困在一个循环中,永远退不回首页)
使用 replace 的历史栈:
[0] 首页
[1] /products/new ← /old-path 的记录被替换掉了
用户点"后退" → 直接回到首页 ✔
因此,重定向路由几乎永远应该使用 replace,否则会造成浏览器后退按钮的陷阱。
4.5.3 useNavigate Hook:命令式跳转
除了 <Navigate> 组件(声明式),React Router 还提供了 useNavigate Hook(命令式):
jsx
import { useNavigate } from 'react-router-dom';
function LoginForm() {
const navigate = useNavigate();
const handleLogin = async () => {
const success = await loginAPI(username, password);
if (success) {
navigate('/dashboard', { replace: true }); // 登录成功后跳转
}
};
return <button onClick={handleLogin}>登录</button>;
}
声明式 vs 命令式:
<Navigate>:适合路由配置中的静态重定向(如旧 URL 迁移)useNavigate:适合事件处理中的动态跳转(如登录成功、表单提交后)
4.5.4 本项目中有两个 NotFound 组件
注意项目中有两个 NotFound 文件:
jsx
// src/pages/NotFound/index.jsx ------ 当前使用的(带 3 秒自动跳转)
import { useEffect } from 'react';
const NotFound = () => {
useEffect(() => {
setTimeout(() => {
window.location.href = '/'; // 3 秒后使用原生方式跳回首页
}, 3000);
}, []);
return (
<>
<h2>404 页面不存在</h2>
<p>你访问的页面走丢了</p>
</>
);
};
export default NotFound;
jsx
// src/NotFound/index.jsx ------ 备选版本(纯展示,无跳转)
const NotFound = () => {
return (
<div>
<h2>404 Not Found</h2>
<p>页面不存在</p>
</div>
);
};
export default NotFound;
两个实现的区别:pages/NotFound 版本使用 window.location.href = '/' 做了 3 秒自动跳回首页。这里使用的是原生 window.location.href 重新赋值而非 useNavigate,这实际上触发了一次完整的页面刷新(不是 SPA 无刷新跳转)。如果追求纯 SPA 体验,应该改为:
jsx
// 更符合 SPA 理念的写法(仅作说明,项目中使用的是上面那种)
import { useNavigate } from 'react-router-dom';
const NotFound = () => {
const navigate = useNavigate();
useEffect(() => {
const timer = setTimeout(() => navigate('/', { replace: true }), 3000);
return () => clearTimeout(timer); // 组件卸载时清除定时器
}, [navigate]);
return (...);
};
4.6 404 兜底:path="*" 通配路由
4.6.1 星号 * 的匹配语义
jsx
// App.jsx 第 55 行
<Route path="*" element={<NotFound />} />
* 表示"匹配任意路径",包括:
/random-page/user/123/profile/settings/anything/at/all- 甚至
/之后的所有可能路径
* 必须放在 <Routes> 的最后一个位置 。因为 Routes 的匹配是"找到第一个匹配的 Route 就停止",如果 * 放在前面,它会吞掉所有后续路由。
jsx
// ❌ 错误:* 在前面会导致 /about、/user 等永远无法命中
<Routes>
<Route path="*" element={<NotFound />} /> // 第一个,匹配一切
<Route path="/" element={<Home />} /> // 永远不会被匹配
<Route path="/about" element={<About />} /> // 永远不会被匹配
</Routes>
// ✔ 正确:* 在最后,只兜底前面都未匹配的路径
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} /> // 最后一个,兜底
</Routes>
4.6.2 * 的不同用法
ini
path="*" → 匹配所有路径(404 兜底用)
path="/user/*" → 匹配 /user/ 开头的所有路径(如 /user/123/profile)
path="*-preview" → 匹配以 -preview 结尾的路径
4.7 React.lazy() + Suspense:路由级代码分割
4.7.1 问题的量化分析
假设一个中型 SPA 有 10 个页面,总 JS 体积 500KB:
ini
不做代码分割(静态 import):
bundle.js = 500KB(所有页面打包在一起)
首次加载 = 下载 + 解析 + 执行 500KB
FCP(首次内容绘制)= 慢
做代码分割(lazy import):
main.js = 200KB(框架 + 公共逻辑)
Home.chunk.js = 50KB
About.chunk.js = 40KB
User.chunk.js = 45KB
Products.chunk.js = 55KB
...其他 6 个页面...
首次加载(访问首页)= 200KB + 50KB = 250KB
FCP = 快一倍
4.7.2 代码实现(逐行解析)
jsx
// App.jsx 第 1-4 行
import { lazy, Suspense } from 'react';
// lazy: 接收一个返回 Promise<{default: Component}> 的函数
// Suspense: 在 lazy 组件加载完成前,显示 fallback 内容
// App.jsx 第 7-11 行
import { HashRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
// 这些是静态导入------框架级别的库,每个页面都需要,所以不做懒加载
// App.jsx 第 12 行
import Navigation from './component/Navigation';
// Navigation 是全局导航栏,每个页面都显示它,静态导入
// App.jsx 第 13-17 行(被注释掉的代码)
// import Home from './pages/Home';
// import About from './pages/About';
// import User from './pages/User';
// 上面是被替换掉的静态导入,下面是懒加载版本 ↓
// App.jsx 第 19-25 行
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const User = lazy(() => import('./pages/User'));
const NotFound = lazy(() => import('./pages/NotFound'));
const Products = lazy(() => import('./Products'));
const ProductDetail = lazy(() => import('./Products/ProductDetail'));
const NewProduct = lazy(() => import('./Products/New'));
4.7.3 lazy + import() 的工作原理
javascript
// 普通 import(静态导入)------ 编译时确定,始终打包在一起
import Home from './pages/Home';
// 动态 import() ------ 运行时按需加载,Vite 自动拆包
const Home = lazy(() => import('./pages/Home'));
// ^^^^^^^^^^^^^^^^^^^^^^^^
// 这是一个函数,返回 Promise<Module>
// Vite/Rollup 看到动态 import(),自动将其拆成独立 chunk
执行的时序图:
javascript
1. 用户首次访问 http://localhost:5173/#/
│
2. 浏览器下载 index.html + main.js(含 App.jsx 的路由配置代码)
│
3. React 渲染 <App />
│
4. HashRouter 初始化,当前 pathname = "/"
│
5. <Routes> 匹配 path="/" → 发现 element={<Home />},但 Home 是 lazy 的
│
6. React 触发 lazy 组件的加载:
import('./pages/Home') → 浏览器发起网络请求 → 下载 Home-abc123.js
│
7. 下载期间:<Suspense fallback={<div>等等我呗...</div>}>
用户看到:"等等我呗..."
│
8. 下载完成 → Promise resolve → React 用真实 <Home /> 替换 fallback
用户看到:首页内容
│
9. 用户点击导航 "About"
│
10. location.hash = "#/about" → hashchange → Routes 重新匹配
│
11. 匹配到 path="/about" → element={<About />}(也是 lazy)
→ import('./pages/About') → 浏览器请求 About-def456.js
│
12. 下载期间再次显示 "等等我呗..."
(此时 Home.chunk.js 仍在浏览器缓存中,不需要重新下载)
│
13. About 下载完成 → 渲染 <About />
4.7.4 Suspense 的 fallback 与用户体验
jsx
// App.jsx 第 32 行
<Suspense fallback={<div>等等我呗...</div>}>
fallback 可以是任意 React 元素,生产环境中通常是 Loading 动画组件:
jsx
// 生产环境中的常见写法:
<Suspense fallback={<PageLoading />}>
<Routes>
{/* ... */}
</Routes>
</Suspense>
// PageLoading 组件可能包含:
// - 骨架屏(Skeleton)模拟页面布局
// - 顶部进度条(NProgress 风格)
// - 居中的 Spin 动画
关于 Suspense 边界位置的考量:
本项目只设置了一个顶层 Suspense,所有页面的加载共用一个 fallback。如果希望不同页面有不同的加载效果,或者避免一个页面的加载阻塞已经加载完成的区域,可以设置多个 Suspense:
jsx
// 精细控制:每个 Route 独立 Suspense(示例,非项目代码)
<HashRouter>
<Navigation /> {/* Navigation 不包裹在 Suspense 中,始终显示 */}
<Routes>
<Route path="/" element={
<Suspense fallback={<HomeSkeleton />}>
<Home />
</Suspense>
} />
<Route path="/about" element={
<Suspense fallback={<AboutSkeleton />}>
<About />
</Suspense>
} />
</Routes>
</HashRouter>
4.8 Link 组件:声明式导航的完整剖析
4.8.1 源代码
jsx
// src/component/Navigation.jsx(共 20 行)
// a 点击后跳转,二次处理
// 不直接用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/new">新产品</Link></li>
</ul>
</nav>
);
}
export default Navigation;
4.8.2 <a> 与 <Link> 的根本差异
markdown
用户点击 <a href="/about">About</a>:
1. 浏览器默认行为:发起 GET /about 请求
2. 服务器返回 HTML
3. 浏览器丢弃当前 DOM
4. 解析新 HTML
5. 重建 CSSOM
6. 重新执行 JS
7. 渲染新页面
→ 结果:白屏 + 所有 React 状态丢失 + SPA 体验被破坏
用户点击 <Link to="/about">About</Link>:
1. Link 拦截 click 事件(event.preventDefault())
2. 调用 location.hash = '#/about'(本项目 HashRouter 模式)
3. 浏览器触发 hashchange 事件(不刷新页面)
4. HashRouter 更新内部 state
5. React 调度重渲染
6. Routes 重新匹配,渲染 <About />
7. React 只更新 DOM 中变化的部分
→ 结果:无白屏 + 所有状态保留 + 纯 SPA 体验
4.8.3 Link 渲染到 DOM 后是什么?
html
<!-- <Link to="/about">About</Link> 渲染到 DOM 后: -->
<a href="#/about">About</a>
<!-- 它仍然是一个 <a> 标签! -->
<!-- href 被自动加上了 # 前缀(因为使用了 HashRouter) -->
<!-- 所以右键"在新标签页打开"、鼠标悬停看 URL、屏幕阅读器等都能正常工作 -->
这就是 react-router-dom 的设计智慧 :虽然拦截了点击行为,但底层仍然渲染为标准的 <a> 标签,保证了无障碍访问(a11y)和浏览器原生特性(如"在新标签页打开")。
4.8.4 Link 的其他属性
jsx
// replace:替换历史记录而非追加
<Link to="/about" replace>About</Link>
// state:传递额外的路由状态(不显示在 URL 中)
<Link to="/user/123" state={{ from: 'home', referrer: 'nav' }}>用户</Link>
// 目标组件可通过 useLocation().state 读取:{ from: 'home', referrer: 'nav' }
// reloadDocument:强制使用原生 <a> 行为(极少使用)
<Link to="/about" reloadDocument>About</Link> // 等同于 <a href="/about">
4.8.5 NavLink:带激活样式的 Link
在项目中没有使用但值得了解:
jsx
import { NavLink } from 'react-router-dom';
<NavLink
to="/about"
className={({ isActive }) => isActive ? 'active' : ''}
// 或者用 style
style={({ isActive }) => ({ fontWeight: isActive ? 'bold' : 'normal' })}
>
About
</NavLink>
NavLink 在 Link 的基础上自动检测当前 URL 是否匹配自己的 to 路径,方便实现菜单高亮。
五、项目工程配置详解
5.1 入口 HTML:单页应用的空壳
html
<!-- index.html(14 行) -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react-router</title>
</head>
<body>
<div id="root"></div>
<!-- ↑ 这行是关键!整个 React 应用都挂载在这个空的 div 上 -->
<!-- Vite 在构建时会处理这个 <script>,注入打包后的 JS -->
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
SPA 的核心特征在这里完全体现:HTML 文件本身几乎没有内容,只有一个空的 <div id="root">。所有的界面都由 JavaScript 动态生成并插入这个 div。
5.2 入口 JS:React 挂载
jsx
// src/main.jsx(共 10 行)
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
React 18+ 的 createRoot API (替代 React 17 的 ReactDOM.render):
createRoot创建了一个 React 渲染根节点,绑定到 DOM 的#root元素<StrictMode>是开发环境的辅助组件,它会故意双重调用某些函数(如 reducer、effect、组件函数体),帮助开发者发现副作用相关的问题<App />作为整个应用的根组件被渲染
5.3 Vite 配置
js
// vite.config.js(共 7 行)
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
@vitejs/plugin-react 使用的是 Oxc 编译器(而非传统的 Babel 或 SWC)。Oxc 是用 Rust 编写的,速度比 SWC 更快,专门为 Vite 优化。这个插件负责:
- 将 JSX 编译为
React.createElement调用(或 React 19 的自动 JSX 转换) - 处理 React Fast Refresh(HMR 热更新)
- 开发环境的额外检查和优化
5.4 依赖版本解读
json
{
"dependencies": {
"react": "^19.2.7", // React 19:支持 Suspense、并发渲染、
// use() Hook、服务端组件等
"react-dom": "^19.2.7", // 与 react 版本严格对应
"react-router-dom": "7.18.2" // React Router v7:最新稳定版
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.3", // Vite 的 React 插件(Oxc)
"vite": "^8.1.1", // Vite 8:下一代前端构建工具
"eslint": "^10.6.0", // ESLint 10(扁平化配置)
"eslint-plugin-react-hooks": "^7.1.1", // Hooks 规则检查
"eslint-plugin-react-refresh": "^0.5.3" // Fast Refresh 相关检查
}
}
React 19 与路由相关的新特性:
- Suspense 功能进一步完善,lazy 加载的体验更好
- 原生支持
use()Hook,可以在渲染期间读取 Promise(替代部分 useEffect + useState 模式) - 自动 JSX 转换更高效
六、完整数据流追踪:一次路由切换的全过程
以用户从首页点击导航栏的 "About" 为例,追踪每一个环节发生了什么:
6.1 时间线
ini
T=0ms 用户在浏览器中看到首页,URL = http://localhost:5173/#/
DOM 状态:Navigation 组件 + Home 组件已渲染
React 状态:所有已加载的组件状态完好
T=1ms 用户点击 <Link to="/about">About</Link>
↓
T=2ms Link 组件内部:
const handleClick = (event) => {
event.preventDefault(); // ← 阻止浏览器的默认导航行为
navigate(to); // ← 调用 react-router 的 navigate
};
↓
T=3ms HashRouter 内部的 navigate 函数执行:
window.location.hash = '#/about';
// 修改 hash 不会触发页面刷新!
↓
T=4ms 浏览器检测到 hash 变化,触发 hashchange 事件
↓
T=5ms HashRouter 注册的 handleHashChange 执行:
const newPath = window.location.hash.slice(1); // "/about"
setLocation({ pathname: newPath }); // 更新 React state
↓
T=6ms React 检测到 HashRouter 的 state 变化,调度重新渲染
↓
T=7ms React 进入渲染阶段:
- Navigation 组件:props 没变,跳过重渲染(React.memo 或 VDOM 比较)
- HashRouter:提供新的 location context
- Routes:接收到新的 pathname="/about"
↓
T=8ms Routes 开始匹配:
遍历所有子 <Route>:
path="/" → "/" !== "/about",不匹配
path="/about" → "/about" === "/about",✔ 匹配!
停止遍历(已找到第一个匹配)
↓
T=9ms React 准备渲染 <About />:
但 About 是 lazy 组件:lazy(() => import('./pages/About'))
检查:About 的 chunk 是否已加载?
如果是首次访问 → 未加载 → 触发动态 import()
如果之前访问过 → 已在浏览器缓存 → 直接使用
↓
T=10ms 场景 A(首次加载):
触发 import('./pages/About')
→ 浏览器发起网络请求 GET /About-def456.js
→ React 发现 lazy 组件状态为 "pending"
→ 向上找到最近的 <Suspense> 边界
→ 显示 fallback:<div>等等我呗...</div>
→ 用户看到短暂的加载提示
... 假设网络延迟 50ms ...
T=60ms About-def456.js 下载完成
→ import() 的 Promise resolve
→ React 将 lazy 组件状态改为 "resolved"
→ 组件函数执行,返回 JSX
→ React 用真实 DOM 替换 fallback
→ 用户看到:"About" 标题
场景 B(已缓存):
Promise 已 resolved → 跳过 fallback → 直接渲染 <About />
↓
T=70ms 渲染完成。DOM 中 #container 区域从 Home 内容变为 About 内容。
URL = http://localhost:5173/#/about
整个过程中:
- 浏览器没有刷新
- Navigation 导航栏一直稳定显示
- React 内部状态(如果有的话)完好保留
- CSS、JS 运行环境未被重置
6.2 更复杂的场景:带参数的嵌套路由
bash
从 / 导航到 /products/42 的过程:
1. 点击 <Link to="/products/123"> → hash = '#/products/123'
2. Routes 匹配:
├── "/" → 不匹配
├── "/about" → 不匹配
├── "/user/:id" → 不匹配(/products ≠ /user)
├── "/products" → ✔ 前缀匹配(/products/123 以 /products 开头)
│ ├── ":productId" → ✔ 匹配 "123"
│ └── "new" → 不匹配(123 ≠ new)
├── 渲染:<Products>
│ <h2>Products 产品列表</h2>
│ <p>这是产品列表内容</p>
│ <Outlet>
│ <ProductDetail>
│ <h3>产品详情</h3>
│ <p>产品 ID: 123</p>
│ </ProductDetail>
│ </Outlet>
│ </Products>
3. ProductDetail 内部:
const { productId } = useParams();
// productId = "123"(由 Routes 内部的路径匹配器提取)
七、常见问题与最佳实践
7.1 为什么 的 to 属性没有 # 前缀?
jsx
<Link to="/about">About</Link>
// ^^^^^^^ 不需要写成 "#/about"
因为 Link 和 useNavigate 使用的是内部路由路径(pathname),不是完整的 URL。HashRouter 会自动负责将内部路径和 URL hash 之间做双向转换:
arduino
内部路径 "/about" ←→ HashRouter ←→ URL "#/about"
如果你写了 <Link to="#/about">,HashRouter 会将其解析为 pathname /#/about,反而无法匹配任何路由。
7.2 嵌套路由中子路由的路径不需要加父路径前缀
jsx
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
{/* ^^^^^^^^^^^ 正确:相对路径 */}
{/* ❌ path="/products/:productId" 错误:绝对路径 */}
</Route>
子路由的 path 属性自动继承父路由的路径前缀。写成绝对路径虽然也能工作(在某些版本中),但会破坏路由配置的可移植性------如果你后续改了父路由的 path,所有子路由都要跟着改。
7.3 <Routes> 中是有一个 <Route> 匹配
这是 React Router v6+ 的核心规则:无论有多少个 <Route>,<Routes> 最终只渲染第一个匹配到的那一个 。这是 v6 区别于 v5 的重要变化(v5 使用的是 <Switch>,行为类似但 API 不同)。
这意味着你不需要手动考虑路由优先级排序(除了 * 必须放最后),React Router 会按你的书写顺序匹配。
7.4 路径末尾斜杠的处理
ini
path="/about" → 匹配 "/about" → 不匹配 "/about/"
path="/about/" → 匹配 "/about/" → 不匹配 "/about"
默认情况下,React Router v6 对末尾斜杠敏感。如果需要兼容两种写法,可以使用可选参数或配置。
7.5 动态 import 的 Webpack/Vite 魔法注释
javascript
// Vite 自动拆包,但可以通过文件名控制 chunk 名称
const Home = lazy(() => import('./pages/Home'));
// → 生成 Home-[hash].js
// webpack 中可以用魔法注释控制 chunk 名(Vite 默认按文件名即可)
const Home = lazy(() => import(/* webpackChunkName: "page-home" */ './pages/Home'));
Vite(基于 Rollup)会自动根据文件路径生成可读的 chunk 名称,不需要额外注释。
八、总结速查表(金字塔塔尖回归)
回到开篇的核心结论:React Router 通过 HashRouter 监听 URL 变化,用声明式的 <Routes>/<Route> 配置映射关系,配合 <Link> 无刷新导航和 React.lazy() 按需加载,将传统后端路由的"URL → 服务端渲染新页面"模式转变为"URL → 客户端匹配组件 → 局部更新 DOM"模式,实现了无白屏、状态保持、按需加载的 SPA 体验。
本项目八大核心特性速查
| 序号 | 特性 | 涉及文件 | 关键 API | 核心要点 |
|---|---|---|---|---|
| 1 | Hash 路由 | App.jsx:7 | HashRouter |
# 后内容不发往服务器,hashchange 事件驱动,零服务端配置 |
| 2 | 路由配置表 | App.jsx:37-56 | Routes, Route |
声明式 JSX 配置,第一个匹配即渲染,* 必须放最后 |
| 3 | 动态参数 | User.jsx, ProductDetail.jsx | useParams, :id |
冒号定义动态段,路径匹配器提取参数,RESTful 风格 URL |
| 4 | 嵌套路由 | Products/index.jsx | Outlet |
父组件提供布局框架 + Outlet 占位,子路由路径相对书写 |
| 5 | 重定向 | App.jsx:50-52 | Navigate |
组件形式的重定向指令,replace 防止后退按钮死循环 |
| 6 | 404 兜底 | App.jsx:55, NotFound/index.jsx | path="*" |
通配符贪婪匹配,3 秒自动跳回首页 |
| 7 | 懒加载 | App.jsx:19-25 | React.lazy, Suspense |
动态 import() 拆包,Suspense fallback 显示加载态 |
| 8 | 声明式导航 | Navigation.jsx | Link |
拦截 <a> 默认行为,SPA 无刷新跳转,渲染为真实 <a> 标签 |
关键文件与行号索引
| 文件 | 关键行号 | 内容 |
|---|---|---|
App.jsx |
7 | HashRouter as Router 导入 |
App.jsx |
19-25 | 7 个 lazy() 懒加载声明 |
App.jsx |
31 | <Router> 根容器 |
App.jsx |
32 | <Suspense fallback> 加载边界 |
App.jsx |
37-56 | <Routes> 完整的路由配置表 |
App.jsx |
38 | path="/" 根路由 |
App.jsx |
40 | path="/user/:id" 动态路由 |
App.jsx |
43-47 | 嵌套路由(Products + 子路由) |
App.jsx |
50-52 | <Navigate> 重定向 |
App.jsx |
55 | path="*" 404 兜底 |
Navigation.jsx |
4,10-15 | Link 组件使用 |
User.jsx |
3,6 | useParams 使用 |
Products/index.jsx |
2,10 | Outlet 使用 |
Products/ProductDetail.jsx |
1,4 | 嵌套路由中的 useParams |
NotFound/index.jsx |
6-9 | 3 秒自动跳回首页 |
main.jsx |
6 | createRoot 挂载 |
index.html |
11 | <div id="root"> 挂载点 |
vite.config.js |
5 | @vitejs/plugin-react 配置 |
package.json |
13-15 | 核心依赖版本 |