🛡️ React Router 7 路由守卫与懒加载:没登录,就别想进支付页

10 阅读7分钟

🛡️ React Router 7 路由守卫与懒加载:没登录,就别想进支付页

学习日志 · 2026-08-20 基于 jwt-demo/login-demo 实战,用 17 行代码实现「访问保护页自动跳登录」,外加 lazy + Suspense 做按需加载。


📑 目录

  1. 需求场景
  2. 路由架构总览
  3. 路由守卫:RequireAuth 组件
  4. 登录后回跳来源页
  5. 懒加载:lazy + Suspense
  6. 导航栏的条件渲染
  7. 前端守卫的边界:安全不等于前端的事
  8. 进阶:基于角色的授权控制
  9. 踩坑记录
  10. 总结

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. 前端守卫的边界

必须反复强调:

⚠️ 前端路由守卫只是「体验优化」,不是「安全屏障」。

因为:

  1. 用户可以直接在地址栏敲任何 URL;
  2. 用户可以用 DevTools 改 localStorage 塞一个假 token;
  3. 真正的数据都在后端接口里,后端必须自己鉴权(见 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. 总结

这次学习把「前端登录鉴权的最后一公里」补齐了:

  1. RequireAuth 守卫:<Navigate> 重定向 + 订阅 store 的 token,17 行实现页面保护;
  2. 登录回跳:state.from + navigate(from),从哪来回哪去;
  3. 懒加载:lazy + Suspense 按需加载页面,优化首屏;
  4. Nav 条件渲染:状态驱动导航栏形态;
  5. 边界认知:前端守卫管体验,后端接口管安全,双保险缺一不可。

一句话总结:路由守卫 = 「读全局登录态 + 没登录就声明式重定向」,再加上懒加载和来源页回跳,就是一套完整的 React 前端鉴权导航体系。