🛡️ React Router 7 路由守卫与懒加载:没登录,就别想进支付页
学习日志 · 2026-08-20 基于
jwt-demo/login-demo实战,用 17 行代码实现「访问保护页自动跳登录」,外加 lazy + Suspense 做按需加载。
📑 目录
- 需求场景
- 路由架构总览
- 路由守卫:RequireAuth 组件
- 登录后回跳来源页
- 懒加载:lazy + Suspense
- 导航栏的条件渲染
- 前端守卫的边界:安全不等于前端的事
- 进阶:基于角色的授权控制
- 踩坑记录
- 总结
1. 需求场景
支付页 /pay 属于「受保护页面」:
未登录用户直接访问
/pay→ 自动跳转到/login; 已登录用户访问/pay→ 正常进入。
类似场景还有:购物车、订单、个人中心……凡是需要登录才能看的内容,都需要「路由守卫」拦截。
用户访问 /pay
│
▼
有 token 吗?
├── 没有 → <Navigate to="/login" replace /> 🔒 拦下
└── 有 → 正常渲染 <Pay /> ✅ 放行
2. 路由架构总览
src/App.jsx 是整个应用的路由中枢:
import { BrowserRouter as Router } from 'react-router-dom';
import { Route, Routes } from 'react-router-dom';
import React, { lazy, Suspense, useEffect } from 'react';
import Nav from './components/Nav';
import RequireAuth from './components/RequireAuth';
// 页面组件全部懒加载
const Home = lazy(() => import('./pages/Home'));
const Login = lazy(() => import('./pages/Login'));
const Pay = lazy(() => import('./pages/Pay'));
function App() {
return (
<Router>
<Nav />
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
{/* 受保护路由:外面包一层 RequireAuth */}
<Route path="/pay" element={
<RequireAuth>
<Pay />
</RequireAuth>
} />
</Routes>
</Suspense>
</Router>
);
}
| 成员 | 作用 |
|---|---|
Router(BrowserRouter) | 声明路由模式,用 History API 管理 URL |
Routes / Route | 声明式路由表,path + element 一一对应 |
RequireAuth | 自定义路由守卫,包裹受保护页面 |
lazy + Suspense | 页面级代码分割,按需加载 |
Nav | 全局导航栏,常驻所有页面 |
💡 注意:守卫是用 JSX 包裹的方式生效的——
<RequireAuth><Pay /></RequireAuth>。想要受保护的页面,套一层壳就行,非常优雅。
3. 路由守卫:RequireAuth 组件
src/components/RequireAuth.jsx 是核心:
import { Navigate } from 'react-router-dom';
import { useAuthStore } from '../store/user';
function RequireAuth({ children }) {
// 从全局 store 读当前 token(见 Zustand 篇)
const token = useAuthStore((state) => state.token);
// 没登录 → 重定向到登录页(replace 替换历史记录,不留「后退陷阱」)
if (!token) {
return <Navigate to="/login" replace />;
}
// 已登录 → 正常渲染子内容
return <>{children}</>;
}
export default RequireAuth;
3.1 关键点逐条拆解
| 关键点 | 说明 |
|---|---|
children | 被保护的路由内容,通过 props 传入 |
useAuthStore(state => state.token) | 精确订阅 token,登录/登出时自动触发重新判断 |
<Navigate to="/login" replace /> | 声明式重定向组件,渲染它即跳转 |
replace | 替换当前历史记录,而不是 push 新记录 |
3.2 为什么用 replace?
想象没有 replace 的后果:
用户点「后退」→ 回到 /pay
→ 守卫又判断没登录
→ 又跳 /login
→ 又后退…… 死循环陷阱 🔁
replace 把 /pay 这条历史记录原地替换成 /login,后退就回不到被拦截的页面了。
3.3 运行时效果
未登录:
直接输入网址 /pay
▼
RequireAuth 发现 token 为空
▼
渲染 <Navigate to="/login" replace />
▼
浏览器 URL 变成 /login,显示登录页
登录成功后(Login 里 setAuth + navigate):
重新访问 /pay
▼
token 已存在 → 渲染 <Pay /> 放行 ✅
登录态变化 → store 里的 token 变化 → 守卫组件重新渲染 → 放行。整个判断是「响应式」的,不需要手动刷新页面。
4. 登录后回跳来源页
体验好的登录流程应该是:我从哪来,登录完回哪去。
Login.jsx 已经做了这个准备:
const location = useLocation();
const from = location.state?.from || '/'; // 默认回首页
// 登录成功
setAuth({ token: res.token, user: res.user });
navigate(from, { replace: true }); // 跳回来源页
from 的来源应该是守卫在重定向时通过 state 带过去的:
// ✅ 推荐的完整版守卫写法
if (!token) {
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
}
| 写法 | 行为 |
|---|---|
| 本项目现状(只传 to) | 登录后固定回首页 / |
带上 state={{ from: location.pathname }} | 登录后回被拦截的那个页面 |
📌 这里可以算一个「待优化点」:本项目守卫没传
from,所以Login里读到的永远是null → '/'。加上state传参,就是完整的「来源页回跳」。
5. 懒加载:lazy + Suspense
页面组件如果全部打包进一个 JS 文件,首屏会越来越重。懒加载把每个页面拆成独立 chunk,用到才加载。
5.1 普通引入 vs 懒加载
// ① 普通引入:进 App.jsx 时立即加载全部页面
import Home from './pages/Home';
import Login from './pages/Login';
import Pay from './pages/Pay';
// ② 懒加载:只有路由命中时才加载对应页面
const Home = lazy(() => import('./pages/Home'));
const Login = lazy(() => import('./pages/Login'));
const Pay = lazy(() => import('./pages/Pay'));
5.2 Suspense 兜底「加载中」
lazy 的组件在「加载完成前」是一个「悬空状态」,必须用 Suspense 包裹,并提供 fallback:
<Suspense fallback={<div>Loading...</div>}>
<Routes>
<Route path="/" element={<Home />} />
...
</Routes>
</Suspense>
首次访问 /pay
│
▼
按需下载 pay 的 chunk(网络请求)
│
▼
下载期间显示 <div>Loading...</div>(fallback)
│
▼
下载完成 → 渲染真正的 <Pay /> 页面
💡 收益:首屏只加载必要的代码,
Login、Pay等页面「用到再下载」,体积和首屏时间都更友好。生产构建后dist里会看到每个页面一个独立的 JS 文件。
6. 导航栏的条件渲染
src/components/Nav.jsx 根据登录态动态渲染导航:
function Nav() {
const token = useAuthStore((state) => state.token);
const user = useAuthStore((state) => state.user);
const logout = useAuthStore((state) => state.logout);
return (
<nav style={{ padding: 0, borderBottom: '1px solid #ccc' }}>
<Link to="/">Home</Link>
<Link to="/pay">Pay</Link>
{/* 未登录才显示「登录」入口 */}
{!token && <Link to="/login">Login</Link>}
{/* 已登录显示用户名 */}
{user && <span>Hello, {user.username}!</span>}
{/* 已登录显示退出按钮 */}
{token && <button onClick={logout}>Logout</button>}
</nav>
);
}
| 条件 | 渲染结果 |
|---|---|
| 未登录 | Home · Pay · Login |
| 已登录 | Home · Pay · Hello, admin! · Logout |
logout() 一执行,store 清空 → token 变空 → 导航栏立即切换回「未登录」形态。状态驱动 UI,全程无手动刷新。
🔗 这里用
Link而不是<a>:Link是 SPA 内部跳转,不刷新页面;<a>会整页刷新,把内存里的状态全冲掉。
7. 前端守卫的边界
必须反复强调:
⚠️ 前端路由守卫只是「体验优化」,不是「安全屏障」。
因为:
- 用户可以直接在地址栏敲任何 URL;
- 用户可以用 DevTools 改 localStorage 塞一个假 token;
- 真正的数据都在后端接口里,后端必须自己鉴权(见 JWT 篇的
/api/repo校验)。
前端守卫:拦住「小白用户」进界面体验 ← 体验层
后端鉴权:拦住「一切拿不到合法 token」的数据请求 ← 安全层
所以正确姿势是双保险:
- 前端守卫管「页面跳转体验」;
- 后端接口管「数据安全」——没有合法 token,接口直接 401。
8. 进阶:基于角色的授权控制
本项目的守卫只判断「有没有登录」(token 是否存在)。真实系统往往要判断「有没有权限」——比如 Pay 页只有 role: 'admin' 能进。
思路是「守卫 + 角色」双检查:
// 伪代码:基于角色的守卫
function RequireRole({ role, children }) {
const user = useAuthStore((state) => state.user);
if (!user) return <Navigate to="/login" replace />;
if (user.role !== role) return <Navigate to="/" replace />; // 没权限回首页
return <>{children}</>;
}
// 用法
<Route path="/pay" element={
<RequireRole role="admin">
<Pay />
</RequireRole>
} />
本项目 JWT 签发时已经带了 role: 'admin' 声明,扩展成角色守卫只是「多判一层」的事:
const token = jwt.sign(
{ username: body.username, role: 'admin' }, // role 就在这里
secret,
{ expiresIn: 86400 }
);
9. 踩坑记录
⚠️ 踩坑 1:忘记包 Suspense
lazy 组件没包 Suspense,React 会直接抛错(约定:lazy 必须在 Suspense 边界内)。报错信息类似 A component suspended while responding to synchronous input。
⚠️ 踩坑 2:守卫没传 from,登录回跳失效
如第 4 节所述,守卫最好带上 state={{ from: location.pathname }},否则 Login 的 from 永远是 '/'。
⚠️ 踩坑 3:别用 <a> 做站内跳转
SPA 里站内导航一律用 Link / useNavigate。用 <a> 会整页刷新,store 内存状态全丢,等于每次跳转都要重新初始化。
⚠️ 踩坑 4:登录态判断别只看 localStorage
组件里直接 localStorage.getItem('token') 判断,改了也不会触发重渲染。要用 store 的响应式状态(useAuthStore),变化才会驱动 UI 更新。
10. 总结
这次学习把「前端登录鉴权的最后一公里」补齐了:
- RequireAuth 守卫:
<Navigate>重定向 + 订阅 store 的 token,17 行实现页面保护; - 登录回跳:
state.from+navigate(from),从哪来回哪去; - 懒加载:
lazy + Suspense按需加载页面,优化首屏; - Nav 条件渲染:状态驱动导航栏形态;
- 边界认知:前端守卫管体验,后端接口管安全,双保险缺一不可。
一句话总结:路由守卫 = 「读全局登录态 + 没登录就声明式重定向」,再加上懒加载和来源页回跳,就是一套完整的 React 前端鉴权导航体系。