
一句话:React 的「路由守卫」本质是「控制渲染」(用包装组件或在
loader里校验),没有 Vue 那样的beforeEach全局钩子;而且前端守卫只是体验优化,真正的权限必须在接口层校验。
一、先明确:前端守卫不是安全边界
⚠️ 最重要的一句话:
前端路由守卫只解决「用户体验」(不让用户看到无权访问的界面),
【不是】安全机制——任何人都可以:
· 直接在控制台改 state / 调用 store 修改权限标记
· 用 Postman 直接请求接口
· 修改 JS 代码绕过判断
所以正确做法是「双层校验」:
① 前端守卫:控制「能不能进这个页面」(体验)
② 后端鉴权:控制「能不能拿到/修改这份数据」(安全,必须)
前端守卫的价值:
· 避免用户看到「加载失败/空白」的页面
· 避免无效请求(减少 403 日志噪音)
· 引导用户去登录/申请权限
二、方案一:包装组件(最常用)
// ✅ 声明式路由下最常见的写法
function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth();
const location = useLocation();
// ⭐ 关键:鉴权状态还在加载时,不要立刻重定向(否则会「闪一下登录页」)
if (isLoading) return <FullPageSkeleton />;
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}
// 使用:包在需要保护的路由上
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/dashboard"
element={
<RequireAuth>
<Dashboard />
</RequireAuth>
}
/>
<Route
path="/admin"
element={
<RequireRole role="admin">
<AdminPanel />
</RequireRole>
}
/>
</Routes>
// ✅ 带角色校验的包装组件
function RequireRole({
role,
children,
fallback = <Navigate to="/403" replace />,
}: {
role: string;
children: React.ReactNode;
fallback?: React.ReactNode;
}) {
const { user, isLoading } = useAuth();
if (isLoading) return <FullPageSkeleton />;
if (!user) return <Navigate to="/login" replace />;
if (!user.roles.includes(role)) return <>{fallback}</>;
return <>{children}</>;
}
三、方案二:布局路由(更简洁)
// ✅ 用「布局路由」一次性保护一组路由(不用每个都包)
function RequireAuth() {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) return <FullPageSkeleton />;
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
return <Outlet />; {/* ⭐ 通过校验后渲染子路由 */}
}
// 数据路由配置
const router = createBrowserRouter([
{ path: '/login', element: <Login /> },
{ path: '/403', element: <Forbidden /> },
{
path: '/',
element: <RequireAuth />, {/* ⭐ 整组路由受保护 */}
errorElement: <RootError />,
children: [
{ index: true, element: <Dashboard /> },
{ path: 'settings', element: <Settings /> },
{
path: 'admin',
element: <RequireRole role="admin" />, {/* ⭐ 二次鉴权 */}
children: [
{ index: true, element: <AdminHome /> },
{ path: 'users', element: <UserManage /> },
],
},
],
},
]);
⚠️ 布局路由做守卫时的一个细节:
「校验组件」必须渲染 <Outlet />,否则子路由不显示。
而且校验逻辑要「同步可判定」——如果依赖异步请求,
必须处理「加载中」状态(否则会误判 → 闪登录页)。
四、方案三:在 loader 里校验(数据路由)
// ✅ 数据路由下,可以在 loader 里「渲染前」校验并重定向
import { redirect } from 'react-router-dom';
const requireAuthLoader = () => {
const user = getCurrentUser(); // 同步读取(如从 store/缓存)
if (!user) {
throw redirect('/login'); // ⭐ 渲染前就跳转(没有闪烁)
}
return null;
};
const router = createBrowserRouter([
{
path: '/',
loader: requireAuthLoader, // ⭐ 整组路由的守卫
element: <RootLayout />,
children: [
{ index: true, element: <Dashboard /> },
{
path: 'admin',
loader: async () => {
const user = await getCurrentUserAsync();
if (!user?.roles.includes('admin')) {
throw redirect('/403');
}
return null;
},
element: <AdminLayout />,
children: [
{ path: 'users', element: <UserManage /> },
],
},
],
},
]);
| 方案 | 时机 | 优点 | 缺点 |
|---|---|---|---|
| 包装组件 | 渲染时 | 简单、声明式、适合声明式路由 | 可能出现「闪一下」(需处理 loading) |
| 布局路由 | 渲染时 | 一处保护一组 | 同上 |
loader 校验 |
渲染前 | 没有闪烁、可并行取数据 | 只在数据路由下可用 |
五、登录后回跳
// ① 守卫里记录来源
function RequireAuth() {
const { user } = useAuth();
const location = useLocation();
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}
// ② 登录成功后回跳
function Login() {
const navigate = useNavigate();
const location = useLocation();
const from = (location.state as { from?: { pathname: string; search: string } })?.from;
const handleSubmit = async (data: Credentials) => {
await login(data);
const target = from ? `${from.pathname}${from.search}` : '/dashboard';
navigate(target, { replace: true }); // ⭐ 保留原始查询参数 + replace
};
return <LoginForm onSubmit={handleSubmit} />;
}
⚠️ 细节:要同时保留 pathname 与 search
❌ navigate(from.pathname) → 丢失筛选参数
✅ navigate(`${from.pathname}${from.search}`)
六、权限粒度
// ① 页面级(整个路由)
<RequireRole role="admin"><AdminPage /></RequireRole>
// ② 按钮级(同一个页面里不同按钮)
function OrderActions({ order }: { order: Order }) {
const { hasPermission } = useAuth();
return (
<>
{hasPermission('order:view') && <Button>查看</Button>}
{hasPermission('order:edit') && <Button>编辑</Button>}
{hasPermission('order:delete') && <Button danger>删除</Button>}
</>
);
}
// ③ 数据级(当前用户只能看自己的数据)—— 只能靠后端
// ④ 路由级(动态路由表,根据权限过滤菜单与路由)
// ④ 动态路由表的常见做法:用权限过滤「路由配置」
function buildRoutes(permissions: string[]) {
const allRoutes: AppRoute[] = [
{ path: 'dashboard', element: <Dashboard />, permission: 'dashboard:view' },
{ path: 'users', element: <UserManage />, permission: 'user:list' },
{ path: 'settings', element: <Settings />, permission: 'settings:view' },
];
return allRoutes
.filter((r) => !r.permission || permissions.includes(r.permission))
.map((r) => ({ path: r.path, element: r.element }));
}
// 使用
const routes = buildRoutes(user.permissions);
// 同时「过滤菜单」(菜单与路由用同一份配置,避免不一致)
function SideMenu() {
const { hasPermission } = useAuth();
return (
<nav>
{MENU_CONFIG.filter((m) => hasPermission(m.permission)).map((m) => (
<NavLink key={m.path} to={m.path}>{m.label}</NavLink>
))}
</nav>
);
}
七、401 / 403 的统一处理
// ✅ 在请求层统一处理(而不是每个页面各写一遍)
const api = axios.create({ baseURL: '/api' });
api.interceptors.response.use(
(res) => res,
(error) => {
const status = error.response?.status;
if (status === 401) {
// 未登录/登录过期 → 清状态 + 跳登录
authStore.getState().clear();
globalNavigate?.('/login', {
replace: true,
state: { from: window.location }, // ⭐ 记录来源,登录后回跳
});
} else if (status === 403) {
// 已登录但无权限 → 提示(不要跳登录页!)
toast.error('没有权限执行此操作');
}
return Promise.reject(error);
}
);
⚠️ 401 与 403 的区别(面试常问):
· 401 Unauthorized —— 「你还没登录」或「凭证无效/过期」→ 应该去登录
· 403 Forbidden —— 「你已登录,但没有权限」→ 应该提示无权限(跳登录没用)
混淆这两个会导致「明明登录了却老是跳登录页」的糟糕体验。
// 「登录过期」的一个细节:并发请求同时 401 → 只跳一次
let isRedirecting = false;
api.interceptors.response.use(undefined, (error) => {
if (error.response?.status === 401 && !isRedirecting) {
isRedirecting = true;
authStore.getState().clear();
globalNavigate?.('/login', { replace: true });
setTimeout(() => { isRedirecting = false; }, 1000); // ⭐ 防重复跳转
}
return Promise.reject(error);
});
八、常见坑
// ① 把前端守卫当「安全措施」
// → 接口必须独立鉴权(前端守卫可被绕过)
// ② 鉴权状态「初始为未登录」→ 刷新时闪一下登录页
// 场景:user 初始是 null,异步请求后才知道已登录
function Bad() {
const { user } = useAuth(); // 初始 null
if (!user) return <Navigate to="/login" />; // ⚠️ 刷新时立刻跳登录页(闪)
}
// ✅ 引入 loading 状态(或「鉴权状态未就绪」的三态)
if (authStatus === 'loading') return <FullPageSkeleton />;
if (authStatus === 'unauthenticated') return <Navigate to="/login" />;
// ③ 权限校验放在「子组件」而不是「路由层」→ 已经渲染了不该渲染的东西
// ④ 忘记「页面级 + 按钮级」两层(只做页面级 → 用户看到按钮但点了报错)
// ⑤ 403 也跳登录页(应该是「无权限提示」)
// ⑥ 登录后丢失原查询参数(只回跳 pathname)
// ⑦ 动态路由过滤后「菜单与路由不一致」(应该用同一份配置)
// ⑧ 在守卫里发「同步请求」(阻塞渲染)
// ⑨ 用 state 判断权限(应该用「从 token 解析/接口返回」的可信来源)
// ⑩ 登录过期后「并发请求都触发跳转」(加防抖/标志位)
// ⑪ 把「权限标记」存在 localStorage 且信任它(用户可改)
// ⑫ 忘记「退出登录后清空缓存数据」(Query 缓存 / store 都要清)
// ⑫ 的完整做法
function useLogout() {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useCallback(() => {
authStore.getState().clear(); // 清鉴权状态
queryClient.clear(); // ⭐ 清服务端数据缓存
localStorage.removeItem('token');
navigate('/login', { replace: true });
}, [navigate, queryClient]);
}
九、面试延伸
- 「React 怎么做路由权限控制?」
三种方式:① 包装组件(<RequireAuth><Page /></RequireAuth>)——在渲染时判断,未登录返回<Navigate to="/login" state={{ from: location }} replace />,最简单常用;② 布局路由(<RequireAuth><Outlet /></RequireAuth>作为一组路由的父级)——一处保护一组路由,代码更少;③loader里校验(数据路由)——loader中throw redirect('/login'),在渲染前就完成跳转,没有闪烁。要点是「必须处理鉴权状态还在加载时的情况」(否则刷新页面会闪一下登录页)。 -
「前端路由守卫是安全措施吗?」
不是。前端守卫只控制「用户能不能进入某个页面」,属于体验优化——因为任何人都可以:① 在控制台改 store/state;② 直接调接口(Postman/curl);③ 修改前端代码绕过判断。真正的权限必须在服务端校验(接口层根据 token 判断身份与权限、按用户过滤数据)。前端守卫的价值在于「避免用户看到空白/报错页」「减少无效请求」「引导用户登录」。 -
「为什么刷新页面会『闪一下登录页』?」
因为鉴权状态通常是异步获取的(token 在 localStorage → 请求/me接口 → 得到 user)。如果user的初始值是null,那么首次渲染时守卫会认为「未登录」→ 立刻<Navigate to="/login" />→ 用户看到登录页闪一下。解法是引入「三态」:loading(鉴权状态未就绪 → 显示骨架)→authenticated/unauthenticated(才做跳转判断)。或者用数据路由的loader在渲染前完成校验。 -
「登录后怎么跳回原页面?」
两步:① 守卫里记录来源——<Navigate to="/login" state={{ from: location }} replace />;② 登录成功后回跳——从location.state.from取出路径,navigate(\${from.pathname}${from.search}`, { replace: true })。**注意要同时保留pathname与search**(否则会丢失筛选/分页参数),并且用replace: true` 避免用户「后退」回到登录页。 -
「401 和 403 有什么区别?该怎么处理?」
401 Unauthorized—— 没有有效身份凭证(未登录 / token 过期 / token 无效)→ 应该清除本地状态并跳转登录页;403 Forbidden—— 身份有效但无权限(已登录但没有访问该资源的权限)→ 应该提示「无权限」(跳登录页没有意义,因为用户登录了也没权限)。混淆两者会导致「明明已登录却反复被踢到登录页」的糟糕体验。两者都应该在请求拦截器里统一处理,而不是每个页面各写一遍。 -
「权限粒度怎么设计?」
四个层次:① 页面级——整个路由受保护(<RequireRole role="admin">);② 按钮/操作级——同一页面里不同按钮按「权限点」显示(hasPermission('order:delete'));③ 菜单级——按权限过滤菜单(最好与「路由配置」共用同一份数据,避免不一致);④ 数据级——「用户只能看到自己的数据」,这只能由后端保证(前端的过滤可以被绕过)。实践上常见的是「页面级 + 按钮级 + 菜单级」用同一份「权限点」配置驱动。 -
「为什么 403 不应该跳登录页?」
因为两者语义不同:403 表示「你已登录但没权限」——此时用户跳到登录页也解决不了问题(登录了还是没有权限),只会让用户困惑(「我刚登录怎么又让我登录」)。正确的处理是「就地提示无权限」(toast、403 页面、或把按钮置灰)。只有 401(身份问题)才应该跳登录页。 -
「退出登录要清理哪些东西?」
至少四类:① 鉴权状态(user / token / 权限列表);② 服务端数据缓存(TanStack Query 的queryClient.clear()——否则下一个用户可能看到上一个用户的数据残留);③ 本地存储(localStorage / sessionStorage 里的 token 与用户偏好);④ 全局状态的其他用户相关数据(购物车、草稿等)。特别注意缓存——很多人只清了 token 而忘了清 Query 缓存,导致「切换账号后看到上一个账号的数据」。
一句话速记
React 的「路由守卫」本质是控制渲染(没有全局
beforeEach):三种做法是「包装组件<RequireAuth>」「布局路由<RequireAuth><Outlet /></RequireAuth>」「loader里throw redirect()(渲染前跳转、无闪烁)」;必须处理「鉴权状态加载中」的三态(否则刷新会闪登录页);登录后回跳要带pathname + search并replace;401 跳登录、403 就地提示(别混);权限有「页面级 / 按钮级 / 菜单级 / 数据级」四层(数据级只能靠后端);前端守卫不是安全边界(接口必须独立鉴权);退出登录要清 store + Query 缓存 + localStorage。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!