陌上人如玉
公子世无双

6.7 路由守卫与权限

6.7 路由守卫与权限

一句话: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]);
}

九、面试延伸

  1. 「React 怎么做路由权限控制?」
    三种方式:① 包装组件(<RequireAuth><Page /></RequireAuth>)——在渲染时判断,未登录返回 <Navigate to="/login" state={{ from: location }} replace />,最简单常用;② 布局路由(<RequireAuth><Outlet /></RequireAuth> 作为一组路由的父级)——一处保护一组路由,代码更少;③ loader 里校验(数据路由)——loader 中 throw redirect('/login'),在渲染前就完成跳转,没有闪烁。要点是「必须处理鉴权状态还在加载时的情况」(否则刷新页面会闪一下登录页)。

  2. 「前端路由守卫是安全措施吗?」
    不是。前端守卫只控制「用户能不能进入某个页面」,属于体验优化——因为任何人都可以:① 在控制台改 store/state;② 直接调接口(Postman/curl);③ 修改前端代码绕过判断。真正的权限必须在服务端校验(接口层根据 token 判断身份与权限、按用户过滤数据)。前端守卫的价值在于「避免用户看到空白/报错页」「减少无效请求」「引导用户登录」。

  3. 「为什么刷新页面会『闪一下登录页』?」
    因为鉴权状态通常是异步获取的(token 在 localStorage → 请求 /me 接口 → 得到 user)。如果 user 的初始值是 null,那么首次渲染时守卫会认为「未登录」→ 立刻 <Navigate to="/login" /> → 用户看到登录页闪一下。解法是引入「三态」:loading(鉴权状态未就绪 → 显示骨架)→ authenticated / unauthenticated(才做跳转判断)。或者用数据路由的 loader 在渲染前完成校验。

  4. 「登录后怎么跳回原页面?」
    两步:① 守卫里记录来源——<Navigate to="/login" state={{ from: location }} replace />;② 登录成功后回跳——从 location.state.from 取出路径,navigate(\${from.pathname}${from.search}`, { replace: true })。**注意要同时保留pathname与search**(否则会丢失筛选/分页参数),并且用replace: true` 避免用户「后退」回到登录页。

  5. 「401 和 403 有什么区别?该怎么处理?」
    401 Unauthorized —— 没有有效身份凭证(未登录 / token 过期 / token 无效)→ 应该清除本地状态并跳转登录页;403 Forbidden —— 身份有效但无权限(已登录但没有访问该资源的权限)→ 应该提示「无权限」(跳登录页没有意义,因为用户登录了也没权限)。混淆两者会导致「明明已登录却反复被踢到登录页」的糟糕体验。两者都应该在请求拦截器里统一处理,而不是每个页面各写一遍。

  6. 「权限粒度怎么设计?」
    四个层次:① 页面级——整个路由受保护(<RequireRole role="admin">);② 按钮/操作级——同一页面里不同按钮按「权限点」显示(hasPermission('order:delete'));③ 菜单级——按权限过滤菜单(最好与「路由配置」共用同一份数据,避免不一致);④ 数据级——「用户只能看到自己的数据」,这只能由后端保证(前端的过滤可以被绕过)。实践上常见的是「页面级 + 按钮级 + 菜单级」用同一份「权限点」配置驱动。

  7. 「为什么 403 不应该跳登录页?」
    因为两者语义不同:403 表示「你已登录但没权限」——此时用户跳到登录页也解决不了问题(登录了还是没有权限),只会让用户困惑(「我刚登录怎么又让我登录」)。正确的处理是「就地提示无权限」(toast、403 页面、或把按钮置灰)。只有 401(身份问题)才应该跳登录页。

  8. 「退出登录要清理哪些东西?」
    至少四类:① 鉴权状态(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。

赞(0) 打赏
未经允许不得转载:陌上寒 » 6.7 路由守卫与权限

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫