前言
上一篇《从多页到 SPA:手写一个 Hash 路由》我们手写了 Hash 路由,理解了前端路由的本质。但实际项目中,我们不会每次都从零造轮子------React Router 就是 React 生态中最主流的前端路由方案。
本文基于 React Router v7(最新版本),通过一个完整的 Demo 项目,覆盖从基础配置、动态路由、嵌套路由、懒加载,到鉴权守卫、404 兜底等所有实际项目中的高频场景。
一、项目起步
技术栈
| 工具 | 版本 | 作用 |
|---|---|---|
| Vite | ^8.1.1 | 构建工具 |
| React | ^19.2.7 | UI 框架 |
| react-router-dom | ^7.18.2 | 前端路由 |
入口文件
jsx
// src/main.jsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
入口一如既往地简洁------因为路由逻辑全部封装在 App.jsx 中。
二、HashRouter vs BrowserRouter:两种路由模式
在正式开始之前,必须先搞清楚一个重要的选型:
bash
HashRouter: http://localhost:5173/#/pay
BrowserRouter: http://localhost:5173/pay
| HashRouter | BrowserRouter | |
|---|---|---|
| URL 外观 | 带 #,有点"丑" |
干净,和传统 URL 一样 |
| 原理 | 监听 hashchange 事件 |
基于 HTML5 History API(pushState/popState) |
| 服务器支持 | 不需要 | 需要服务器配置(所有路径返回 index.html) |
| SEO | 差(hash 不会被搜索引擎收录) | 好 |
| 适用场景 | 简单 demo、不需要 SEO 的后台系统 | 绝大多数生产项目 |
本文 Demo 使用 BrowserRouter,这是现代 SPA 的主流选择。
三、基础路由配置:Routes + Route
React Router v7 的核心配置模式是 组件式配置------路由就是组件,配置即声明。
jsx
// src/App.jsx(核心骨架)
import {
BrowserRouter as Router,
Routes,
Route,
} from 'react-router-dom'
const App = () => {
return (
<Router>
<Navigation />
<div id="container">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</div>
</Router>
)
}
关键点
<Router>:最外层包裹,整个应用的路由容器(这里用了BrowserRouter)<Routes>:路由匹配区域,有且只有一个<Route>会被渲染<Route>:单条路由规则,path匹配 URL,element指定渲染的组件path="*":通配符,匹配所有未命中路径,用于 404 兜底
注意 :
<Navigation />放在<Routes>外面,意味着它在所有页面都显示------这就是 SPA 的"公共区域"(页头/导航栏)。
四、Link 组件:SPA 的导航方式
传统的 <a href="/about"> 会触发浏览器完整刷新,这在 SPA 中是灾难性的。
React Router 提供了 <Link> 组件------它在底层拦截了点击事件,只更新 URL 和渲染内容,不刷新页面。
jsx
// src/components/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>
<li><Link to="/products/new">商品新增</Link></li>
<li><Link to="/pay">支付</Link></li>
</ul>
</nav>
);
}
<Link> 本质上渲染的还是 <a> 标签,但它接管了点击行为,调用 history.pushState 来改变 URL。
五、路由懒加载:让首页飞起来
如果所有页面组件都在首页一次性加载,JS bundle 会非常大,首页加载速度堪忧。
React.lazy + Suspense 实现了"按需加载"------只有访问某个路由时,才去下载对应组件的代码。
jsx
import { lazy, Suspense } from 'react';
// ❌ 传统方式:首页就全部加载
// import Home from './pages/Home';
// import About from './pages/About';
// ✅ 懒加载:访问时才下载
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 App = () => {
return (
<Router>
{/* Suspense 是懒加载的"加载中"兜底 */}
<Suspense fallback={<div>Loading...</div>}>
<Navigation />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
{/* ... */}
</Routes>
</Suspense>
</Router>
)
}
执行流程
xml
用户访问 /about
→ about 的 JS chunk 还没下载
→ 显示 <Suspense fallback>(Loading...)
→ 异步下载完成
→ 渲染 <About />
打开浏览器 DevTools 的 Network 面板,你会看到每个页面都是独立的 JS 文件,只在首次访问时加载。
六、动态路由:/user/:id 的参数魔法
同一个页面模板,根据 URL 中的不同参数显示不同内容------这就是动态路由。
jsx
{/* 配置 */}
<Route path="/user/:id" element={<UserProfile />} />
jsx
// src/pages/UserProfile/index.jsx
import { useParams } from 'react-router-dom';
function UserProfile() {
let { id } = useParams(); // 取出 URL 参数
return (
<h2>User Profile: {id}</h2>
)
}
| 访问 URL | useParams() 结果 |
|---|---|
/user/123 |
{ id: "123" } |
/user/小家 |
{ id: "小家" } |
useParams 是 React Router 最常用的 Hook 之一------召之即来,来之即用。
七、嵌套路由:父子页面的优雅组织
实际项目中,页面往往有层级关系。比如 /products 是商品列表,/products/123 是商品详情,/products/new 是新增商品。
jsx
{/* 嵌套路由配置 */}
<Route path="/products" element={<Products />}>
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
注意:子路由的
path是相对路径 ,不需要写/products/:productId。
jsx
// src/pages/Products/index.jsx
import { Outlet } from 'react-router-dom';
const Products = () => {
return (
<>
<h1>产品列表</h1>
{/* Outlet 是子路由的"插槽",子组件渲染在这里 */}
<Outlet />
</>
)
}
渲染结果
bash
访问 /products → 产品列表(Outlet 为空)
访问 /products/123 → 产品列表 + 产品详情 123
访问 /products/new → 产品列表 + 新增商品
<Outlet /> 就是嵌套路由的"占位符"------父组件决定公共布局,子组件填充可变区域。
八、重定向:Navigate 组件
项目迭代中,有些旧 URL 需要跳转到新地址;或者未登录用户访问需要跳转登录页。
jsx
{/* 重定向:/old-path → /new-path */}
<Route path="/old-path" element={
<Navigate replace to="/new-path" />
} />
| 属性 | 作用 |
|---|---|
to |
目标路径 |
replace |
true 时用新记录替换当前历史记录(用户无法"后退"回来) |
replace 在登录场景尤其重要------登录成功后用户不应该还能"后退"回登录页。
九、鉴权路由:ProtectRoute 路由守卫
这是企业项目中最常见的需求------某些页面需要登录才能访问。
jsx
// src/App.jsx
<Route path="/pay" element={
<ProtectRoute>
<Pay />
</ProtectRoute>
} />
jsx
// src/ProtectRoute.jsx
import { Navigate } from 'react-router-dom';
const ProtectRoute = ({ children }) => {
const isLogin = localStorage.getItem('isLogin') === 'true';
if (!isLogin) {
// 未登录 → 重定向到登录页,并记住"从哪来"
return (
<Navigate
to="/login"
replace
state={{ from: location.pathname }}
/>
);
}
// 已登录 → 放行,渲染子组件
return <>{children}</>;
}
核心设计模式:children 插槽
xml
<ProtectRoute>
<Pay /> ← 这个就是 children
</ProtectRoute>
jsx
const ProtectRoute = ({ children }) => {
// children 就是 <Pay />
// 已验证:渲染 {children}
// 未验证:渲染 <Navigate>(不渲染 children)
}
这就是 React 组件化的精髓------children 让组件拥有了"包裹"其他组件的能力,类比弹窗的 Modal 组件:
jsx
<Modal> ← 蒙层 + 窗体框架
<form>...</form> ← children 定制内容
</Modal>
十、登录流程:useNavigate + useLocation + state
鉴权路由拦截后重定向到 /login,同时传递 state 记录来源路径------登录成功后自动跳回。
jsx
// src/pages/Login/index.jsx
import { useNavigate, useLocation } from 'react-router-dom';
const Login = () => {
const navigate = useNavigate();
const location = useLocation();
// 从 state 中取出"从哪来",没有则默认跳首页
const from = location.state?.from || "/";
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const username = formData.get("username");
const password = formData.get("password");
if (username === 'admin' && password === '123456') {
localStorage.setItem('isLogin', 'true');
// replace: true --- 不让用户后退回登录页
navigate(from, { replace: true });
}
}
return (
<form onSubmit={handleSubmit}>
<input name="username" placeholder="请输入用户名" />
<input name="password" placeholder="请输入密码" />
<button type="submit">登录</button>
</form>
)
}
登录流程全链路
bash
1. 未登录用户访问 /pay
2. ProtectRoute 检测到 isLogin=false
3. <Navigate to="/login" state={{ from: "/pay" }} />
4. 用户在 /login 输入账号密码并提交
5. 登录成功,localStorage.setItem('isLogin', 'true')
6. navigate("/pay", { replace: true })
--- 跳回 /pay,并且 /login 不会留在浏览器历史中
?.是 ES11 的可选链运算符 ,location.state?.from安全地读取可能不存在的嵌套属性,避免了Cannot read property 'from' of undefined。
十一、404 页面:通配符路由的兜底艺术
jsx
{/* * 放在最后,兜底所有未匹配的路径 */}
<Route path="*" element={<NotFound />} />
jsx
// src/pages/NotFound/index.jsx
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const NotFound = () => {
let navigate = useNavigate();
useEffect(() => {
// 3 秒后自动跳回首页
setTimeout(() => navigate('/'), 3000);
}, []);
return <h1>Not Found</h1>;
}
注意顺序 :
path="*"必须放在<Routes>的最后 。React Router 按顺序匹配,*在前面会吞掉所有路由。
十二、完整路由架构一览
把以上所有知识点串起来,就是这个项目的完整路由树:
bash
<Router> // 路由容器
<Suspense fallback={Loading}> // 懒加载兜底
<Navigation /> // 全局导航(不受路由切换影响)
<Routes> // 路由匹配区
/ → Home
/about → About
/user/:id → UserProfile (动态路由)
/products → Products + Outlet (嵌套路由)
/products/:productId → Detail
/products/new → NewProduct
/old-path → Navigate → /new-path (重定向)
/login → Login (登录页)
/pay → ProtectRoute → Pay (鉴权守卫)
* → NotFound (404 兜底)
</Routes>
</Suspense>
</Router>
十三、总结:一张图理解 React Router
ini
┌─────────────────────────────────────────────────┐
│ <Router> │
│ ┌──────────┐ ┌──────────────────────────────┐ │
│ │Navigation│ │ <Routes> │ │
│ │ (始终显示)│ │ path="/" → <Home /> │ │
│ │ │ │ path="/about"→ <About /> │ │
│ │ <Link> │ │ path="/user/:id" → 动态路由 │ │
│ │ <Link> │ │ path="/products" → 嵌套+Outlet│ │
│ │ <Link> │ │ path="/pay" → 鉴权守卫 │ │
│ │ │ │ path="*" → 404 兜底 │ │
│ └──────────┘ └──────────────────────────────┘ │
└─────────────────────────────────────────────────┘
| 概念 | 一句话总结 |
|---|---|
| BrowserRouter | 基于 History API,URL 干净,生产项目首选 |
| Routes + Route | 组件式配置,声明即路由 |
| Link | SPA 的 <a> 标签替代品,不刷新页面 |
| lazy + Suspense | 按需加载页面代码,优化首屏速度 |
| useParams | 从 URL 中取出动态参数 :id |
| Outlet | 嵌套路由的子组件"插槽" |
| Navigate | 声明式重定向 |
| useNavigate | 命令式跳转(事件处理中调用) |
| useLocation | 获取当前 URL 和路由 state |
| ProtectRoute | children 模式实现鉴权守卫 |
| path="*" | 通配符兜底 404 |
下一步
如果你是从上一篇 Hash 路由文章过来的,现在应该能清晰地看到技术演进的脉络:
bash
原生 hashchange 事件
↓
手写 HashRouter 类
↓
React Router(hash 模式)
↓
React Router(history 模式)+ 懒加载 + 鉴权 + 嵌套路由
下一步可以深入学习:
- React Router 源码:它的路由匹配算法是怎么实现的?
- SSR 路由:Next.js 的文件系统路由
- 状态管理联动:zustand + React Router 的配合
如果这篇文章对你有帮助,欢迎点赞、收藏、评论🎉 你的支持是我持续输出的动力!