Vite + React 19 + Router v7,一文搞懂 HashRouter、嵌套路由、懒加载和 404 兜底
从零搭建一个 SPA 路由系统,涵盖 HashRouter、嵌套路由、路由懒加载、参数传递、重定向和 404 兜底——每个知识点都配有"踩坑提醒"。
前言
如果你正在学习 React 路由,大概率会碰到这些问题:
HashRouter和BrowserRouter到底有什么区别?- 嵌套路由的
<Outlet />为什么写了也没渲染? useParams()明明调了,为啥拿到的值是undefined?- 路由懒加载怎么配
<Suspense>才不报错?
这篇文章用一个完整的 Demo 项目,把 React Router v7 的核心 API 逐一拆解。适合刚学 React 路由、想系统梳理知识点的同学阅读。文末附有完整可运行的项目代码,克隆即用。
读完你会收获:
- 一套可运行的 SPA 路由项目结构
- HashRouter / Routes / Route / Link / Outlet / useParams / Navigate / useNavigate 的实战用法
- 路由懒加载 + Suspense 的正确搭配
- 至少 3 个"不看不知道"的坑
项目概览
这是一个基于 Vite + React 19 + react-router-dom v7 的 Hash 模式单页应用,包含 6 个页面 + 导航栏。
组件树
App
├── HashRouter
│ └── Suspense (Loading... 兜底)
│ ├── Navigation ← 导航栏,Link 跳转
│ └── Routes
│ ├── "/" → Home
│ ├── "/about" → About
│ ├── "/user/:id" → UserProfile (动态参数)
│ ├── "/products" → Products (父路由)
│ │ ├── ":productsId" → ProductDetail (子路由)
│ │ └── "new" → NewProduct (子路由)
│ ├── "/old-path" → Navigate 重定向到 /new-path
│ └── "*" → NotFound (404 兜底)
核心技术点
| 类别 | 用到的 API |
|---|---|
| 路由模式 | HashRouter |
| 路由配置 | Routes、Route |
| 导航 | Link |
| 动态参数 | useParams |
| 嵌套路由 | Outlet |
| 懒加载 | React.lazy + Suspense |
| 重定向 | Navigate |
| 编程式跳转 | useNavigate |
核心知识点:深入 3 个最重要的概念
知识点 1:HashRouter — URL 里为什么有个 #?
它是什么?
HashRouter 利用 URL 中 # 后面的部分(叫 hash)来管理前端路由。# 后面的内容变化时,浏览器不会向服务器发请求,页面也不会刷新。
https://example.com/#/user/123
↑
这一整段叫 hash
为什么这个项目用 HashRouter 而不是 BrowserRouter?
| HashRouter | BrowserRouter | |
|---|---|---|
| URL 样子 | xxx.com/#/about | xxx.com/about |
| 刷新页面 | 不会 404 | 需要服务端配置(否则 404) |
| 部署难度 | 零配置,丢服务器就能跑 | 需要 nginx/Caddy 配置 fallback |
| 适用场景 | 演示项目、静态部署、GitHub Pages | 生产应用(配合服务端) |
一句话:HashRouter 省心,BrowserRouter 好看。Demo 项目、静态托管用 HashRouter;正式上线且有后端支持用 BrowserRouter。
在这个项目里怎么用的?
// App.jsx
import { HashRouter as Router } from 'react-router-dom';
<Router>
<Navigation />
<Routes>
<Route path="/" element={<Home />} />
{/* ... */}
</Routes>
</Router>
HashRouter 像一个"外壳",包裹整个应用,接管所有路由逻辑。
知识点 2:嵌套路由 + Outlet — 父组件不变,子组件切换
它是什么?
嵌套路由让你把页面的公共部分写在父组件里,子路由只负责变化的内容。
用生活场景类比:
淘宝商品列表页,顶部导航栏和侧边筛选栏永远在那里,切换"手机"或"电脑"分类时,只有中间的商品区域在变。父组件 = 壳子(导航+筛选栏),子公司 = 中间那坨商品卡片。
在这个项目里怎么用的?
路由配置(App.jsx):
<Route path="/products" element={<Products />} > {/* 父:壳子 */}
<Route path=":productsId" element={<ProductDetail />} /> {/* 子:详情 */}
<Route path="new" element={<NewProduct />} /> {/* 子:新增 */}
</Route>
父组件(Products/index.jsx):
import { Outlet } from 'react-router-dom';
const Products = () => (
<>
<h1>产品列表</h1> {/* 公共标题,永远显示 */}
<Outlet /> {/* 子组件渲染在这里 */}
</>
);
实际效果:
| 访问 URL | <h1> 产品列表 </h1> | <Outlet /> 里渲染 |
|---|---|---|
/products | ✅ 显示 | 空 |
/products/123 | ✅ 显示 | <ProductDetail /> |
/products/new | ✅ 显示 | <NewProduct /> |
踩坑提醒:
<Outlet />必须写在父组件的return里,不是写在路由配置里!很多人把<Outlet />当成<Route>的属性来配,怎么都不生效,原因就在这。
知识点 3:React.lazy + Suspense — 按需加载,首页不卡
它是什么?
默认情况下,import Home from './pages/Home' 会把 Home 组件的代码打包进主文件——用户不管访问哪个页面,都要下载全部页面的代码。
React.lazy() 配合 import() 实现动态导入:只有用户真正访问某个页面时,才去下载那个页面的 JS。
在这个项目里怎么用的?
// App.jsx
import { lazy, Suspense } from 'react';
// ❌ 静态导入:所有页面一起下载
// import Home from './pages/Home';
// ✅ 动态导入:访问时才下载
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'));
Suspense 的作用
lazy 组件在下载时,React 需要显示一个"加载中"的界面——这就是 <Suspense> 的 fallback:
<Suspense fallback={<div>Loading...</div>}>
<Navigation />
<Routes>
<Route path="/" element={<Home />} />
{/* ... */}
</Routes>
</Suspense>
当用户首次访问 /about 时,流程是这样的:
点击"About"链接
↓
React 发现 About 组件还没下载
↓
显示 fallback:<div>Loading...</div> ← 用户看到加载提示
↓
异步下载 About 的 JS 文件
↓
下载完成,替换为 <About /> 组件 ← 用户看到页面内容
踩坑提醒:
<Suspense>必须包裹lazy组件,否则 React 会报A React component suspended while rendering错误。
逐组件拆解
Navigation — 导航栏
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/new">新增产品</Link></li>
<li><Link to="/products/123">产品详情</Link></li>
</ul>
</nav>
);
}
为什么用 <Link> 而不是 <a>?
<a href="/about"> | <Link to="/about"> | |
|---|---|---|
| 页面刷新 | 整个页面刷新,白屏闪烁 | 不刷新,局部替换 |
| SPA 体验 | ❌ 失去 SPA 优势 | ✅ 流畅切换 |
| 状态保持 | ❌ 丢失 | ✅ 保留 |
<Link> 底层调用了 history.pushState(),改变 URL 但不触发浏览器刷新——这是 SPA 体验的基石。
UserProfile — 动态路由参数
import { useParams } from 'react-router-dom';
function UserProfile() {
const { id } = useParams(); // 从 URL 中提取参数
return <h1>UserProfile {id}</h1>;
}
链路:
路由规则 /user/:id ← :id 是占位符,定义了参数名
用户访问 /user/小家 ← 把 "小家" 填入 :id 的位置
useParams() 返回 { id: "小家" } ← 组件拿到参数值
页面显示 UserProfile 小家
Products + ProductDetail — 嵌套路由 + 参数名匹配问题 ⚠️
路由配置:
<Route path="/products" element={<Products />} >
<Route path=":productsId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
注意这里有两个子路由:
:productsId— 动态参数,匹配/products/123、/products/abc等new— 固定路径,精确匹配/products/new
ProductDetail 组件:
function ProductDetail() {
const { productId } = useParams(); // 从 URL 中读取 productId
return <h3>产品详情 {productId}</h3>;
}
🔴 这里有一个极易被忽视的坑!
路由里定义的是
:productsId(带 s),但组件里解构的是productId(不带 s)。 参数名必须完全一致,否则productId永远是undefined。
useParams()返回的是一个对象,key 就是路由里:后面的名字。path=":productsId"意味着useParams()返回{ productsId: '123' },取productId当然取不到。正确写法:
const { productsId } = useParams();
NotFound — 404 兜底 + 编程式导航
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const NotFound = () => {
const navigate = useNavigate();
useEffect(() => {
setTimeout(() => {
navigate('/'); // 3 秒后自动跳回首页
}, 3000);
}, []);
return <h1>404 Not Found</h1>;
};
两个关键点:
-
path="*"兜底匹配 — 放在<Routes>的最后,所有未匹配的路径都会被它捕获:<Route path="*" element={<NotFound />} /> -
useNavigate编程式跳转 — 不用<Link>,而是在代码逻辑里主动跳转。适合"3 秒后自动跳回首页"这种场景。
Navigate — 路由重定向
<Route path="old-path" element={
<Navigate replace to="/new-path" />} />
访问 /old-path 时,自动跳转到 /new-path。replace 属性表示替换当前历史记录(用户点"后退"不会回到 old-path),不加 replace 则保留历史记录。
总结
回顾一下你从这个项目能带走的核心知识:
- HashRouter —
#后面的路径变化不触发浏览器刷新,适合静态部署 - Routes + Route — 声明式路由配置,
path匹配 URL,element指定渲染组件 - Link 替代
<a>— 保持 SPA 体验,不刷新页面 :xxx动态参数 +useParams()— 参数名必须和路由定义一致,否则取到undefined- 嵌套路由 +
<Outlet />— 父组件写公共布局,子路由内容填入 Outlet 占位符 React.lazy+<Suspense>— 按需加载页面,减少首屏体积path="*"兜底 +useNavigate/Navigate— 404 页面 + 自动跳转 / 重定向
下一步可以扩展的方向:添加路由守卫(登录鉴权)、面包屑导航、路由过渡动画、配合 Zustand/Redux 做全局状态管理。
完整项目代码
项目代码已托管在 Gitee,克隆即可运行:
🔗 仓库地址:gitee.com/dcx2758/ai_…
# 克隆整个仓库
git clone git@gitee.com:dcx2758/ai_doubao_dcx.git
# 进入项目目录
cd ai_doubao_dcx/fe/react/router/react-route-demo
# 安装依赖
npm install
# 启动开发服务器
npm run dev
# 浏览器访问
# http://localhost:5173
项目结构
react-route-demo/
├── package.json
├── vite.config.js
├── index.html
└── src/
├── main.jsx # 入口文件
├── App.jsx # 路由配置核心
├── App.css
├── index.css
├── components/
│ └── Navigation.jsx # 导航栏组件
└── pages/
├── Home/
│ └── index.jsx # 首页
├── About/
│ └── index.jsx # 关于页
├── UserProfile/
│ └── index.jsx # 用户页(动态参数)
├── Products/
│ ├── index.jsx # 产品列表(嵌套路由父组件)
│ ├── ProductDetail/
│ │ └── index.jsx # 产品详情(子路由)
│ └── NewProduct/
│ └── index.jsx # 新增产品(子路由)
└── NotFound/
└── index.jsx # 404 页面
技术栈
| 依赖 | 版本 |
|---|---|
| Vite | ^8.0.12 |
| React | ^19.2.6 |
| react-dom | ^19.2.6 |
| react-router-dom | ^7.18.2 |
npm install 后即可运行,无需额外配置。
写代码的路上,路由是你绕不过去的一关。希望这篇文章帮你少踩几个坑 🍀 觉得有用的话欢迎点赞收藏,有问题评论区交流 👏