☰
React Router 7 全局路由保护实战:从 loader 到中间件
2026/10/14 23:57:01 网站建设 项目流程

React Router 7 发布之后,我做了个小实验:把原来老项目里几十处手写的登录跳转全部删掉,换成一两条集中规则。实验做完,最大的感受是——全局路由保护这件事,本质上不是“怎么跳转”,而是“什么时候判断权限”。

很多人第一次听说“路由保护”,第一反应是:这不是写个if (!token) return <Navigate to="/login" />就结束了吗?理论上是,但落到真实项目里,你会发现 token 存在哪、用户信息怎么拉、权限怎么分级、刷新页面后会不会白屏、登录过期怎么踢人、回调地址怎么带过去,这些问题全部绕不开。React Router 7 把数据路由、loader、中间件这些能力合并进来之后,路由保护终于可以不再靠几十个页面里散落的 useEffect 硬撑了。

这篇文章我从最简单的客户端拦截开始讲,再逐步升级到 loader 前置校验和框架模式下的全局中间件,最后给出一份可以直接抄的落地清单。适合正在从 React Router 6 升 7 的人,也适合刚接触 React Router 的开发者。

1. 先把问题认清:路由保护到底在保护什么

1.1 三类最常见的拦截诉求

路由保护不是单一需求,它至少包含三类典型场景。

第一类是“未登录要拦住”。用户没登录就访问/admin、/dashboard、/settings这类私有页面,系统应该把他送去登录页,而不是让页面渲染到一半再报错。第二类是“登录了但权限不够要挡在门外”。普通用户访问只有管理员能进的页面,不能只是不给他页面,最好有一个明确的 403 提示,或者直接隐藏入口。第三类是“登录过期要踢出去”。用户一直开着页面,token 失效后点了某个按钮,页面跳转可以被允许,但数据请求会返回 401,这时候要统一处理,不能让他卡在一个残缺页面里什么都干不了。

这三类场景有个共同点:它们都需要在一个“路由即将生效”的时间点做判断。React Router 7 生态里,这个时间点被很好地抽象成了几种不同的层级:组件渲染前、路由 loader 加载前、框架模式的中间件阶段。选择在哪一层做判断,决定了你的方案是笨重还是优雅。

1.2 旧写法里那口“仙气”不够用

我在旧项目里见过太多形形色色的路由保护写法:有在useEffect里判断 token 然后手动navigate('/login')的;有在每个页面组件顶部复制粘贴一段“鉴权三连”的;有把整个路由表包在一个<AuthButton>组件里,自己在组件外面套一层的。

这些写法不是不能用,但有几个通病。

一是闪烁。useEffect是在组件挂载之后才执行的,所以用户会先看到一帧空白、一帧加载中、一帧“正在跳转”,体验非常差。二是逻辑散落。每个页面都要自己 importuseAuth、useNavigate,一旦权限规则调整,你要去十几个文件里同步修改。三是不好处理异步状态。用户信息如果是异步拉取的,你需要手动管理 loading、error、retry 三态,很容易在某个角落忘记处理。

React Router 7 的价值在于,它把“在路由匹配阶段做事情”这件事做成了平台级能力。你不用再自己琢磨到底在哪个生命周期里拦截,而是可以直接声明式地说:这条路由,进入之前先校验身份。

2. 最小可靠的客户端拦截:RequireAuth 组件

2.1 登录状态从哪来

先聊一个基础问题:全局路由保护第一步,是拿到“当前用户是谁”。

常见做法有两种。一种是前端把 token 存在localStorage或sessionStorage,首次进入应用时用 token 请求/api/me拿用户信息;另一种是后端把 token 放在 httpOnly cookie 里,前端通过X-CSRF之类的机制配合请求,这种方案通常配合服务端或 BFF 层使用,纯前端 SPA 里会更依赖接口返回。

我自己的习惯是:纯前端项目里,至少要做一个AuthProvider,它负责三件事:初始化时恢复登录态、提供user和loading、提供login/logout方法。全局路由保护的所有判断都从这一个 context 里取数据,而不是直接在组件里读localStorage.getItem('token')。

一个最小可用的useAuth大概是这样的:

// AuthContext.tsx import { createContext, useContext, useEffect, useState } from "react"; type User = { id: string; name: string; role: string }; type AuthContextValue = { user: User | null; loading: boolean; login: (token: string) => void; logout: () => void; }; const AuthContext = createContext<AuthContextValue | null>(null); export function AuthProvider({ children }: { children: React.ReactNode }) { const [user, setUser] = useState<User | null>(null); const [loading, setLoading] = useState(true); useEffect(() => { const token = localStorage.getItem("token"); if (!token) { setLoading(false); return; } // 模拟拉取用户信息 fetch("/api/me", { headers: { Authorization: `Bearer ${token}` }, }) .then((res) => { if (!res.ok) throw new Error("401"); return res.json(); }) .then((data) => setUser(data.user)) .catch(() => { localStorage.removeItem("token"); }) .finally(() => setLoading(false)); }, []); const login = (token: string) => { localStorage.setItem("token", token); // 正常项目里这里要重新 fetch /api/me 或直接接收 user 对象 }; const logout = () => { localStorage.removeItem("token"); setUser(null); }; return ( <AuthContext.Provider value={{ user, loading, login, logout }}> {children} </AuthContext.Provider> ); } export function useAuth() { const ctx = useContext(AuthContext); if (!ctx) throw new Error("useAuth 必须在 AuthProvider 内使用"); return ctx; }

这里有个细节容易被忽略:login函数只把 token 写进 localStorage,是不会自动把user状态更新出来的。很多新手在这里踩坑,跳转登录页后明明存了 token,但页面还是认为你没登录。更稳妥的做法是登录接口返回 user 对象,然后由setUser直接更新状态。

2.2 渲染期拦截与 loading 处理

有了useAuth,最直接的路由保护写法就是封装一个RequireAuth组件。

// RequireAuth.tsx import { Navigate, useLocation } from "react-router"; import { useAuth } from "./AuthContext"; export function RequireAuth({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); const location = useLocation(); if (loading) { return <div>页面加载中...</div>; } if (!user) { return <Navigate to="/login" replace state={{ from: location }} />; } return <>{children}</>; }

这个组件有三个关键点。

第一,loading状态必须优先处理。如果没有 loading 判断,刷新页面时user还是null,路由会立刻跳到登录页,等异步请求回来用户又变成了已登录,于是页面在登录页和业务页之间闪一下。任何路由保护方案都要面对这个问题。第二,state={{ from: location }}是回跳信息的种子。登录成功后,你要能从location.state里拿到用户原本想去的地址,再把他送回去。第三,replace参数很重要。不让登录页出现在历史记录里,用户点浏览器返回时不会回到已经被拦截的页面。

2.3 用嵌套路由把公共逻辑集中起来

组件本身很简单,但怎么把它铺到路由里是有讲究的。最省事的写法是每个页面路由外面包一层<RequireAuth>:

<Route path="/dashboard" element={<RequireAuth><Dashboard /></RequireAuth>} />

但这种写法有个坏处:每个页面都要重复声明,而且互相之间没有统一的布局关系。在 React Router 7 里,嵌套路由是更好的选择。你可以把需要登录后才能访问的页面全部放到同一个父路由下,然后在父路由的 element 里只渲染<RequireAuth><Outlet /></RequireAuth>。

import { Routes, Route, Outlet } from "react-router"; import { RequireAuth } from "./RequireAuth"; function ProtectedLayout() { return ( <RequireAuth> <Outlet /> </RequireAuth> ); } function AppRoutes() { return ( <Routes> <Route path="/login" element={<LoginPage />} /> <Route element={<ProtectedLayout />}> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/settings" element={<Settings />} /> </Route> </Routes> ); }

如果你还想要统一的后台布局,比如左侧菜单、顶部栏,ProtectedLayout里完全可以再套一层布局组件。这样整体结构就变成了:先鉴权,再布局,最后渲染具体页面。

这套方案的优点是非常直观,适合中小型项目。缺点是判断发生在组件渲染阶段,用户信息还没拉回来之前,仍然需要一个全局 loading。另外,请求数据是在页面组件 mount 之后才发出的,和路由 loader 相比,还是显得有些慢。

3. 用数据路由把保护逻辑前移到 loader

3.1 loader 为什么更适合做权限判断

React Router 7 保留了createBrowserRouter数据路由方案。数据路由的典型特征是:路由在匹配后、组件渲染前,可以执行一个叫做loader的函数。loader的返回值会作为路由数据传给对应组件,同时它抛出的异常或Response会被上层的errorElement捕获。

这个机制对全局路由保护来说简直是量身定做的。

以前你用useEffect做拦截,页面是先渲染、后判断。现在如果用loader做拦截,页面是“先判断、后渲染”。也就是说,只要 loader 还没说完这个用户能不能进,业务组件本身根本不会渲染,闪烁和空白无从谈起。

你可以这样理解:useEffect相当于餐厅已经让你入座了,服务员上菜时才发现你忘带钱包;loader 则是门口迎宾时就把你拦下来,没有会员卡连大厅都进不去。

3.2 一个带白名单和 403 的 loader 示例

在createBrowserRouter里,路由配置是这样的:

import { createBrowserRouter, RouterProvider, redirect } from "react-router"; const router = createBrowserRouter([ { path: "/", element: <RootLayout />, errorElement: <RouteErrorBoundary />, children: [ { path: "/login", element: <LoginPage /> }, { path: "/dashboard", loader: async () => { const token = localStorage.getItem("token"); if (!token) { throw redirect("/login?redirect=/dashboard"); } return null; }, element: <Dashboard />, }, ], }, ]);

注意这里的关键是throw redirect(...),而不是return redirect(...)。React Router 里的redirect返回的是一个Response对象,如果你把它 return 出去,React Router 会认为这是这个路由要展示的数据,页面不会跳转,只会渲染出一串你根本不想看的东西。用throw才能让它被当作一次导航中断指令处理。

但每个页面都写一个 loader 还是太啰嗦。你完全可以在一个父路由上挂 loader,让所有子路由共享:

async function protectedLoader() { const token = localStorage.getItem("token"); if (!token) { throw redirect("/login"); } return null; } const router = createBrowserRouter([ { path: "/", element: <RootLayout />, children: [ { path: "/login", element: <LoginPage /> }, { element: <ProtectedLayout />, loader: protectedLoader, children: [ { path: "/dashboard", element: <Dashboard /> }, { path: "/settings", element: <Settings /> }, ], }, ], }, ]);

这里ProtectedLayout同样可以是一个带有Outlet的布局组件,但它的鉴权判断不是自己在 useEffect 里做,而是交给父路由 loader 提前做。组件拿到user数据时,已经是确定有权限之后的事了。

权限细分时,你还可以在 loader 里根据某个角色判断是否返回 403:

async function adminLoader() { // 从某个共享数据源读取用户信息 const user = await getUser(); if (!user) { throw redirect("/login"); } if (user.role !== "admin") { throw new Response("Forbidden", { status: 403 }); } return { user }; }

throw new Response("Forbidden", { status: 403 })会被errorElement捕获。你可以在错误边界里读取状态码,区分是 401 还是 403,分别渲染“请登录”和“您没有权限访问”。

3.3 路由数据缓存与重复校验

loader 方案有个新问题:用户从/dashboard切到/settings,loader 会再次执行,每次都要重新读一次 token。如果是纯前端 localStorage,这个开销可以忽略。但如果你在 loader 里请求了/api/me,那用户每点一个菜单,就多发一次用户信息请求,这就有点浪费了。

这时候可以考虑用shouldRevalidate。它默认的返回行为是:在非首次加载、且 action 没有提交数据时,会重新走 loader。你可以让它只在特定条件下才重新校验:

async function protectedLoader() { const token = localStorage.getItem("token"); if (!token) throw redirect("/login"); return null; } export const shouldRevalidate = ({ currentUrl, nextUrl }) => { if (currentUrl.pathname === nextUrl.pathname) return false; return true; };

不过我的建议是,用户信息的获取不要放在每个子路由的 loader 里,而是放在根路由 loader 里只加载一次,子路由通过useRouteLoaderData去读。这样权限判断的重复成本会低很多。

loader 方案已经是比较成熟的客户端路由保护方案。它的缺陷在于,真正的高级权限判断如果依赖后端,前端的 localStorage 可以被任意修改,所以它更多是体验层保护,而不是真正的安全边界。

4. 框架模式的全局中间件:真正“进门前”拦截

4.1 框架模式与库模式的分水岭

React Router 7 有个很重要的变化:它同时支持两种模式。

第一种是库模式,也就是你只用react-router这个包,路由配置、数据加载都是你手动组织。上面两节讲的内容都建立在库模式上,它对已有的 CRA、Vite + React SPA 项目很友好。第二种是框架模式,在这个模式下,项目会使用官方提供的脚手架能力,路由不再只是前端 URL 和组件的映射,它已经和服务器、请求上下文挂钩。你可以定义全局中间件,在路由匹配进入组件之前,先经过一层类似“门卫”的代码。

框架模式带来的最直接能力,就是可以拿到Request。这意味着你可以读取请求头、Cookie,甚至直接访问 session 信息。对路由保护来说,这在体验上比客户端方案前进了一大步:你不再需要先渲染一个空白 loading、再等 JS 文件下载、再执行 loader,而是请求进来的时候,服务器就已经决定了该不该放行。

4.2 中间件里读请求头返回重定向

框架模式下的中间件概念,简单说就是在正式加载路由数据之前,你可以注册一个函数,函数里对请求做预处理。如果发现用户没有登录,可以直接返回一个重定向响应,请求根本不会走到业务组件。

我目前在实际项目里比较喜欢的写法是这样的:

// app/middleware.ts import { redirect } from "react-router"; export async function authMiddleware(request: Request) { // 单点登录场景下,token 往往放在 cookie 里 const cookie = request.headers.get("Cookie") ?? ""; const hasToken = cookie.includes("token="); if (!hasToken) { return redirect("/login"); } // 如果需要拉用户信息,也可以在这里做 return null; }

注册方式要看具体的 React Router 7 小版本,不同版本在routes.ts里的挂载写法略有差异,以官方文档为准。但核心思想都是一样的:中间件返回值如果不是null,就代表这次请求被拦截了,返回redirect或Response都会直接终止路由匹配。

如果你用了 httpOnly cookie,前端 JavaScript 根本读不到 token,这时候客户端 loader 方案就做不了完整的鉴权判断,而中间件方案刚刚好。你在中间件里可以从 cookie 直接判断是否登录,也可以在中间件里调用一个内部接口校验 session 是否过期,然后返回重定向。这种做法对用户来说是几乎无感的,因为服务器已经在响应第一个 HTML 之前完成判断,不会出现客户端 SPA 那种“白屏一下再跳登录页”的动画。

4.3 什么时候才该上中间件

中间件听起来很香,但不是所有项目都适合立刻切换。

如果项目是纯静态托管的 SPA,前端构建产物只是 js、css、html 文件,没有 Node 服务层,那么框架模式的服务能力用不上。你可以继续使用库模式,配合 loader 来写路由保护,效果也不差。如果项目已经有 BFF 层、或者本身就用 React Router 的框架模式做服务端渲染,那中间件几乎是必选项。因为只有在请求分发阶段做判断,才能避免把需要鉴权的页面资源先发给浏览器,然后再通过 JS 重定向回来。

另外一个需要想清楚的问题是:中间件做的是“粗粒度”拦截,还是“细粒度”拦截。全局中间件适合检查“是否登录”,但“这个页面是否允许角色为 editor 的用户访问”这种判断,放在 loader 里会更清晰。因为有些权限需要读取 URL 参数或路由信息,每个页面权限不同,中间件里写太多条件会变得很难维护。我通常的划分是:中间件管登录态基础门槛,loader 管具体页面角色权限,组件层负责根据用户信息做交互展示控制。

5. 权限扩展:角色、菜单和动态路由表

5.1 在路由元信息里声明角色

路由保护做得稍微复杂一点,就不再只是登录/未登录的二值判断,而是角色和权限的多级判断。React Router 没有内建的权限系统,但你可以利用handle或自定义字段来声明每个路由需要的角色。

handle是 React Router 里和路由关联的额外信息,它不会影响路由渲染,但可以通过useMatches拿到。比如你可以在路由上声明这样一段信息:

const router = createBrowserRouter([ { path: "/admin", element: <AdminLayout />, handle: { roles: ["admin"], }, children: [ { path: "users", element: <Users /> }, { path: "settings", element: <AdminSettings /> }, ], }, ]);

然后在布局组件里统一读取:

import { useMatches } from "react-router"; function AdminLayout() { const matches = useMatches(); const routeHandle = matches.at(-1)?.handle as { roles?: string[] } | undefined; const { user } = useAuth(); if (routeHandle?.roles && !routeHandle.roles.includes(user.role)) { return <Forbidden />; } return <Outlet />; }

这样做的好处是:权限策略集中在路由定义文件里,而不是散落在每个页面的 if 判断里。后续要调整某个页面的可见角色,只需要改路由配置。

5.2 动态过滤路由表之后再做全局保护

后台管理系统还有一个常见需求:不同角色登录后,看到的菜单和路由不一样。比如管理员能看到用户管理,普通成员只能看到自己的个人中心。这种需求如果靠前端写死所有路由,再在渲染时逐个判断,代码会很难看。更好的是从后端接口返回当前用户的路由表,前端拿着这份路由表动态生成路由。

React Router 7 的createBrowserRouter完全支持动态生成路由数组。你可以先请求权限配置,再根据配置过滤出用户可以访问的路由:

async function loadUserPermission() { const res = await fetch("/api/permissions"); return await res.json(); // [{ path: "/dashboard", roles: ["user", "admin"] }] } async function bootstrap() { const permissions = await loadUserPermission(); const dynamicRoutes = permissions .filter((perm) => allowed(perm, currentUser)) .map((perm) => ({ path: perm.path, element: pageMap[perm.component], loader: protectedLoader, })); const router = createBrowserRouter([ { path: "/login", element: <LoginPage /> }, { path: "/", element: <RootLayout />, children: [ { element: <RequireAuth />, children: dynamicRoutes, }, { path: "*", element: <NotFound /> }, ], }, ]); root.render( <AuthProvider> <RouterProvider router={router} /> </AuthProvider> ); }

这里有个容易踩的坑:动态路由无法在createBrowserRouter构建后再随意增删路由,因为路由表在RouterProvider挂载后就固定了。如果你想在运行时根据权限动态更新,最稳妥的办法是给RouterProvider设置一个不同的key,或者用状态重建整个 router。新建 router 的成本不高,但如果页面已经有用户操作痕迹,重建会丢失当前页面状态,所以能一开始就拿到完整权限表就尽量在一开始生成。

5.3 403 页面、错误边界和权限刷新

权限判断结束后,总要有兜底方案。我建议至少准备三层:

未登录统一跳/login。已登录但角色不够,渲染一个 403 页面。路由完全匹配不到,渲染 404 页面。这三层可以在errorElement和path="*"里分别处理。

比较容易被忽视的是“权限变更后的刷新”。如果用户在系统里被管理员把角色从admin降为user,他当前可能还停留在用户管理页,接口可能会返回 403。这时候简单的处理是全局拦截接口 403,跳到一个“无权限”提示页,或者强制重新登录。实在要做得很细,可以给权限接口返回一个版本号,前端轮询或者每次页面聚焦时比较版本,变了就重新创建 router。

6. 实际落地清单与高频问题排查

6.1 一个最小可运行项目该有哪些文件

如果你要从零开始搭一个带全局路由保护的前端项目,我建议至少拆出这几个文件:

src/ App.tsx main.tsx router/index.tsx auth/AuthContext.tsx auth/RequireAuth.tsx auth/ProtectedLayout.tsx pages/Login.tsx pages/Dashboard.tsx pages/Forbidden.tsx pages/NotFound.tsx

router/index.tsx统一出 router 实例,RequireAuth只负责未登录拦截,ProtectedLayout负责布局和可选的角色拦截,这样职责分离比较清晰。如果是大型项目,还可以进一步把 loader 拆到loaders/auth.ts,中间件拆到app/middleware.ts。

6.2 登录回跳与过期会话的完整链路

全局路由保护不能只拦进,还要管出。用户被拦到登录页之后,等他完成登录,你要把他带回原来的页面,这就是回跳。

在RequireAuth里已经通过state={{ from: location }}把来源地址传给了登录页。登录页里这样消费:

import { useLocation, useNavigate } from "react-router"; function LoginPage() { const location = useLocation(); const navigate = useNavigate(); const from = (location.state as { from?: Location })?.from?.pathname ?? "/"; async function handleLogin() { await doLogin(); navigate(from, { replace: true }); } }

要小心一个问题:如果from直接指向另一个需要特殊权限的页面,比如只有 admin 才能访问的/admin/users,普通用户登录后会被再次弹回来,形成“登录页 -> /admin/users -> 403”的奇怪体验。比较稳的做法是,登录成功后先跳到一个默认首页,再根据from做一次权限复核。或者在拦截的时候就把目标地址的角色要求一起带过去,登录页判断当前用户角色不够时,直接跳到默认首页。

登录过期是另一个容易被忽略的链路。token 过期后,一般方式是在接口层做统一拦截。服务端返回 401,前端清除 token,跳转登录页,并带上当前地址作为新的from。这个过程最好放在封装好的 fetch 或 axios 拦截器里做,而不是在每个页面 catch 里写一遍。

6.3 速查表:5 个典型坑

我把实际开发里经常遇到的问题整理成一张速查表,方便你排查。

现象大概率原因解决思路
刷新页面瞬间闪一下登录页初始化时 user 还是 null,loading 没控制好在RequireAuth里先判断 loading,loading 时渲染全局加载组件
登录成功后页面还在登录页登录只存了 token,context 里的 user 没更新登录方法里显式 setUser,或重新 fetch 用户信息
loader 里写了 redirect 但页面不跳把 redirect 当成返回值 return 了使用throw redirect()中断路由
从 A 页面跳到 B 页面,用户信息请求重复发每个子路由 loader 都在请求/api/me把用户信息加载放到根 loader,子路由用useRouteLoaderData读取
动态路由权限变更后页面没反应createBrowserRouter 的路由表不可动态增改用 state 或 key 重建 RouterProvider 的 router

这些坑我自己都踩过,尤其是第一个,几乎在每个项目里都会遇到。loading 状态看起来简单,但在全局路由保护里,它就是决定体验是“顺滑”还是“闪瞎眼”的关键。

最后再分享一个小技巧,也是我在实际项目里比较受用的做法:路由保护规则要和业务代码解耦,尽量留一个“可测试的纯函数”核心。比如把“这个 URL 是否在白名单里”“当前用户是否拥有这个页面要求的角色”抽成独立函数,这样你不需要启动整个前端项目,就能用单元测试把最容易被改坏的部分保护住。路由保护的逻辑往往一年也动不了几次,但每次动都是紧要关头,稳定比花哨重要得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询