还在被路由配置绕晕?这篇文章用"说人话"的方式,带你彻底搞懂 HashRouter、懒加载、动态路由、嵌套路由......附带完整可运行代码 🔥
前言
最近在系统学 React 全家桶,搞到路由这卡了好久------不是概念多难,而是各种组件(Router、Routes、Route、Link、Outlet......)混在一起,不知道谁管谁的。踩了一堆坑之后,我用大白话把所有知识点串了一遍,分享出来。
你将会收获:
- 前端路由到底是干嘛的,和以前的"后端路由"有啥区别
- HashRouter 为什么用
#号,背后的 hashchange 原理 - 路由配置三件套:Router → Routes → Route,谁管谁一清二楚
- 路由懒加载怎么配,lazy + Suspense 对首屏速度有什么帮助
- 动态路由
:id怎么用,useParams 怎么取参数 - 404 页面兜底 + useNavigate 3 秒自动跳回首页
- Navigate 重定向 ------ 旧地址自动跳新地址
- 嵌套路由(二级路由)+ Outlet ------ 最难理解的部分用大白话讲透
技术栈: React 19 + react-router-dom v7 + Vite
一、前端路由到底是干嘛的?
没有前端路由的时候
以前网站全是后端渲染。你在地址栏输一个 URL,浏览器发请求给服务器,服务器返回一整个 HTML 页面。用户点一个链接 → 浏览器刷新 → 白一下 → 新页面出来。每点一次就完整刷新一次,慢,体验也不好。
bash
┌──────────┐ 请求 /about ┌──────────┐
│ 浏览器 │ ─────────────────→ │ 后端 │
│ │ ←───────────────── │ │
│ (刷新) │ 返回完整HTML页面 │ 渲染页面 │
└──────────┘ └──────────┘
前端路由做了什么
SPA(单页面应用)出来了------整个网站其实只有一个 HTML 文件 。不同"页面"的切换,本质上是不同的组件在同一个 <div> 里换着显示。
那问题来了:用户点了导航链接,怎么知道该切换成哪个组件?这个"接管 URL 变化、根据 URL 决定显示哪个组件"的机制,这就是前端路由。
| 对比维度 | 后端路由 | 前端路由 |
|---|---|---|
| 谁判断显示什么 | 服务端 | 浏览器里的 JS |
| 切换时刷新页面? | 是,会白一下 | 否,瞬间切换 |
| 速度 | 慢(等网络往返) | 快(纯 JS 执行) |
| 类比 | 每次换台要先关机重启 | 遥控器换台,秒切 |
HashRouter 为什么地址栏有个 #
前端路由有两种实现方式:History 模式和 Hash 模式。HashRouter 的原理特别直观:
shell
#/ ← 首页
#/about ← 关于页
#/user/123 ← 用户 123 页
# 后面的部分叫 hash (锚链接)。改变 hash 不会触发浏览器刷新页面 ------以前 <a href="#section2"> 做页内锚点跳转,跳完页面不刷新,就是这个原理。
前端路由正是利用了这个特性:只变 # 后面的内容,浏览器就不刷新,JS 自己监听变化来切组件。
javascript
// 原生 JS 监听 hash 变化
window.addEventListener('hashchange', () => {
console.log('当前 hash:', window.location.hash)
// 根据 hash 切换显示的组件
})
一句话记住:hash 变,页面不刷;hashchange 一监听到,JS 自己来切组件。
二、基本配置------路由三件套
先装依赖:
bash
npm install react-router-dom
核心三件套:Router → Routes → Route
这三层是嵌套关系,缺一不可:
jsx
import {
HashRouter as Router, // 声明用 hash 模式
Routes, // 路由出口,扫描匹配
Route, // 一条具体规则
} from 'react-router-dom'
const App = () => {
return (
<Router> {/* 第1层:注入路由能力 */}
<Routes> {/* 第2层:找匹配的 Route */}
<Route path="/" element={<Home />} /> {/* 第3层:规则 */}
<Route path="/about" element={<About />} />
</Routes>
</Router>
)
}
逐行拆解:
| 层级 | 组件 | 一句话作用 |
|---|---|---|
| 1 | <Router> |
最外层包裹,给整个应用注入路由能力(监听 hashchange) |
| 2 | <Routes> |
遍历所有子 <Route>,找到 path 和当前 URL 匹配的那一条 |
| 3 | <Route> |
一条匹配规则:path="xxx" 定义 URL 模式,element={} 指定对应组件 |
Router管能力,Routes管匹配,Route管映射。
不用 <a>,用 <Link> 跳转
jsx
// Navigation.jsx
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>
</ul>
</nav>
)
}
<a href="/about"> |
<Link to="/about"> |
|
|---|---|---|
| 行为 | 浏览器向服务器发请求 | JS 拦截,只改 URL hash |
| 刷新页面? | 是,会白一下 🔴 | 否,瞬间切换 🟢 |
| 适合 SPA? | 否 🔴 | 是 🟢 |
三、路由懒加载------lazy + Suspense
没有懒加载的时候
jsx
// ❌ 没有懒加载:所有页面组件一次性下载
import Home from './pages/Home'
import About from './pages/About'
import UserProfile from './pages/UserProfile'
// 用户只访问首页,但其他页面的代码也全下载了
不管用户访问哪个页面,所有页面组件的 JS 代码都会在打开网站时一次性全部下载。10 个页面就是 10 份代码,首屏特别慢。
加上懒加载
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 App = () => {
return (
<Router>
<Suspense fallback={<div>Loading...</div>}> {/* 下载中显示占位 */}
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Suspense>
</Router>
)
}
lazy + Suspense 搭档原理:
markdown
1. 用户打开 #/ → 只下载 Home 的代码 ← 首屏飞快
2. 用户点击 About → 触发懒加载,去下载 About
3. 下载期间 → Suspense 的 fallback 显示 "Loading..."
4. 下载完成 → 替换掉 fallback,显示 About 页面
| 概念 | 作用 | 类比 |
|---|---|---|
lazy(() => import(...)) |
声明"用到时才加载" | 点菜时才现做,不是提前全做完 |
<Suspense fallback={...}> |
下载期间的占位内容 | 服务员说"稍等一下,马上来" |
⚠️ 坑:只用
lazy不写Suspense会直接报错,两者必须一起出现。
四、动态路由 + useParams
用户列表页,点"用户123"和"用户456"应该跳到不同页面。不可能给每个用户 ID 都写一条 Route。
用动态参数 :id:
jsx
// App.jsx 路由配置
<Route path="/user/:id" element={<UserProfile />} />
: 冒号开头表示这是个变量,可以匹配任意值:
shell
#/user/123 → :id = "123"
#/user/456 → :id = "456"
#/user/abc → :id = "abc"
用 useParams 把参数取出来:
jsx
// UserProfile/index.jsx
import { useParams } from 'react-router-dom'
function UserProfile() {
let { id } = useParams() // 从 URL 里取出 :id 的实际值
console.log(id) // "123"
return <h1>User Profile: {id}</h1>
}
完整数据流:
ini
┌──────────────┬──────────────────────────────────┐
│ 路由配置 │ <Route path="/user/:id" ...> │
├──────────────┼──────────────────────────────────┤
│ 导航链接 │ <Link to="/user/123"> │
├──────────────┼──────────────────────────────────┤
│ 组件取参 │ let { id } = useParams() │
├──────────────┼──────────────────────────────────┤
│ 渲染结果 │ "User Profile: 123" │
└──────────────┴──────────────────────────────────┘
一句话记住:
Link传参(写在 URL 里),useParams接参(从 URL 里读出来),:冒号标记变量位。
五、404 Not Found + useNavigate
jsx
// NotFound/index.jsx
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
const NotFound = () => {
let navigate = useNavigate() // 拿到跳转函数
useEffect(() => {
setTimeout(() => {
navigate('/') // 3 秒后跳回首页
}, 3000)
}, [])
return <>404 Not Found</>
}
路由配置里用 path="*" 兜底:
jsx
<Route path="*" element={<NotFound />} /> {/* * 通配符,上面都没匹配到就到这里 */}
path="*" 是通配符------上面所有 Route 都没匹配到的时候,它来兜底,给用户一个"页面不存在"的提示,而不是静默没反应。
| Hook | 作用 |
|---|---|
useNavigate() |
返回一个跳转函数,代码里想跳哪就跳哪 |
navigate('/') |
跳到首页,不刷新页面 |
useEffect(() => {...}, []) |
组件挂载时执行一次(启动倒计时) |
六、Navigate 重定向------旧地址自动跳新地址
jsx
import { Navigate } from 'react-router-dom'
// 在 Routes 里面配一条重定向
<Route path="old-path" element={<Navigate to="/new-path" replace />} />
lua
用户访问 #/old-path → Navigate 拦截 → 自动跳转 #/new-path
replace 属性表示"替换当前历史记录",这样用户按返回键不会回到旧地址(否则按返回又触发重定向,无限循环)。
七、嵌套路由(二级路由)+ Outlet------最难的部分
这是路由里最容易绕的一个概念。先看场景:
shell
#/products → 产品列表页
#/products/123 → 产品详情页(123 号产品)
#/products/new → 新增产品页
products 下面有两个子页面,这种就叫嵌套路由(多级路由)。
父组件用 Outlet 给子路由留位置
jsx
// Products/index.jsx
import { Outlet } from 'react-router-dom'
const Products = () => {
return (
<>
<h1>产品列表</h1>
<Outlet /> {/* ← 子路由的组件会渲染到这个地方 */}
</>
)
}
Outlet 就是子路由的"插座",没有它,子组件匹配到了也不知道该插到哪。
路由配置:子 Route 写进父 Route 里面
jsx
// App.jsx
<Routes>
<Route path="/products" element={<Products />}> {/* 父路由,用 > 不是 /> */}
<Route path=":productId" element={<ProductDetail />} /> {/* 子路由 */}
<Route path="new" element={<NewProduct />} /> {/* 子路由 */}
</Route>
</Routes>
关键语法:
jsx
// ❌ 错误:父 Route 自闭合了 /> → 子路由进不去,报错
<Route path="/products" element={<Products />} />
<Route path=":productId" element={<ProductDetail />} />
// ✅ 正确:用完整标签,子 Route 嵌套在里面
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
</Route>
子路由组件用 useParams 拿参数
jsx
// Products/Detail/index.jsx
import { useParams } from 'react-router-dom'
const ProductDetail = () => {
const { productId } = useParams() // 拿到 :productId 的实际值
return <h3>产品详情 {productId}</h3>
}
完整数据流
ini
用户点 <Link to="/products/789"> 商品789 </Link>
↓
URL 变成 #/products/789
↓
Routes 匹配到 path="/products" + path=":productId" → productId = "789"
↓
Products 父组件渲染(标题"产品列表")
↓
Products 里的 <Outlet /> 渲染 ProductDetail 子组件
↓
ProductDetail 里 useParams() 拿到 { productId: "789" }
↓
最终显示:产品列表 / 产品详情 789
八、常见坑速查
| 坑 | 原因 | 修复 |
|---|---|---|
| 页面完全空白 | 组件文件没有 export default |
补上 export default Xxx |
| 点击导航不切换 | 用了 <a> 而不是 <Link> |
换成 <Link to="..."> |
| 懒加载报错 | 只有 lazy 没有 Suspense 包裹 |
外层加 <Suspense fallback={...}> |
| 500 Internal Error | 懒加载的路径对应的文件不存在 | 检查 import('./pages/Xxx') 路径 |
| 嵌套路由不显示 | 父 Route 用了自闭合 /> |
改成 <Route ...>子路由</Route> |
总结
核心概念速查表
| 概念 | 一句话 |
|---|---|
| 前端路由 | JS 接管 URL 变化,不刷新页面,动态切换组件 |
| HashRouter | 用 # 后的 hash 做路由,改变 hash 不刷新页面 |
| Route | path(URL 模式) → element(对应组件)的映射规则 |
| Link | SPA 专用跳转组件,替代 <a>,不刷新页面 |
| lazy + Suspense | 用到时才加载页面代码,首屏只下载当前页面 |
:id 动态路由 |
冒号标记变量位,同一组件适配不同 URL 参数 |
| useParams | Hook,从 URL 里取出 :id 的实际值 |
| useNavigate | Hook,用代码控制页面跳转 |
| Navigate | 重定向组件,旧 URL 自动跳新 URL |
| Outlet | 嵌套路由的渲染出口,子组件插到这里 |
一句口诀
Router 管全局,Routes 管匹配,Route 管映射,Link 管跳转,Outlet 管嵌套,useParams 管取参。
核心代码骨架
jsx
<Router>
<Suspense fallback={<div>加载中...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/user/:id" element={<UserProfile />} />
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</Router>
结尾
源码在我的 React 学习仓库 hjf-ai/fe/react/router/ 里,每个知识点都对应一个可运行的小 demo。路由这块彻底搞懂之后,后面学状态管理(zustand/pinia)就顺畅多了。
希望这篇文章对你有帮助!有问题欢迎在评论区交流 🔥