一、起点:手写的 HashRouter 已经做了什么
在打开 react-router-demo 之前,我们先回顾已有的认知。在 demo2/index.html 中,我们用不到 60 行代码手写了一个 SPA 路由:
javascript
class HashRouter {
constructor() {
this.routers = {} // 路由表
window.addEventListener('hashchange', // 监听 hash
this.load.bind(this))
}
register(hash, callback) { this.routers[hash] = callback } // 注册
load() { /* 查表 → 执行 callback → 替换 DOM */ }
}
三件套:hash 链接(<a href="#/xxx">)+ 挂载点(<div id="container">)+ hashchange 监听。每次点击链接触发 hash 变化,load() 从 this.routers 查出对应 callback 执行,用 container.innerHTML 替换内容。
这套代码解决了 SPA 的核心问题:改 URL 但不发请求、不刷新页面 。但它的局限性也很明显------路由规则用 register() 调用来注册,不够直观;DOM 更新用 innerHTML,React 项目里没法用;没有嵌套路由、没有懒加载、没有重定向。
React Router 就是把手写 HashRouter 的每一个概念都做成了声明式的 React 组件和 Hook。今天的学习就是逐一建立这个对应关系。
二、项目结构:一个文件夹一件事
bash
src/
├── main.jsx ← 入口
├── App.jsx ← 路由核心(管理员)
├── components/
│ └── Navigation.jsx ← 导航栏
└── pages/
├── Home/index.jsx ← 首页
├── About/index.jsx ← 关于(含模块级 console.log)
├── UserProfile/index.jsx ← 用户(useParams)
├── NotFound/index.jsx ← 404 兜底(useNavigate + useEffect)
└── Products/
├── index.jsx ← 商品列表父级(Outlet)
├── detail/index.jsx ← 商品详情(子路由 + useParams)
└── New/index.jsx ← 新增商品(子路由)
每个组件一个文件夹,放置逻辑文件 index.jsx 加上专属样式、测试和图片。import Home from './pages/Home' 自动解析到 pages/Home/index.jsx,路径简洁。
学到这会想问 : 为什么每个组件要单独建一个文件夹而不是直接放一个
.jsx文件?解答 :因为一个组件不止一个文件------它有样式(
.css)、测试(.test.jsx)、图片等资源。如果所有组件平铺在 pages 目录下,文件数量爆炸。用文件夹 +index.jsx的方式,每个组件自成一格,删或挪都是一锅端。实际开发中,Home 文件夹可能包含index.jsx、index.css、index.test.jsx、hero.png和其他子组件。
三、入口和外壳:main.jsx 与 App.jsx 的宏观结构
3.1 main.jsx ------ 纯入口
javascript
import App from './App.jsx'
createRoot(document.getElementById('root')).render(<App />)
和所有 React 项目一样,纯粹的挂载入口,把 <App /> 渲染到 index.html 的 <div id="root"> 上。
3.2 App.jsx ------ 路由管理员
这是整个项目的核心文件。打开 App.jsx,先从上到下扫一遍结构:
javascript
// 1. React 基础
import { lazy, Suspense } from 'react'
// 2. React Router
import {
HashRouter as Router, // 底层就是 hashchange
Routes, Route, Navigate
} from 'react-router-dom'
// 3. 导航栏(同步加载)
import Navigation from './components/Navigation'
// 4. 页面组件(懒加载)
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/detail'))
const NewProduct = lazy(() => import('./pages/Products/New'))
注释里解释了为什么用 lazy:
arduino
// spa 动态的切换多个页面
// 下载执行,影响首页的加载速度
// 只需要加载当前页面就好,路由懒加载
传统 import Home from './pages/Home' 是同步加载------页面一打开,七个组件文件全部下载并执行。这些文件对于首屏来说大多是多余的:用户只看首页,About 和 UserProfile 的代码不该成为首屏开销。
lazy(() => import('./pages/Home')) 把加载推迟到真正需要的时候。注意 () => import(...) 是个箭头函数,现在不调用,只有当 <Home /> 要被渲染时 React 才去执行它,触发文件下载。
python
// 注释掉的同步 import,作为懒加载的对照
// import Home from './pages/Home'
// import About from './pages/About'
写完懒加载版本保留了原来的同步 import 作为注释,是为了直观对比。
学到这会想问 :
lazy里的箭头函数什么时候真正执行?不用 lazy 首页会慢多少?解答 :只有当
<Home />在渲染流程中被实际需要时------也就是用户首次访问/路径时,React 才会调用那个箭头函数触发import()。在此之前它只是一个"promise 生成器"存在变量里,不会发起网络请求。不用 lazy 的话,首屏会把 Home + About + UserProfile + NotFound + Products + ProductDetail + NewProduct 七个文件全部下载执行,即使 99% 的用户可能只看首页就走了。用了 lazy 后首屏只下载必需的 Navigation 和 Home,随着用户点击逐步加载其余组件。
四、逐层拆解 App.jsx 的 JSX 结构
javascript
const App = () => {
return (
<>
{/* 前端路由接管一切 */}
<Router>
{/* 导航栏组件 */}
<Navigation />
{/* 动态页面切换部分 */}
<div id="container">
{/* 即是配置,也是出现的地方 */}
<Suspense fallback={<div>Loading...</div>}>
<Routes>
{/* 有且仅有一个route显示 当前location.hash
对应页面级别组件 */}
<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 to="/new-path" replace />} />
{/* 404 Not Found */}
{/* *贪婪匹配所有,最后404兜底 */}
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</div>
</Router>
</>
)
}
逐层拆解,每层都有和手写版的对应关系。
4.1 <Router> --- HashRouter 的别名
javascript
import { HashRouter as Router } from 'react-router-dom'
// 注释: // location.hash
// 另一个注释: // 前端路由 #/ hashchange
<Router> 就是 <HashRouter> 的别名。注释写了两件事:它的数据来源是 location.hash(URL 中 # 后面的部分),它的底层机制是 hashchange 事件。这和手写版 window.addEventListener('hashchange', ...) 是同一套东西,只是 React Router 团队把监听、匹配、渲染整条链路封装好了。
为什么要起别名?以后如果换成 BrowserRouter(服务器配合的路径路由),只需要改 import 这一行,组件树里的 <Router> 不用动。
arduino
{/* 前端路由接管一切 */}
这句注释是 App.jsx 的核心宣言。传统多页应用中,路由规则在服务端(不同的 URL 路径对应不同的服务器文件)。现在 <Router> 内部的所有跳转、匹配、渲染全部由前端 JavaScript 接管,不再依赖服务器。
学到这会想问 :
<HashRouter>导入之后内部到底包含了什么?它是"自动有"的吗?解答 :
import { HashRouter } from 'react-router-dom'导入的就是 react-router-dom 包中写好的组件。它内部封装了:(1)hashchange事件的监听(等于手写版的addEventListener),(2)location.hash的读取和解析,(3) 通过 React Context 向下传递路由信息给所有子组件,(4) 浏览历史的 push/replace/goBack 管理,(5) hash 变化后触发 Routes 重新匹配的整条链路。它不是你手写版的替代魔法,而是把你写的那 ~30 行代码扩充到了几百行,加上了 Context、历史管理、React 深度整合。底层hashchange原理一模一样。
4.2 <Navigation /> --- 导航栏始终显示
放在 <Router> 内、<Suspense> 外。导航栏不需要懒加载------每个页面都要用它,同步 import 直接渲染。
4.3 <div id="container"> --- 和手写版同样的挂载点
和 demo2/index.html 的 <div id="container"></div> 作用完全一样------壳(header、footer、导航栏)不动,只有这个区域的内容发生变化。React 版不再用 innerHTML 进行 DOM 操作,而是在内部通过 React 组件系统更新。
4.4 <Suspense> --- 懒加载的过渡层
xml
<Suspense fallback={<div>Loading...</div>}>
因为所有页面组件都是 lazy 加载的,首次切换到某个页面时文件还没下载好,React 会先渲染 fallback 中的内容作为占位。文件下载完毕后再替换为真正的组件。
<Suspense> 只包裹 <Routes> 而不包裹 <Navigation> 的原因:导航栏不需要懒加载,包进去反而会让导航栏在懒组件加载期间也受到影响。
4.5 <Routes> --- 既是路由配置,也是组件出现的位置
arduino
{/* 即是配置,也是出现的地方 */}
<Routes>
两个角色合二为一:
- 配置 :里面写的
<Route path="..." element={...} />定义了路由规则 - 出现的地方 :匹配到的组件最终渲染在
<Routes>这个位置
这和你手写版的 this.routers 表 + container.innerHTML 替换是同一套思想的 React 化表达。
<Routes> 内部会自动按书写顺序遍历 所有子 <Route>,找到第一个匹配当前 hash 的就渲染其 element,后面的不再看。
arduino
{/* 有且仅有一个route显示 当前location.hash 对应页面级别组件 */}
"有且仅有一个" 是 Routes 的核心约束------一次只渲染一个路由。"页面级别组件" 指的是 Home、About 这种整页组件,不是导航栏这种小片段。
五、六条 Route,六个知识点
5.1 / --- 首页,最简单的匹配
ini
<Route path="/" element={<Home />} />
path="/" 是钥匙,element={<Home />} 是匹配到了该调用并渲染的组件。首次访问时 Home 文件还没下载,<Suspense> 显示 Loading 直到 import 完成。
Home 组件里只有一句:
javascript
// 页面级别组件,比单纯的构成页面的组件
// /view/pages目录下
function Home() {
return (<>Home</>)
}
第一条注释区分了"页面级别组件"(一整页)和"构成页面的组件"(如导航栏、按钮)。第二条注释说明这类组件在工程中的标准路径是 /view/pages 目录下。
5.2 /about --- 模块级代码的陷阱
ini
<Route path="/about" element={<About />} />
About 组件中有一个值得注意的细节:
javascript
function About() {
return (<>About</>)
}
console.log('About') // 这行在函数外面!
export default About
这里的 console.log('About') 写在了 function About() 的外部,属于模块级别代码。当 import('./pages/About') 执行时,整个文件从头到尾运行一遍------定义函数、执行 console.log、导出。console.log 是在 import 那一刻 立即执行的,和用户什么时候点击 About 链接、组件是否被渲染无关。如果写在 return 前面,那才是每次渲染时打印。
这就解释了为什么 App.jsx 的注释强调"只需要加载当前页面就好"------每个被 import 的文件都会立即执行其模块级代码,import 越多,首屏要跑的代码越多。
5.3 /user/:id --- 动态参数
ini
<Route path="/user/:id" element={<UserProfile />} />
:id 中的冒号表示这是一个动态参数,匹配 /user/ 后面跟着的任何值:/user/123、/user/abc、/user/999 全部命中同一条 Route。
组件里用 useParams 取值:
javascript
import { useParams } from 'react-router-dom'
function UserProfile() {
const { id } = useParams() // 对应的 Route 里写了 :id
return <h2>用户 {id}</h2>
}
path=":id" 定义了参数名为 id,useParams() 返回 { id: '实际值' },两边名字要一致。如果用 path=":userId",useParams() 里解构的键就得改为 userId。
这和你手写版的 window.location.hash.slice(1) 从 URL 取参数是同一回事,只是 useParams 基于 Route path 中的占位符自动解析。
5.4 /products 的嵌套路由 --- 父级 + Outlet + 子级
xml
{/* 多级路由,嵌套路由 */}
<Route path="/products" element={<Products />}>
{/* 二级路由 */}
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
和前面自闭合 <Route /> 不同,带子路由的 <Route> 用开始和结束标签包裹子路由。子路由的 path 不带前导 /,会自动拼接父级的 /products 前缀,形成完整的 /products/:productId 和 /products/new。
Products 父组件通过 <Outlet /> 为子路由留坑:
javascript
import { Outlet } from 'react-router-dom'
const Products = () => {
return (
<>
<h1>商品列表</h1>
<Outlet /> // 注释: // 二级路由出口
</>
)
}
学到这会想问 :
<Outlet />到底是什么?它和#container是什么关系?没有 Outlet 会怎样?解答 :
<Outlet />就是嵌套路由里的子级挂载点。和手写版<div id="container">的角色完全一样------它是一个坑,子路由匹配到的组件就渲染在这个坑里。访问/products/123时,父级<h1>商品列表</h1>不动,<Outlet />位置渲染<ProductDetail />的内容 "商品详情123"。切换到/products/new,Outlets 里替换为<NewProduct />,父级纹丝不动。这就是嵌套路由和普通路由的关键区别:普通路由切换整个组件销毁重建,嵌套路由切换只换子级。如果父组件没写<Outlet />,子路由匹配到了但没地方渲染,等于白配。
子路由 ProductDetail 同样使用 useParams 取值:
javascript
const { productId } = useParams() // 对应 path=":productId"
return <h3>商品详情{productId}</h3>
和 UserProfile 的 useParams 原理完全一致,只是参数名从 id 换成了 productId。
学到这会想问 : 为什么
:productId前有冒号表示动态参数,而new没有?解答 :
path=":productId"的冒号意味着它是通配符,/products/123、/products/abc、/products/999全部匹配,冒号后面的名字就是useParams里的 key。而path="new"没有冒号,只有精确等于"new"时才匹配。这样设计的原因是:商品有无数个,不可能给每个 ID 写一条 Route,所以用动态参数一行搞定;而 "新增商品" 是固定的一个页面,直接写死new即可。Routes 遍历时先匹配到哪条算哪条,所以/products/new会优先命中path="new"而不是path=":productId"。
5.5 Navigate --- 重定向
ini
<Route path="/old-path" element={<Navigate to="/new-path" replace />} />
<Navigate /> 是重定向组件。当用户访问 /old-path,匹配到这条 Route,Navigate 组件挂载后立即改 hash 跳向 /new-path。
Navigate 比你手写版更精准------手写版没有现成的重定向机制,只能手动改 window.location.hash。
5.6 * --- 404 兜底
javascript
{/* 404 Not Found */}
{/* *贪婪匹配所有,最后404兜底 */}
<Route path="*" element={<NotFound />} />
* 是通配符,匹配任何路径。两条注释写明了它的两个特性:贪婪匹配所有 (任何没被上面路由命中的 hash 都会被它兜住)和最后放置(必须放在所有 Route 的最后,否则前面的 Route 全部被它抢先匹配到而失效)。
NotFound 组件使用了 useNavigate + useEffect 实现自动跳回:
javascript
import { useNavigate } from 'react-router-dom'
// 注释: // 路由跳转
const NotFound = () => {
let navigate = useNavigate() // 拿到 navigate 函数
useEffect(() => {
setTimeout(() => {
navigate('/') // 3 秒后跳回首页
// window.location.href = '/' // 旧写法,会刷新页面,淘汰了
}, 3000)
}, []) // 空数组 = 只在挂载时执行一次
return (<>404 Not Found</>)
}
useNavigate 是编程式路由跳转。和 <Link> 的区别在于:<Link> 依赖用户点击(声明式),navigate 是 JS 代码主动调用(命令式)。useNavigate() 返回一个函数,调用它传入路径就能跳转。
注释掉的 window.location.href = '/' 是旧写法------它会触发完整的页面刷新,发 HTTP 请求,破坏 SPA 体验。navigate('/') 走的是 HashRouter 内部逻辑,只改 hash,不刷新,全程 SPA。
useEffect 的 [] 表示依赖为空,只在组件挂载时执行一次。每次 NotFound 被渲染(即每次用户闯入不存在的路径),都会重新挂载,重新设定时器,重新倒计时 3 秒。
这里还有一个细节值得注意:当前代码在组件卸载时没有清除定时器。如果用户在 3 秒内离开 404 页面,定时器仍在后台继续倒数,到期后依然会执行 navigate('/')------虽然跳的是同一路径影响不大,但严格来说应该 return () => clearTimeout(timer) 来清理。这是一个可以优化的点。
六、Navigation.jsx --- Link 的本质
javascript
// 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">商品列表</Link></li>
<li><Link to="/products/123">商品详情</Link></li>
<li><Link to="/products/New">新增商品</Link></li>
</ul>
</nav>
)
}
三行注释已经把 Link 存在的理由说透了:<a> 标签点击会触发浏览器默认跳转(发请求、刷新),不适合 SPA。<Link> 内部拦截了 onClick,用 e.preventDefault() 阻止默认行为,然后手动修改 window.location.hash,整个过程不刷新。
Link 最终渲染到页面上就是一个 <a href="#/xxx">,和你手写版的 <a href="#/page1"> 是同一个东西,只是封装了拦截逻辑。所以用户右键在新标签页打开、悬停时看到链接地址,这些 <a> 的原生体验全部保留。
学到这会想问 :
<Link>到底是什么?点击 Link 到底是不是浏览器的点击?解答 :点击 Link 当然是浏览器的真实点击------
click事件照常触发。区别在于 Link 内部在 onClick 中调用了e.preventDefault(),"伸腿绊了浏览器一脚",阻止了后续的 HTTP 请求和页面跳转。然后它自己接管:读取to属性 → 拼成'#' + to→ 赋值给window.location.hash→ hashchange 触发 → HashRouter 通知 Routes 重新匹配 → 渲染新组件。浏览器以为自己只是改了个锚点,实际上整个页面的内容都被换了。
七、完整流程模拟:从点击到渲染
假设用户第一次打开 http://localhost:5174/,然后点击 "商品详情":
bash
1. 应用启动
main.jsx → render(<App />) → App 组件执行
→ Navigation 同步 Import 并渲染(导航栏出现)
→ Routes 遍历:hash 为空 → 只有 "*" 匹配 → NotFound
→ NotFound 是 lazy 的,第一次被用到 → Suspense 显示 Loading
→ import('./pages/NotFound') → 下载文件 → 渲染 "404 Not Found"
→ NotFound 挂载 → useEffect 执行 → 3 秒后 navigate('/')
→ hash 变为 '#/' → Routes 重新匹配 → path="/" 命中
→ Home 是 lazy 的 → 下载 → 渲染 "Home"
2. 用户点击 <Link to="/products/123">商品详情</Link>
→ Link 内部 e.preventDefault() → window.location.hash = '/products/123'
→ URL 变成 #/products/123 → 没有 HTTP 请求
→ hashchange 触发 → HashRouter 通知 Routes
→ Routes 遍历:
"/" ≠ "/products/123" ❌
"/about" ≠ "/products/123" ❌
"/user/:id" ≠ "/products/123" ❌
"/products" → 前缀匹配 ✅
进入子路由:
":productId" → "123" 匹配 ✅ → productId = '123'
→ Products 和 ProductDetail 都是 lazy → Suspense 显示 Loading
→ 下载 Products/index.jsx 和 Products/detail/index.jsx
→ Products 渲染:
<h1>商品列表</h1>
<Outlet /> → ProductDetail 渲染在这里 → <h3>商品详情123</h3>
→ 页面最终输出:
导航栏(Home About 小家 商品列表 商品详情 新增商品)
──────────────────────────────
商品列表
商品详情123
整个过程从浏览器角度看:URL 从 localhost:5174 变成了 localhost:5174/#/products/123。没有发出一条 HTTP 请求,页面没有闪一下白屏,导航栏没有任何重新渲染的痕迹。 这就是 SPA。
八、概念对照:手写版 vs React Router
每个 React Router 的 API,心里都要能对应到手写版的概念:
| React Router | demo2 手写版 | 本质 |
|---|---|---|
<HashRouter> |
window.addEventListener('hashchange', ...) |
监听 hash 变化 |
<Link to="/about"> |
<a href="#/about"> |
改 hash 不发请求 |
<Routes> + <Route> |
this.routers = {} |
路由表 |
<Route path="..." element={...} /> |
register(key, callback) |
注册路由规则 |
useParams() |
window.location.hash.slice(1) |
从 URL 拿参数 |
useNavigate()('/') |
window.location.hash = '/' |
编程式跳转 |
<Outlet /> |
#container(子级版本) |
嵌套挂载点 |
lazy(() => import(...)) |
register 的延迟调用 |
按需加载 |
<Navigate to="..." replace /> |
手动改 hash + 不留历史 | 声明式重定向 |
path="*" |
路由表最后的兜底匹配 | 404 通配 |
九、回顾检查清单
把这些问题口头回答一遍,能说清楚就说明 React Router 这部分掌握了:
- 从点击导航栏 Link 到页面切换,React Router 内部发生了什么?
<Link>为什么不刷新页面?它和<a>的区别是什么?HashRouter内部封装了什么?它对应手写版的哪些代码?lazy + Suspense解决了什么问题?没有它们会怎样?useParams的参数名从哪里来?为什么 UserProfile 用的是id,ProductDetail 用的是productId?path="*"为什么要放在所有 Route 的最后?<Outlet>在嵌套路由里扮演什么角色?和手写版#container有什么关系?useNavigate()和window.location.href有什么区别?为什么前者更好?:productId的冒号表示什么?和new有什么设计上的不同?Navigate的replace属性不加会有什么后果?
#React #ReactRouter #SPA #前端路由