不讲 API 列表,而是带你从零搭一个完整的 React 单页应用路由系统。读完你会知道每个 API 为什么存在、什么时候用、以及容易踩的坑。
目录
- 开篇:你打开的真的是"新页面"吗?
- 一、前端路由为什么存在
- 二、第一个路由:Routes、Route、Link
- 三、路由懒加载:首页快三秒的秘密
- 四、嵌套路由:页面套页面的结构之美
- 五、动态路由参数:同一个组件渲染不同数据
- [六、编程式导航:不用 Link 也能跳转](#六、编程式导航:不用 Link 也能跳转 "#%E5%85%AD%E7%BC%96%E7%A8%8B%E5%BC%8F%E5%AF%BC%E8%88%AA%E4%B8%8D%E7%94%A8-link-%E4%B9%9F%E8%83%BD%E8%B7%B3%E8%BD%AC")
- [七、404 兜底:优雅的"页面不存在"](#七、404 兜底:优雅的"页面不存在" "#%E4%B8%83404-%E5%85%9C%E5%BA%95%E4%BC%98%E9%9B%85%E7%9A%84%E9%A1%B5%E9%9D%A2%E4%B8%8D%E5%AD%98%E5%9C%A8")
- 八、布局决策:父路由放什么、子路由放什么
- 结尾:一张图回顾全部知识点
开篇:你打开的真的是"新页面"吗?
你在浏览器地址栏看到 /home、/products/123、/about,看起来和传统网站一模一样。但你注意到没有------页面切换时没有白屏,没有 loading 转圈,甚至 DevTools 的 Network 面板里也没有新的 HTML 请求。
这不是魔法,这是前端路由。
这篇文章带你用 React Router v7 从零搭建一个完整的前端路由系统,覆盖 7 个核心知识点。每个知识点都会回答三个问题:是什么、为什么需要、怎么写(以及为什么这样写)。
一、前端路由为什么存在
先回到本质:什么是路由?
路由就是一个映射关系------"这个 URL → 那个内容"。浏览器地址栏里的路径变了,页面显示的内容跟着变。
传统做法是这样:
每一次导航都是一次 HTTP 往返 + 整页重建。 在 3G 网络或页面很重的时候,用户体验就是"点一下、白一下、等一会儿"。
前端路由的思路是换一种做法:
核心理念:URL 要变(不同页面得有不同的 URL),但不能每次都找服务器要新 HTML------JavaScript 自己在客户端就把 DOM 换了。
实现方式有两种:Hash 路由 (URL 里带 #)和 History 路由 (URL 干净如 /page1)。本文项目用的是 HashRouter,原理是监听 hashchange 事件------Hash 部分的改变不会触发浏览器发 HTTP 请求,所以页面不会刷新。
二、第一个路由:Routes、Route、Link
先看项目里最基础的路由配置:
jsx
// App.jsx --- 路由配置,所有路由的"总开关"
import { HashRouter, Routes, Route } from 'react-router-dom'
<HashRouter> {/* ① 路由器:包裹一切,决定用 Hash 还是 History 模式 */}
<Routes> {/* ② 路由集合:里面放多个 Route,只渲染第一个匹配的 */}
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/user/:id" element={<UserProfile />} />
</Routes>
</HashRouter>
三个角色,分工明确:
| 组件 | 职责 | 类比 |
|---|---|---|
HashRouter |
监听 URL 变化,提供路由上下文 | 整个"导航系统" |
Routes |
扫描所有子 Route,找到匹配的渲染 | "导航地图" |
Route |
一条规则:这个 path 对应那个 element |
"一条路" |
导航链接:为什么不用 <a> 标签?
jsx
// ❌ 用 a 标签:点击会触发整页刷新,SPA 状态全丢
<a href="/about">关于我们</a>
// ✅ 用 Link 组件:React Router 拦截点击,只做局部替换
// components/Navigation.jsx
import { Link } from 'react-router-dom'
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link> 本质上渲染的还是 <a> 标签,但它拦截了点击事件,阻止浏览器默认的页面跳转行为,改为用 JavaScript 更新 URL 并触发组件切换。
⚠️ 新手坑 :用
<a>做 SPA 内部导航。URL 是变了,但页面整个刷新了,之前的状态(表单输入、滚动位置、Redux store)全没了。除了跳转到外部网站,SPA 内部一律用<Link>。
三、路由懒加载:首页快三秒的秘密
如果项目有 20 个页面,用户打开首页就要下载 20 个页面的 JS 代码------但他可能只看首页。
jsx
// App.jsx
import { lazy, Suspense } from 'react'
// 🔑 webpack/vite 看到 import() 就会把每个页面拆成独立的 JS chunk
const Home = lazy(() => import('./pages/Home'))
const About = lazy(() => import('./pages/About'))
const UserProfile = lazy(() => import('./pages/UserProfile'))
// ⚠️ 懒加载的组件必须用 Suspense 包裹,
// 因为加载需要时间,React 需要知道"还没加载完时显示什么"
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
{/* ... */}
</Routes>
</Suspense>
lazy() 告诉打包工具:"这个组件单独打包,访问到了再下载。" Suspense 的 fallback 则是下载期间的占位 UI。
加载时机一目了然:
效果:首页从"下载全部 20 个页面"变成"只下载当前 1 个页面",首屏加载量降低 80% 以上。
四、嵌套路由:页面套页面的结构之美
实际项目中,页面很少是"平铺"的。比如产品模块有两个子页面------产品详情 和新增产品 ,它们都挂在 /products 下面:
jsx
// App.jsx --- 嵌套路由:子 Route 写在父 Route 标签内部
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
注意子路由的 path 是相对路径 (":productId" 不是 "/products/:productId")。React Router 会自动拼接成 /products/:productId。
父组件 Products 里需要一个"插槽"来放子路由的内容:
jsx
// pages/products/index.jsx
import { Outlet } from 'react-router-dom'
const Products = () => <Outlet />
// 🔑 Outlet 是"子路由的渲染位":访问 /products/123 时这里渲染 Detail
// 访问 /products/new 时这里渲染 NewProduct
路由匹配过程如下:
一句话理解嵌套路由 :父 Route 负责"框架"(侧边栏、面包屑、模块标题),
Outlet是留给子页面的"内容区"。URL 每深入一层,就在 Outlet 里多渲染一层。
五、动态路由参数:同一个组件渲染不同数据
/user/123 和 /user/456 用的都是 UserProfile 组件,但展示的是不同用户。useParams 负责从 URL 中提取动态部分:
jsx
// pages/UserProfile/index.jsx
import { useParams } from 'react-router-dom'
const UserProfile = () => {
const { id } = useParams() // 🔑 从 URL 中解出 :id 的值
console.log(id) // 访问 /user/123 → "123"
return <p>用户 ID: {id}</p>
}
路由配置中 :id 的冒号前缀表示"这是一个动态段",React Router 会把它提取出来放到 useParams() 返回的对象里。结合嵌套路由使用也一样------/products/:productId 匹配到的 productId,在 ProductDetail 及它的任何更深层子组件中,都可通过 useParams() 拿到。
真实场景通常会配合 useEffect 发请求:
jsx
const { productId } = useParams()
useEffect(() => {
fetch(`/api/products/${productId}`).then(/* ... */)
}, [productId]) // ⚠️ 别忘了依赖:productId 变了要重新请求
六、编程式导航:不用 Link 也能跳转
Link 是"用户主动点击",但有些场景需要"代码自动跳"------比如提交表单成功后跳到列表页、登录过期后跳到登录页、404 页面倒计时后跳回首页。
useNavigate 就是干这个的:
jsx
// pages/NotFound/index.jsx --- 3 秒后自动跳回首页
import { useNavigate } from 'react-router-dom'
const navigate = useNavigate()
useEffect(() => {
setTimeout(() => navigate('/'), 3000)
}, [])
navigate() 的工作方式和点击 <Link to="/"> 一模一样------都经过 React Router 的拦截,不会触发整页刷新。如果用 window.location.href = '/' 也能跳,但那是"真·页面跳转"------整页刷新、状态全丢。
三者的使用场景:
| 方式 | 场景 | 刷新页面? |
|---|---|---|
<Link to="/"> |
导航栏、菜单 | ❌ 不刷新 |
navigate('/') |
表单提交后、权限不够跳登录 | ❌ 不刷新 |
window.location.href |
跳转到外部网站(唯一推荐场景) | ✅ 刷新 |
七、404 兜底:优雅的"页面不存在"
用户输入了一个不存在的 URL,不能给他看白屏或报错。path="*" 通配符匹配所有未被上面 Route 捕获的路径:
jsx
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
{/* ... 其他路由 ... */}
{/* 🔑 放在最后!匹配所有上面没匹配到的 URL */}
<Route path="*" element={<NotFound />} />
</Routes>
⚠️ 重点坑:
*必须放在<Routes>的最后面。 React Router 是从上到下 匹配的,*能匹配任何路径。放在前面的话,后面的所有 Route 都不会被匹配到------用户永远看不到 Home、About,全是 404。
八、布局决策:父路由放什么、子路由放什么
这是实际开发中最常碰到的设计问题。回顾我们的项目结构:
bash
/products → Products 组件(布局层)
/products/:productId → ProductDetail(内容层)
/products/new → NewProduct(内容层)
什么放父路由? 所有子页面共同需要的东西。比如侧边栏、面包屑、模块导航。
什么放子路由? 只有特定页面需要的东西。比如"产品列表"标题------只有详情页需要,新增页不需要。
jsx
// ✅ 正确:父路由只做"框架",子路由各自负责内容
// Products --- 纯容器,没有自己的 UI
const Products = () => <Outlet />
// Detail --- 自己需要"产品列表"标题,自己加
const ProductDetail = () => {
const { productId } = useParams()
return (
<>
<h1>产品列表</h1>
<h3>产品详情-{productId}</h3>
</>
)
}
// New --- 不需要标题,什么都不用管
const NewProduct = () => <h1>新增产品</h1>
判断标准一句话:加一段 UI 到父路由之后,问自己------"这段 UI 出现在 /products/new,合理吗?" 不合理就往下挪。
如果未来产品模块的 3 个子页面都需要同一个侧边栏,那再把侧边栏提回 Products 里------这和 React 的 state 提升是同一个思路,只不过提升的是 UI 而不是状态。
结尾:一张图回顾全部知识点
这个 Demo 里包含了 React Router 的 7 个核心知识点:
| # | 知识点 | 文件 |
|---|---|---|
| 1 | 路由基础配置(HashRouter / Routes / Route) | App.jsx |
| 2 | Link 导航(不用 a 标签) | Navigation.jsx |
| 3 | 路由懒加载(lazy + Suspense) | App.jsx |
| 4 | 嵌套路由(Outlet) | App.jsx + products/index.jsx |
| 5 | 动态参数(useParams) | UserProfile/index.jsx + Detail/index.jsx |
| 6 | 编程式导航(useNavigate) | NotFound/index.jsx |
| 7 | 404 兜底(path="*") | App.jsx |
前端路由的本质就是用 JavaScript 接管了原本属于服务器的"URL → 内容"映射。React Router 在这个基础上做了三件事:声明式配置、组件化路由、性能自动优化。理解了这七个知识点,你就掌握了 React 前端路由的全貌。
下次你搭新项目的路由时,打开这个 Demo,照着这个结构搭就行------懒加载提速、嵌套路由分层、404 兜底,一个都不少。