前言
在 React 项目开发中,React Router v6 是路由管理的标配方案。从基础的页面跳转、嵌套路由,到进阶的权限拦截、性能优化,再到通用组件的封装思路,几乎每个中后台项目都会完整覆盖这些场景。
本文把开发中最高频的知识点一次性梳理透彻:一级 / 二级路由怎么区分、Outlet 的作用、useParams 踩坑点、路由重定向的三大业务场景、懒加载标准三步走、登录鉴权守卫完整实现、state/from/replace 底层原理,最后延伸到通用 Modal 弹窗组件的抽象封装思路。全文结合实战代码与高频踩坑总结,新手也能一步到位吃透核心逻辑。
一、路由基础:一级路由与二级嵌套路由
很多初学者最困惑的第一个问题:怎么判断一个路由是一级还是二级?核心判断标准只有一个:看它是直接写在 <Routes> 下,还是被包裹在另一个 <Route> 内部。
1.1 一级路由(顶层路由)
直接作为 <Routes> 的直接子元素,路径以 / 开头,对应一个完整的独立页面。
jsx
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/login" element={<Login />} />
</Routes>
一级路由的特点:跳转时整个页面内容全部替换,是最基础的路由层级。
1.2 二级路由(嵌套子路由)
写在另一个 <Route> 标签内部,也叫嵌套路由。父路由提供页面公共部分,子路由渲染在父页面的指定插槽位置。
路由配置写法:
jsx
<Routes>
{/* 父路由:一级路由 */}
<Route path="/products" element={<Products />}>
{/* 子路由:二级路由,path 不要加开头的 / */}
<Route path=":productId" element={<ProductDetail />} />
<Route path="new" element={<NewProduct />} />
</Route>
</Routes>
1.3 核心组件:Outlet
二级路由必须配合 <Outlet /> 才能生效,它就是嵌套路由的「渲染插槽」。父组件里写在哪里,子页面就渲染在哪里。
父组件 Products 代码:
jsx
import { Outlet } from 'react-router-dom';
const Products = () => {
return (
<>
<h1>产品列表(父页面公共内容,始终保留)</h1>
{/* 子路由页面渲染到这里 */}
<Outlet />
</>
);
};
export default Products;
访问 /products/123 时,页面上半部分保留产品列表标题,下半部分渲染产品详情;访问 /products/new 则渲染新建产品表单。这就是嵌套路由最典型的应用:公共布局不变,局部内容切换。
1.4 一级与二级路由对比表
表格
| 维度 | 一级路由 | 二级(嵌套)路由 |
|---|---|---|
| 位置 | 直接在 <Routes> 下 | 包裹在另一个 Route 内部 |
| path 写法 | 必须以 / 开头 | 不能写开头的 /,自动拼接父路径 |
| 渲染方式 | 替换整个页面 | 插入到父组件的 Outlet 位置 |
| 依赖 | 无 | 父组件必须存在 Outlet,否则子页面不显示 |
| 典型场景 | 独立页面跳转 | 列表 + 详情、布局 + 子页面 |
新手最高频坑:写了嵌套路由却忘了在父组件加
<Outlet />,导致子路由页面完全看不见,排查半天找不到原因。
二、动态路由参数:useParams 用法与避坑
当路由路径中有可变部分时(比如商品 ID、用户 ID),就需要动态路由,配合 useParams 钩子读取参数。
2.1 基础用法
路由配置中用 :参数名 定义动态段:
jsx
<Route path="/products/:productId" element={<ProductDetail />} />
组件内读取参数:
jsx
import { useParams } from 'react-router-dom';
const ProductDetail = () => {
// 解构取出参数,名字必须和路由里 : 后面一致
const { productId } = useParams();
return <h3>产品详情:{productId}</h3>;
};
export default ProductDetail;
2.2 必记的三个坑
- 永远是字符串类型哪怕地址栏写的是数字,
useParams拿到的也一定是字符串。需要数字类型时必须手动转换:
jsx
const id = Number(productId);
- 参数名必须严格对应路由里写
:productId,解构就必须写productId,大小写、拼写错一个,拿到的就是undefined。 - 只能在组件顶层调用不能写在
if、for、useEffect内部,必须遵守 React Hooks 调用规则。
2.3 典型业务场景
拿到 ID 后,通常配合 useEffect 发起后端请求,获取对应详情数据:
jsx
useEffect(() => {
// 根据 productId 请求商品详情
fetchProductDetail(productId);
}, [productId]);
三、路由重定向与 404 兜底:Navigate 的实战场景
<Navigate> 组件是 React Router v6 里做重定向的核心方式,一渲染就会立即跳转,常见有三大业务场景。
3.1 场景一:活动 / 链接过期跳转
运营活动结束后,旧地址不能直接 404,需要自动跳转到结果页或活动首页。
jsx
<Route
path="/game/2024summer"
element={<Navigate to="/game/result" replace />}
/>
3.2 场景二:网站改版,旧地址兼容
项目重构调整了 URL 规范,但用户收藏夹、外部推广链接还是旧地址,需要做兼容跳转。
jsx
{/* 旧首页 /home 重定向到新根路径 */}
<Route path="/home" element={<Navigate to="/" replace />} />
3.3 场景三:权限拦截跳转
这是最常用的场景,未登录用户访问受保护页面时,自动跳转到登录页。详细实现会在后面鉴权守卫部分展开。
3.4 404 兜底路由
用 path="*" 匹配所有未命中的地址,必须写在所有路由的最后面:
jsx
<Routes>
{/* 其他正常路由 */}
<Route path="*" element={<NotFound />} />
</Routes>
注意:404 是直接渲染页面,不属于重定向;如果需要把非法地址统一跳首页,也可以写成
<Navigate to="/" replace />。
3.5 replace 属性的作用
replace 控制浏览器历史记录的写入方式:
- 不加
replace(默认push):新增一条历史记录,点后退能回到旧地址 - 加
replace:替换当前这条历史记录,点后退不会回到旧地址
重定向场景几乎都建议加 replace,避免用户点后退又跳回过期 / 无权限的页面,造成死循环。
四、性能优化:路由懒加载标准三步走
页面多了之后,首屏会加载所有页面的 JS 文件,导致首屏变慢。路由懒加载就是访问哪个页面,才下载哪个页面的代码,是前端性能优化的基础操作。
第一步:引入 API
从 React 中导入 lazy 和 Suspense:
jsx
import { lazy, Suspense } from 'react';
第二步:改写导入方式
把普通的静态 import 改成 lazy 动态导入:
jsx
// ❌ 静态导入,首屏全部加载
import Home from './pages/Home';
import About from './pages/About';
// ✅ 懒加载写法
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const NotFound = lazy(() => import('./pages/NotFound'));
第三步:用 Suspense 包裹路由
页面 JS 下载过程中需要一个加载态,用 Suspense 的 fallback 提供加载提示:
jsx
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div>页面加载中...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
注意事项
lazy只能用于默认导出(export default)的组件Suspense可以直接包裹整个 Routes,不用每个路由单独包- 小组件、公共组件不用做懒加载,反而会增加网络请求开销
五、进阶实战:登录鉴权守卫 ProtectRoute 完整实现
登录鉴权是中后台系统的标配需求:未登录不能访问核心页面,登录后自动跳回用户原本想去的页面。我们封装一个通用的路由守卫组件来实现。
5.1 守卫组件封装
ProtectRoute 本质就是一个「门卫组件」:判断登录状态,已登录放行页面,未登录重定向到登录页。
jsx
import { Navigate, useLocation } from 'react-router-dom';
const ProtectRoute = ({ children }) => {
const location = useLocation();
// localStorage 存的都是字符串,必须和字符串 'true' 比较
const isLogin = localStorage.getItem('isLogin') === 'true';
if (!isLogin) {
// 未登录:跳登录页,把当前路由信息藏在 state 里带过去
return (
<Navigate
to="/login"
state={{ from: location }}
replace
/>
);
}
// 已登录:渲染被保护的页面
return children;
};
export default ProtectRoute;
5.2 路由中使用守卫
给需要登录权限的页面外层包裹守卫组件即可:
jsx
<Routes>
<Route path="/login" element={<Login />} />
{/* 需要登录的页面,包上 ProtectRoute */}
<Route
path="/posts/new"
element={
<ProtectRoute>
<NewPost />
</ProtectRoute>
}
/>
</Routes>
5.3 state、from、replace 深度解析
1. state 是什么
一句话理解:state 是路由跳转时的隐藏传参小纸条,数据不会显示在浏览器地址栏里,目标页面通过 useLocation() 读取。和地址栏 query 参数相比,它支持传对象、数组等复杂类型,且地址栏更干净。
在鉴权场景中,我们用它传递「用户本来想去哪个页面」,实现登录后原路返回。
2. from 的两种传法,千万别搞混
这是最高频的踩坑点,两种传法对应两种读取方式,写错直接报错。
表格
| 传递内容 | 守卫写法 | 登录页读取方式 | 特点 |
|---|---|---|---|
| 完整路由对象 | state={{ from: location }} | location.state?.from?.pathname | 信息完整,推荐使用 |
| 纯路径字符串 | state={{ from: location.pathname }} | location.state?.from | 写法简单,丢失查询参数 |
❌ 典型错误:传的是字符串
location.pathname,读取时却写location.state.from.pathname。字符串没有pathname属性,会直接抛出错误导致页面白屏。
3. replace 为什么两处都要加
登录流程有两次跳转,两处都建议加 replace:
- 守卫跳登录页:替换历史记录,防止用户点后退又回到受保护页面,触发循环拦截
- 登录成功跳转:替换登录页的历史记录,防止登录成功后点后退又回到登录页,造成用户困惑
5.4 登录页完整实现
jsx
import { useNavigate, useLocation } from 'react-router-dom';
const Login = () => {
const navigate = useNavigate();
const location = useLocation();
// 读取来源地址:可选链防止 state 不存在时报错
// 没有来源地址时,兜底跳首页
const from = location.state?.from?.pathname || '/';
const handleSubmit = (e) => {
e.preventDefault();
// 模拟登录校验
const loginSuccess = true;
if (loginSuccess) {
// 写入登录标记
localStorage.setItem('isLogin', 'true');
// 登录成功,跳回来源页,替换历史记录
navigate(from, { replace: true });
} else {
alert('用户名或密码错误');
}
};
return (
<form onSubmit={handleSubmit}>
<input type="text" placeholder="用户名" />
<input type="password" placeholder="密码" />
<button type="submit">登录</button>
</form>
);
};
export default Login;
必加的可选链 ?.
location.state?.from?.pathname 是关键的容错保护。如果用户直接手动输入 /login 进入登录页,没有经过守卫拦截,location.state 就是 undefined。不用可选链直接访问会直接报错白屏,|| '/' 则做了兜底默认值。
六、组件抽象思路:Modal 弹窗通用封装
讲完路由,延伸一个通用组件的封装思路。Modal 弹窗是项目里最高频的组件之一,登录框、确认框、表单弹窗都要用,抽象成通用组件能大幅减少重复代码。
6.1 核心封装思路
- 结构分层:弹窗分为遮罩层 Mask 和窗体 Body 两部分,窗体再可拆分为头部、内容、底部
- 插槽定制:用
props.children做内容插槽,弹窗外壳通用,内部业务内容由外部传入 - 受控模式:显示隐藏状态由父组件管理,子组件只负责展示和回调通知,不自己维护状态
6.2 通用 Modal 组件代码
jsx
import './Modal.css';
const Modal = ({ visible, onClose, children }) => {
// 控制显示隐藏
if (!visible) return null;
// 点击弹窗内容区阻止冒泡,避免点内容也关闭弹窗
const handleContentClick = (e) => e.stopPropagation();
return (
<>
{/* 遮罩层:点击关闭 */}
<div className="modal-mask" onClick={onClose}>
{/* 弹窗窗体 */}
<div className="modal-body" onClick={handleContentClick}>
<button className="modal-close" onClick={onClose}>×</button>
{/* 内容插槽,外部传入任意业务内容 */}
<div className="modal-content">{children}</div>
</div>
</div>
</>
);
};
export default Modal;
配套基础样式:
css
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
}
.modal-body {
background: #fff;
border-radius: 8px;
min-width: 400px;
padding: 20px;
position: relative;
}
.modal-close {
position: absolute;
top: 10px;
right: 15px;
border: none;
background: none;
font-size: 20px;
cursor: pointer;
}
6.3 父组件使用
jsx
import { useState } from 'react';
import Modal from './components/Modal';
function App() {
const [showModal, setShowModal] = useState(false);
return (
<div>
<button onClick={() => setShowModal(true)}>打开确认弹窗</button>
<Modal visible={showModal} onClose={() => setShowModal(false)}>
{/* 自定义业务内容 */}
<h3>确认删除</h3>
<p>删除后数据不可恢复,确定继续吗?</p>
<button onClick={() => setShowModal(false)}>取消</button>
<button>确认删除</button>
</Modal>
</div>
);
}
6.4 封装避坑点
- 不要在 Modal 内部写 useState 控制显示,状态交给父组件管理,即受控组件模式,否则外部无法控制弹窗
- 弹窗内容区要阻止冒泡,否则点击弹窗内部也会触发遮罩的关闭事件
- 用 children 做内容插槽,不要把业务逻辑写死在 Modal 组件里,才能实现一处封装多处复用
七、高频踩坑汇总
- 嵌套路由不生效:父组件忘记写
<Outlet />,或子路由 path 多加了开头的/ - useParams 拿不到值:参数名和路由里
:后面的名字不一致,或忘记解构 - 登录后白屏报错:state 传了字符串却用
.pathname读取,或遗漏可选链?. - 后退逻辑混乱:重定向和登录跳转没加
replace,造成循环跳转或回到登录页 - localStorage 判断永远为假:直接和布尔值
true比较,忽略了 localStorage 存储的都是字符串 - Modal 点击内容也关闭:没有阻止内容区的点击事件冒泡
- 懒加载报错:忘记用
Suspense包裹,或组件不是默认导出
总结
从基础路由到进阶鉴权,再到通用组件封装,这一套流程覆盖了 React 项目 80% 的日常开发场景。核心可以归纳为三条主线:
- 路由体系:一级 / 二级路由 + Outlet 嵌套 + useParams 动态参数 + Navigate 重定向
- 优化与鉴权:lazy + Suspense 懒加载三步走 + ProtectRoute 守卫 + state/from/replace 原路返回
- 组件思想:抽离公共外壳、用 children 做插槽、受控模式管理状态
把这些核心逻辑理解透,不仅能搞定日常业务开发,也能避开绝大多数新手坑,写出更规范、更易维护的代码。