React Router 7全局路由保护实战:组件守卫、Loader与Middleware详解
手头这个中后台项目第一版是没做登录的页面全裸在外网。等到业务数据跑起来需求方轻飘飘一句话所有页面都要登录才能看管理员页面只能管理员进。这种活儿干过的人都知道代码放不对位置后面权限需求一多起来整个路由层就会变得又脏又乱。我最近把项目从 react-router-dom v6 升到了 React Router 7路由保护这块基本重写了一遍。这篇文章不聊大道理就把我在 React Router 7 下做全局路由保护的完整思路、三种落地方案和踩坑记录整理出来。适合正在给中后台项目加权限逻辑的开发者也适合刚开始用 v7 数据路由、对 loader 和 middleware 还不太熟的同行。1. 升级到 React Router 7 之后路由保护的核心思路变了很多人在 v6 时代已经习惯了写一个RequireAuth组件把需要保护的页面塞进去。这个思路在 v7 里依然能用但 React Router 7 本身已经把数据路由推到了主位路由保护这件事的思路也跟着变了。如果只是把旧代码搬过来等于浪费了 v7 的一堆新能力。1.1 v7 到底改了什么和路由保护有什么关系先梳理一下 v7 的关键变化因为这些变化直接决定路由保护的写法。第一包合并了。react-router-dom 和 react-router 不再需要分开装和分开引用v7 里统一从react-router导入。你原来的import { BrowserRouter, Routes, Route } from react-router-dom要改成从react-router导入打包体积和使用体验都会好一些。第二数据路由成了默认推荐。createBrowserRouter从 v6.4 开始引入到 v7 已经是默认主流。数据路由的核心是路由不只是匹配到什么组件而是可以在路由对象上挂loader、action、errorElement这些配置项让路由在组件渲染之前就完成数据加载、接口校验、状态判断等工作。第三中间件能力来了。React Router 7 后续版本开始支持middleware可以在 loader/action 之前执行逻辑。这一点对全局路由保护是重大利好相当于路由层内置了守卫的原生能力比我们自己用组件包一层要干净得多。第四v7 对 React 版本有要求项目需要 React 18 及以上推荐 React 19。如果你还停在 React 17升级 React Router 7 之前得先把 React 版本提上来。1.2 渲染后拦截与渲染前拦截的本质区别这是理解路由保护方案的关键分水岭。组件守卫方案属于渲染后拦截。用户点击链接React Router 匹配到目标路由渲染RequireAuth组件组件在自己内部判断有没有登录态没有就渲染Navigate to/login再跳走。本质上是页面已经走到浏览器这一步了在最后一关把它拦回来。loader 守卫方案属于渲染前拦截。导航发生时React Router 先执行目标路由的 loaderloader 里去做鉴权判断不通过直接抛出一个redirect(/login)整个目标页面根本不会被渲染。用户在视觉上就感觉跳过去了中间不会闪现任何目标页面内容。两种方案在大多数简单场景下都能用但差异很实际对比维度组件守卫loader 守卫执行时机目标组件已匹配渲染前一刻路由匹配完成后组件渲染前页面闪现处理不好会闪一下登录前的内容不会闪现数据状态需要自己维护初始化状态天然等待 loader 完成权限数据传递通过组件 props 或外部 store通过useLoaderData直接给页面和 v7 数据路由的契合度一般高我见过不少团队在 v6 时代写组件守卫升级到 v7 之后继续沿用甚至把 loader 也写了却不做鉴权等到组件里再去判断。这样不是不行而是没把数据路由的价值用起来。后面几节我会把三种方案都详细展开你根据自己的项目阶段来选。2. 方案一组件守卫适合快速改造 v6 老项目如果你的项目刚从 v6 升过来路由还是一大堆RoutesRoute最稳的改造方式是先写一个守卫组件把全局的登录校验、角色校验集中在一处。这个方案不改路由结构改动量最小当天就能上。2.1 设计思路一个组件包住所有受保护页面核心思路是用一个布局式路由把所有需要登录的页面包起来守卫组件作为这个父路由的element通过Outlet /渲染子路由。比在每一个页面组件里复制粘贴一段if (!token) navigate(/login)要优雅得多。新增页面只需要放进受保护的父路由里守卫逻辑不需要改任何代码。这就是全局路由保护的含义保护逻辑只写一遍所有子路由继承。// RequireAuth.tsx import { Navigate, Outlet, useLocation } from react-router; import { useAuthStore } from /stores/authStore; export default function RequireAuth() { const { token, user, initialized } useAuthStore(); const location useLocation(); // 登录态初始化完成前不要做任何跳转判断 if (!initialized) { return div classNamepage-loading正在加载用户信息.../div; } // 未登录重定向到登录页并且记住用户原本想去哪 if (!token) { return ( Navigate to/login state{{ from: location.pathname location.search }} replace / ); } // 管理员专属页面普通用户直接进 403 if ( user?.role ! admin location.pathname.startsWith(/admin) ) { return Navigate to/403 replace /; } return Outlet /; }路由配置// App.tsx import { Routes, Route } from react-router; import RequireAuth from /components/RequireAuth; import LoginPage from /pages/LoginPage; import Dashboard from /pages/Dashboard; import AdminUsers from /pages/admin/AdminUsers; import Forbidden from /pages/Forbidden; export default function App() { return ( Routes Route path/login element{LoginPage /} / Route path/403 element{Forbidden /} / Route element{RequireAuth /} Route path/dashboard element{Dashboard /} / Route path/admin/users element{AdminUsers /} / /Route /Routes ); }这个写法有两个细节值得注意。一是state{{ from: location.pathname location.search }}。用户未登录访问/dashboard?type1时被带到了登录页登录成功之后要把用户送回他原本想去的地址而不是永远送他去首页。这个记住来源地址的细节决定了权限体验的完整度。二是守卫组件里不能只检查 token必须判断initialized。登录态通常存储在全局 store 里初始值可能是 null 或 false等页面刷新的瞬间如果 token 还没从 localStorage 加载回来会出现明明已登录却被判定为未登录的误杀。初始化完成之后再放行是防止页面刷新后退回登录页的关键。2.2 一个容易被忽略的问题多个守卫嵌套时的顺序中后台项目经常有这样的需求/dashboard 需要登录/admin 下的页面不仅需要登录还需要管理员角色而 /admin/settings 还额外要求是超级管理员。这时候不要为了搞花样去创建好几个守卫组件最好是同一个守卫里做角色判断或者用一个可配置权限的守卫工厂。// RequireAuth.tsx增强版 import { Navigate, Outlet, useLocation } from react-router; import { useAuthStore } from /stores/authStore; type Permission { /** 需要登录 */ auth?: boolean; /** 允许的角色列表缺省表示不限角色 */ roles?: string[]; }; export default function RequireAuth({ auth true, roles [] }: Permission) { const { token, user, initialized } useAuthStore(); const location useLocation(); if (!initialized) { return div classNamepage-loading正在加载用户信息.../div; } if (auth !token) { return Navigate to/login state{{ from: location.pathname location.search }} replace /; } if (roles.length 0 user !roles.includes(user.role)) { return Navigate to/403 replace /; } return Outlet /; }用法Route element{RequireAuth /} Route path/dashboard element{Dashboard /} / /Route Route element{RequireAuth roles{[admin]} /} Route path/admin/users element{AdminUsers /} / /Route Route element{RequireAuth roles{[super_admin]} /} Route path/admin/super-settings element{SuperSettings /} / /Route嵌套守卫逻辑清晰以后权限配置调整只需要改 roles 数组不需要新建组件。2.3 直接渲染 Navigate 还是 useEffect 里跳转很多刚接触 React Router 的开发者会纠结应该直接渲染Navigate还是在useEffect里调用navigate()我的结论是纯路由跳转直接渲染Navigate完全没问题。React Router 的Navigate组件内部已经处理了渲染期间触发导航这个动作React 18 严格模式下面也不会有问题。useEffectnavigate()适合需要做副作用的场景比如跳转之前要上报、要写 localStorage、要清理页面状态等。下面这个例子是登录页带上回跳地址的处理// LoginPage.tsx import { useEffect } from react; import { useLocation, useNavigate } from react-router; export default function LoginPage() { const navigate useNavigate(); const location useLocation(); const from (location.state as { from?: string })?.from ?? /dashboard; const handleLogin async () { await loginAPI(); navigate(from, { replace: true }); }; return ( div h1登录/h1 button onClick{handleLogin}登录/button /div ); }这里有个非常常见的问题from可能是/admin/users?page2这种带 query 的字符串直接navigate(from)是支持的因为 React Router 的navigate接受字符串。但用useEffect必须注意依赖数组不然登录成功后连续跳两次第二次会把首次跳转覆盖成空让页面直接白屏。3. 方案二loader 守卫让鉴权提前到路由匹配阶段组件守卫虽然直观但它有个天然短板目标页面的数据和资源可能在鉴权通过之前就被请求了。比如你在组件里写了useEffect请求数据守卫组件和页面组件是并行渲染的严格来说不是先鉴权后加载页面。对于大多数内部系统来说这点性能损耗可以忽略但如果你的页面里有些敏感接口或者你想让权限判断在用户看到页面骨架之前就完成loader 守卫是更好的选择。3.1 为什么 loader 是比组件判断更可靠的防线在数据路由里loader 是路由匹配之后、组件渲染之前执行的一个钩子。loader 执行完拿到数据之后组件才开始渲染。这个时序意味着loader 里决定重定向的瞬间目标组件根本不存在不存在组件里先发请求再被拦下来的尴尬。另外 loader 的返回值会注册到路由上下文里页面组件通过useLoaderData()直接拿不需要再通过 store 或 props 传递少了一层状态同步的麻烦。3.2 全局 loader 守卫的两种写法先看最直接的写法用一个父路由统一挂 loader// router.tsx import { createBrowserRouter, redirect } from react-router; import { authApi } from /api/auth; import AppLayout from /components/AppLayout; import LoginPage from /pages/LoginPage; import Dashboard from /pages/Dashboard; import AdminUsers from /pages/admin/AdminUsers; const router createBrowserRouter([ { path: /login, element: LoginPage /, }, { element: AppLayout /, loader: async () { try { const user await authApi.fetchCurrentUser(); return { user }; } catch { return redirect(/login); } }, children: [ { path: /dashboard, element: Dashboard / }, { path: /admin/users, element: AdminUsers / }, ], }, ]); export default router;任何子路由的导航进入都会先触发父路由 loader。未登录时fetchCurrentUser抛错或被服务端拒绝loader 直接重定向到登录页用户根本看不到AppLayout。3.3 loader 里的鉴权能拿到登录态吗这是我被问得最多的问题loader 函数里能用useAuth这种 Hook 吗不行。loader 是普通异步函数不是 React 组件不能调用任何 Hook。但这不意味着 loader 里没法拿登录态。在 SPA 模式下登录后 token 通常存在 localStorage 或 cookie 里loader 函数在浏览器端执行直接读 localStorage 是安全的。要注意的是如果项目用了框架模式也就是 React Router 7 的 Server Rendering 场景loader 会在服务端执行服务端没有 localStorage这时就得依赖请求头里的 cookie 或 Authorization 头。所以我的做法是把获取当前用户封装成独立模块既不依赖 Hook也不依赖组件上下文// services/authService.ts const TOKEN_KEY app_token; export const authService { getToken() { return localStorage.getItem(TOKEN_KEY); }, async fetchCurrentUser() { const token this.getToken(); if (!token) { throw new Error(NO_TOKEN); } const res await fetch(/api/me, { headers: { Authorization: Bearer ${token}, }, }); if (!res.ok) { throw new Error(AUTH_FAILED); } return res.json(); }, async login(username: string, password: string) { const res await fetch(/api/login, { method: POST, body: JSON.stringify({ username, password }), }); const data await res.json(); localStorage.setItem(TOKEN_KEY, data.token); return data; }, logout() { localStorage.removeItem(TOKEN_KEY); }, };然后 loader 里直接调用// router.tsx import { redirect } from react-router; import { authService } from /services/authService; const requireAuthLoader async () { try { const user await authService.fetchCurrentUser(); return { user }; } catch { return redirect(/login); } }; const router createBrowserRouter([ { path: /login, element: LoginPage / }, { element: AppLayout /, loader: requireAuthLoader, children: [ { path: /dashboard, element: Dashboard / }, // ... ], }, ]);这种服务层拿状态loader 做判断的模式在组件守卫和 loader 守卫之间是通用的也是我比较推荐的项目结构。3.4 子页面如何拿到 loader 返回的用户信息父路由 loader 返回{ user }后所有子路由组件都可以通过useLoaderData拿到这个对象因为useLoaderData返回的是离你最近的 loader 数据// Dashboard.tsx import { useLoaderData } from react-router; export default function Dashboard() { const { user } useLoaderData{ user: { name: string; role: string } }(); return ( div h1仪表盘/h1 p当前登录用户{user.name}{user.role}/p /div ); }如果想让某个页面加一层管理员校验可以给子路由单独挂 loader在子路由 loader 里取父 loader 的返回。但子路由 loader 拿不到父 loader 的返回对象所以管理员校验一般还是放在父路由 loader 里做角色判断或者用一个带参数的 loader 工厂// routeGuards.ts import { redirect } from react-router; import { authService } from /services/authService; export const requireAuth async () { try { const user await authService.fetchCurrentUser(); return { user }; } catch { return redirect(/login); } }; export const requireRole (roles: string[]) async () { try { const user await authService.fetchCurrentUser(); if (!user) return redirect(/login); if (!roles.includes(user.role)) return redirect(/403); return { user }; } catch { return redirect(/login); } };用法{ path: /admin, element: AdminLayout /, loader: requireRole([admin, super_admin]), children: [ { path: users, element: AdminUsers / }, { path: roles, element: AdminRoles / }, ], }这样登录校验和角色校验被拆成两个可组合的守卫比把所有判断堆在同一个组件里清晰得多。4. 方案三middleware 中间件v7 新特性的正确打开方式React Router 7 的后续版本开始支持路由中间件middleware这是在 loader 和 action 之前执行的一层逻辑。很多人刚看到时不知道怎么用其实它就是官方版的路由守卫特别适合做全局保护因为中间件可以做三件事读取请求、做鉴权判断、要么放行要么直接响应。4.1 middleware 和 loader 的职责划分middleware 和 loader 容易搞混简单区分就是项目middlewareloader执行时机loader/action 之前组件渲染前核心职责拦截、鉴权、改写请求上下文加载页面数据能否重定向能能写日志埋点更合适也可以打个比方middleware 像公司大楼门口的保安进楼先看工牌loader 像工位上整理今天工作资料的员工。保安不放行员工根本不需要开工。把保安的工作写成员工自己检查工位前的工牌多少有点不合适。我们应该把能不能进来和进来了准备好什么资料分成两件事。4.2 一个可复用的 authGuard 中间件全局路由保护用 middleware 实现的话标准写法是这样的注意middleware 特性需要 React Router 7.4 及以上版本小版本间 API 有调整最终以官方文档为准// middleware/authGuard.ts import { redirect } from react-router; import { authService } from /services/authService; export async function authGuard({ request }: { request: Request }) { const url new URL(request.url); // 场景一未登录直接送去登录页并保留来源地址 if (!authService.getToken()) { const redirectTo url.pathname url.search; return redirect(/login?redirectTo${encodeURIComponent(redirectTo)}); } // 场景二token 在但用户信息过期走后端校验 try { const user await authService.fetchCurrentUser(); // 可以把用户信息挂到上下文后续 loader 也能用 return { user }; } catch { authService.logout(); return redirect(/login?redirectTo${encodeURIComponent(url.pathname)}); } }路由配置// routes.ts export const routes [ { path: /login, component: LoginPage, }, { id: root, middleware: authGuard, component: AppLayout, children: [ { path: /dashboard, component: Dashboard }, { path: /admin, component: AdminLayout }, ], }, ];如果某个子路由需要角色限制再来一个角色中间件// middleware/roleGuard.ts import { redirect } from react-router; import { authService } from /services/authService; export const roleGuard (roles: string[]) async () { try { const user await authService.fetchCurrentUser(); if (!user) return redirect(/login); if (!roles.includes(user.role)) return redirect(/403); return null; } catch { return redirect(/login); } }; // 使用 { path: /admin, middleware: roleGuard([admin, super_admin]), component: AdminLayout, children: [...], }从结构上看middleware 方案最接近我理想中的全局路由保护守卫逻辑独立于组件、独立于 loader职责单一。业务组件负责页面呈现loader 负责加载数据middleware 负责判断谁能进来。4.3 角色权限和菜单动态渲染怎么配套路由保护只是权限体系的一部分另一个逃不掉的工作是动态菜单。登录进来后普通用户只能看到仪表盘管理员能看到仪表盘、用户管理、角色管理。菜单数据应该来自后端或者根据用户角色过滤前端的菜单配置。我的做法是维护一份路由 菜单元数据配置把哪些路由需要什么角色写清楚// config/menuConfig.ts export const menuConfig [ { path: /dashboard, title: 仪表盘, icon: Dashboard, roles: [user, admin, super_admin], }, { path: /admin/users, title: 用户管理, icon: Users, roles: [admin, super_admin], }, { path: /admin/roles, title: 角色管理, icon: Shield, roles: [super_admin], }, ];菜单组件里按用户角色过滤// components/Menu.tsx import { useLoaderData } from react-router; import { NavLink } from react-router; import { menuConfig } from /config/menuConfig; export default function Menu() { const { user } useLoaderData{ user: { role: string } }(); const visibleMenus menuConfig.filter((item) item.roles.includes(user.role) ); return ( nav {visibleMenus.map((item) ( NavLink key{item.path} to{item.path} {item.title} /NavLink ))} /nav ); }这样菜单和路由守卫共用同一份 roles 声明不会出现菜单隐藏了但手动输入 URL 还能进的漏网之鱼。路由层有中间件兜底即使绕过菜单直接输 URL也会被角色守卫拦下来。5. 实战中踩过的坑和我的最终建议方案讲完了接下来是这段实战里真正值钱的部分那些文档不会写但一定会踩的坑。5.1 重定向死循环最容易发生在哪最典型的死循环场景是这样的用户未登录访问/dashboard守卫把他重定向到/login?redirectTo/dashboard用户填完账号密码登录成功后我把redirectTo拿出来navigate(redirectTo)。如果redirectTo恰好是/login本身登录成功后跳回/login登录页守卫又把他弹出去来回跳页面一直闪。修复方式是在登录成功回跳前判断一次const target redirectTo redirectTo ! /login ? redirectTo : /dashboard; navigate(target, { replace: true });另外在 loader/middleware 里做重定向前也要判断当前是否已经在登录页了。React Router 的中间件在匹配阶段就能获取到当前路由信息是否要处理取决于你的路由结构但至少要在心里绷着别把用户在两个路由之间踢皮球这根弦。5.2 登录态从无变有时路由守卫没有重新执行这个坑非常隐蔽。假设用户未登录打开/dashboard被 middleware 或 loader 重定向到了/login。用户输入账号密码登录成功。这时候问题来了用户在登录页但当前地址已经是/login/dashboard的 loader 是不会自动重新执行的。只有在你navigate(/dashboard)或者navigate(from)之后目标路由的 loader/middleware 才会重新跑。也就是说登录成功后的手动跳转动作不能省。我见过有团队用监听 auth storetoken 变化就自动跳转的方案结果跳转时机和登录流程偶合偶尔出现跳太早导致守卫又把人弹回登录页的情况。最好的做法是登录接口成功返回后由表单代码主动拿着from跳转不要在 store 层做隐式跳转。显式跳转让守卫按正常导航流程执行逻辑最不容易乱。5.3 同级导航时 loader 不重新触发权限降级后仍能看到旧页面数据路由有个细节从/admin/users导航到/admin/roles会重新执行/admin/roles的组件和 loader但从/admin/users导航到/admin/users比如点击当前菜单路由不会重新加载loader 不会重新执行。这带来一个实际问题用户权限被管理员降级后如果他自己不刷新页面只在系统内部点击某些已经加载的路由数据仍然是旧权限下的。举例他停留在/admin/users权限被收回此时任何路由切换都可能被限制但当前这一页的画面还在。这个问题靠前端路由守卫解决不了因为前端只能控制下一次导航时是否放行控制不了已经渲染在屏幕上的内容。靠谱的兜底方案有两个一是所有敏感操作必须走后端接口后端接口校验权限返回 401/403 时前端统一登出或跳 403二是在角色变化时主动调用useRevalidator().revalidate()重新验证当前路由的数据。5.4 我的最终选型建议文章最后聊点实在的选型建议不是非黑即白的结论。如果你的项目刚升到 React Router 7路由结构还是传统的RoutesRoute没准备好大规模重构成createBrowserRouter先用组件守卫方案把登录校验和角色校验集中在几个组件里风险最低改动范围可控。如果你正在新建项目或者原有项目已经用了createBrowserRouter那直接把鉴权放进 loader至少登录校验必须在父路由的 loader 里做。这样能利用数据路由的加载时序把页面可见和鉴权通过严格绑定。如果项目已经跑到 React Router 7.4 以上团队对数据路由也比较熟可以考虑上 middleware 方案。这个方案把判断能不能进和加载数据彻底拆开后续权限模型变复杂时比如加多租户、加操作级别权限中间件的可扩展性是最好的。最后再分享一个小细节不管用哪种方案登录鉴权逻辑一定不要散落在单个路由或组件里。把authService.fetchCurrentUser()、getToken()、logout()这类方法收敛到一个独立模块守卫、菜单、页面都能复用。我第一次升级时图省事在组件守卫里直接写了 localStorage 操作后来要接单点登录改了一堆地方纯属给自己挖坑。这个教训希望你能避开。