手头这个中后台项目,第一版是没做登录的,页面全裸在外网。等到业务数据跑起来,需求方轻飘飘一句话:"所有页面都要登录才能看,管理员页面只能管理员进"。这种活儿干过的人都知道,代码放不对位置,后面权限需求一多起来,整个路由层就会变得又脏又乱。
我最近把项目从 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 先执行目标路由的 loader,loader 里去做鉴权,判断不通过直接抛出一个redirect('/login'),整个目标页面根本不会被渲染。用户在视觉上就感觉"跳过去了",中间不会闪现任何目标页面内容。
两种方案在大多数简单场景下都能用,但差异很实际:
| 对比维度 | 组件守卫 | loader 守卫 |
|---|---|---|
| 执行时机 | 目标组件已匹配,渲染前一刻 | 路由匹配完成后,组件渲染前 |
| 页面闪现 | 处理不好会闪一下登录前的内容 | 不会闪现 |
| 数据状态 | 需要自己维护初始化状态 | 天然等待 loader 完成 |
| 权限数据传递 | 通过组件 props 或外部 store | 通过useLoaderData直接给页面 |
| 和 v7 数据路由的契合度 | 一般 | 高 |
我见过不少团队在 v6 时代写组件守卫,升级到 v7 之后继续沿用,甚至把 loader 也写了却不做鉴权,等到组件里再去判断。这样不是不行,而是没把数据路由的价值用起来。后面几节我会把三种方案都详细展开,你根据自己的项目阶段来选。
2. 方案一:组件守卫,适合快速改造 v6 老项目
如果你的项目刚从 v6 升过来,路由还是一大堆<Routes>+<Route>,最稳的改造方式是先写一个守卫组件,把全局的登录校验、角色校验集中在一处。这个方案不改路由结构,改动量最小,当天就能上。
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 className="page-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?type=1时被带到了登录页,登录成功之后要把用户送回他原本想去的地址,而不是永远送他去首页。这个"记住来源地址"的细节决定了权限体验的完整度。
二是守卫组件里不能只检查 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 className="page-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 严格模式下面也不会有问题。
useEffect+navigate()适合需要做副作用的场景,比如跳转之前要上报、要写 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?page=2这种带 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 容易搞混,简单区分就是:
| 项目 | middleware | loader |
|---|---|---|
| 执行时机 | 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 store,token 变化就自动跳转"的方案,结果跳转时机和登录流程偶合,偶尔出现跳太早导致守卫又把人弹回登录页的情况。
最好的做法是:登录接口成功返回后,由表单代码主动拿着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,路由结构还是传统的<Routes>+<Route>,没准备好大规模重构成createBrowserRouter,先用组件守卫方案,把登录校验和角色校验集中在几个组件里,风险最低,改动范围可控。
如果你正在新建项目,或者原有项目已经用了createBrowserRouter,那直接把鉴权放进 loader,至少登录校验必须在父路由的 loader 里做。这样能利用数据路由的加载时序,把"页面可见"和"鉴权通过"严格绑定。
如果项目已经跑到 React Router 7.4 以上,团队对数据路由也比较熟,可以考虑上 middleware 方案。这个方案把"判断能不能进"和"加载数据"彻底拆开,后续权限模型变复杂时,比如加多租户、加操作级别权限,中间件的可扩展性是最好的。
最后再分享一个小细节:不管用哪种方案,登录鉴权逻辑一定不要散落在单个路由或组件里。把authService.fetchCurrentUser()、getToken()、logout()这类方法收敛到一个独立模块,守卫、菜单、页面都能复用。我第一次升级时图省事,在组件守卫里直接写了 localStorage 操作,后来要接单点登录,改了一堆地方,纯属给自己挖坑。这个教训,希望你能避开。