跳转到内容
123xiao | 无名键客

《前端中级实战:基于 React 与 TypeScript 构建可维护的权限控制与动态路由方案》

字数: 0 阅读时长: 1 分钟

前端中级实战:基于 React 与 TypeScript 构建可维护的权限控制与动态路由方案

很多团队在项目初期做权限控制时,都会先来一版“能跑就行”的实现:

  • 路由表里塞一个 role: 'admin'
  • 登录后把用户角色放到全局状态
  • 每次渲染页面时判断一下能不能进
  • 菜单和路由各维护一份,后面再同步

一开始确实很快,但项目一大,问题会立刻冒出来:

  • 菜单显示和路由访问不一致
  • 按钮级权限散落各处,难以追踪
  • 后端返回动态菜单后,前端类型全丢了
  • 新增角色或权限点时,改动面非常大
  • 页面刷新后动态路由丢失,白屏或 404

我自己在中型后台项目里踩过最深的坑,就是“菜单、路由、权限三套配置各管各的”。当页面超过 30 个以后,维护成本会明显失控。

这篇文章我们不讲“最简单 demo”,而是从一个中级项目能长期维护的角度,带你做一套:

  • React + TypeScript
  • 可扩展的权限模型
  • 支持动态路由注入
  • 菜单、页面访问、按钮权限统一管理
  • 可运行的示例代码

背景与问题

在中后台系统里,权限控制通常不是只有一种需求,而是几层叠加:

  1. 页面级权限
    某个路由用户能不能访问。

  2. 菜单级权限
    左侧菜单是否显示。

  3. 操作级权限
    比如“新增用户”“删除订单”“导出报表”按钮是否可见或可点击。

  4. 动态路由
    登录后根据用户身份、租户、组织、功能开通情况,动态生成路由。

如果只靠“写死在前端”的角色判断,通常会出现两个问题:

  • 可维护性差:角色越来越多,判断条件越来越复杂
  • 灵活性差:一旦权限策略变化,要发版才能生效

所以更稳妥的方案通常是:

  • 前端维护一份标准路由注册表
  • 后端返回用户权限或菜单树
  • 前端根据权限结果过滤并生成最终可访问路由
  • 页面内按钮通过统一 Hook / 组件判断权限

前置知识与环境准备

本文示例基于以下技术栈:

  • React 18
  • React Router v6
  • TypeScript 5+
  • Vite

初始化项目:

npm create vite@latest react-auth-router-demo -- --template react-ts
cd react-auth-router-demo
npm install
npm install react-router-dom
npm run dev

本文示例目录结构如下:

src/
  api/
    auth.ts
  components/
    Guard.tsx
    PermissionButton.tsx
  hooks/
    usePermission.ts
  layout/
    AppLayout.tsx
  pages/
    Dashboard.tsx
    UserList.tsx
    RoleList.tsx
    OrderList.tsx
    Forbidden.tsx
    Login.tsx
    NotFound.tsx
  router/
    routeRegistry.tsx
    buildRoutes.tsx
  store/
    auth.tsx
  types/
    auth.ts
  main.tsx
  App.tsx

核心原理

这一套方案的重点,不是“怎么判断权限”,而是怎么设计边界。我建议把职责拆成四层:

  1. 权限数据层
    用户拥有哪些权限码、角色、菜单项。

  2. 路由注册层
    前端有哪些页面可供挂载,统一登记。

  3. 路由构建层
    根据权限过滤出当前用户可访问的路由。

  4. 视图守卫层
    页面级、按钮级做最终拦截。

1. 权限模型:优先使用“权限码”,角色只做聚合

很多项目喜欢直接判断角色:

if (user.role === 'admin') { ... }

这样短期简单,长期会很痛。更好的方式是:

  • 角色是一个“权限集合”
  • 前端真正判断的是权限码

例如:

  • dashboard:view
  • user:list
  • user:create
  • user:delete
  • role:list
  • order:list

这样做的好处是:

  • 角色变化时,前端判断逻辑不用大改
  • 更适合按钮级细粒度控制
  • 后端更容易统一授权模型

2. 动态路由不是“后端直接返回组件”,而是“返回路由 key”

这是一个很重要的边界。

不要让后端直接返回组件路径再让前端动态 import 任意文件。

更稳妥的方式是:

  • 前端维护一份受控注册表
  • 后端只返回 routeKey
  • 前端根据 routeKey 从注册表中找到对应组件

例如后端返回:

[
  { "routeKey": "dashboard", "path": "/dashboard" },
  { "routeKey": "userList", "path": "/users" }
]

前端注册表:

{
  dashboard: Dashboard,
  userList: UserList
}

这样可以避免:

  • 任意路径注入
  • 配置失控
  • 类型缺失
  • 组件加载不受控

3. 一份元数据,驱动菜单、路由、权限

最容易失控的点,是:

  • 路由配置一份
  • 菜单配置一份
  • 权限配置一份

更好的实践是:让一份路由元数据承担更多职责

例如每条路由都带上:

  • title
  • menu
  • permission
  • children

最终:

  • 菜单从路由树生成
  • 页面守卫看 permission
  • 按钮权限用统一权限集合判断

方案总览图

flowchart TD
    A[用户登录] --> B[获取用户信息]
    B --> C[获取权限码列表/菜单树]
    C --> D[前端路由注册表匹配]
    D --> E[过滤可访问路由]
    E --> F[生成菜单]
    E --> G[挂载 Router]
    G --> H[页面级权限守卫]
    H --> I[按钮级权限控制]

数据流时序图

sequenceDiagram
    participant U as 用户
    participant P as React页面
    participant A as AuthProvider
    participant S as 后端接口
    participant R as Router Builder

    U->>P: 打开系统并登录
    P->>A: 调用 login()
    A->>S: 请求 token / 用户信息 / 权限
    S-->>A: 返回 permissions + menus
    A->>R: 构建当前用户路由
    R-->>A: 返回可访问路由树
    A-->>P: 更新 auth 状态
    P->>P: 渲染菜单与页面

类型设计

先把核心类型定义好,这是 TypeScript 真正能帮我们兜底的地方。

src/types/auth.ts

import type { ReactNode, ComponentType } from 'react';

export type PermissionCode =
  | 'dashboard:view'
  | 'user:list'
  | 'user:create'
  | 'user:delete'
  | 'role:list'
  | 'order:list';

export type RouteKey =
  | 'dashboard'
  | 'userList'
  | 'roleList'
  | 'orderList';

export interface UserInfo {
  id: string;
  name: string;
  roles: string[];
  permissions: PermissionCode[];
}

export interface BackendRouteItem {
  routeKey: RouteKey;
  path: string;
  title: string;
  permission?: PermissionCode;
  menu?: boolean;
  children?: BackendRouteItem[];
}

export interface AppRouteMeta {
  title: string;
  permission?: PermissionCode;
  menu?: boolean;
}

export interface AppRouteConfig {
  key: RouteKey;
  path: string;
  element: ComponentType;
  meta: AppRouteMeta;
  children?: AppRouteConfig[];
}

export interface BuiltRoute {
  key: RouteKey;
  path: string;
  element: ReactNode;
  meta: AppRouteMeta;
  children?: BuiltRoute[];
}

实战代码(可运行)

下面我们做一个能直接跑起来的版本。为了方便演示,后端接口先用 mock 模拟。


第一步:准备页面组件

src/pages/Dashboard.tsx

export default function Dashboard() {
  return <h2>Dashboard 页面</h2>;
}

src/pages/UserList.tsx

import PermissionButton from '../components/PermissionButton';

export default function UserList() {
  return (
    <div>
      <h2>用户列表</h2>
      <PermissionButton permission="user:create">新增用户</PermissionButton>
      <PermissionButton permission="user:delete">删除用户</PermissionButton>
    </div>
  );
}

src/pages/RoleList.tsx

export default function RoleList() {
  return <h2>角色列表</h2>;
}

src/pages/OrderList.tsx

export default function OrderList() {
  return <h2>订单列表</h2>;
}

src/pages/Forbidden.tsx

export default function Forbidden() {
  return <h2>403 Forbidden:你没有访问权限</h2>;
}

src/pages/NotFound.tsx

export default function NotFound() {
  return <h2>404 Not Found</h2>;
}

src/pages/Login.tsx

import { useNavigate } from 'react-router-dom';
import { useAuth } from '../store/auth';

export default function Login() {
  const navigate = useNavigate();
  const { loginAsAdmin, loginAsEditor } = useAuth();

  return (
    <div>
      <h2>登录页</h2>
      <button
        onClick={async () => {
          await loginAsAdmin();
          navigate('/');
        }}
      >
        以管理员登录
      </button>

      <button
        onClick={async () => {
          await loginAsEditor();
          navigate('/');
        }}
        style={{ marginLeft: 12 }}
      >
        以编辑者登录
      </button>
    </div>
  );
}

第二步:模拟后端接口

src/api/auth.ts

import type { BackendRouteItem, UserInfo } from '../types/auth';

export async function fetchAdminProfile(): Promise<{
  user: UserInfo;
  routes: BackendRouteItem[];
}> {
  return Promise.resolve({
    user: {
      id: '1',
      name: 'Admin',
      roles: ['admin'],
      permissions: [
        'dashboard:view',
        'user:list',
        'user:create',
        'user:delete',
        'role:list',
        'order:list',
      ],
    },
    routes: [
      {
        routeKey: 'dashboard',
        path: '/dashboard',
        title: '仪表盘',
        permission: 'dashboard:view',
        menu: true,
      },
      {
        routeKey: 'userList',
        path: '/users',
        title: '用户管理',
        permission: 'user:list',
        menu: true,
      },
      {
        routeKey: 'roleList',
        path: '/roles',
        title: '角色管理',
        permission: 'role:list',
        menu: true,
      },
      {
        routeKey: 'orderList',
        path: '/orders',
        title: '订单管理',
        permission: 'order:list',
        menu: true,
      },
    ],
  });
}

export async function fetchEditorProfile(): Promise<{
  user: UserInfo;
  routes: BackendRouteItem[];
}> {
  return Promise.resolve({
    user: {
      id: '2',
      name: 'Editor',
      roles: ['editor'],
      permissions: ['dashboard:view', 'user:list', 'user:create'],
    },
    routes: [
      {
        routeKey: 'dashboard',
        path: '/dashboard',
        title: '仪表盘',
        permission: 'dashboard:view',
        menu: true,
      },
      {
        routeKey: 'userList',
        path: '/users',
        title: '用户管理',
        permission: 'user:list',
        menu: true,
      },
    ],
  });
}

第三步:前端受控路由注册表

src/router/routeRegistry.tsx

import Dashboard from '../pages/Dashboard';
import UserList from '../pages/UserList';
import RoleList from '../pages/RoleList';
import OrderList from '../pages/OrderList';
import type { RouteKey, AppRouteConfig } from '../types/auth';

const routeRegistry: Record<RouteKey, Omit<AppRouteConfig, 'path'>> = {
  dashboard: {
    key: 'dashboard',
    element: Dashboard,
    meta: {
      title: '仪表盘',
      permission: 'dashboard:view',
      menu: true,
    },
  },
  userList: {
    key: 'userList',
    element: UserList,
    meta: {
      title: '用户管理',
      permission: 'user:list',
      menu: true,
    },
  },
  roleList: {
    key: 'roleList',
    element: RoleList,
    meta: {
      title: '角色管理',
      permission: 'role:list',
      menu: true,
    },
  },
  orderList: {
    key: 'orderList',
    element: OrderList,
    meta: {
      title: '订单管理',
      permission: 'order:list',
      menu: true,
    },
  },
};

export default routeRegistry;

这里有个实践细节:路径 path 不放死在注册表里,而是允许以后端返回为准
这样可以兼容多租户、不同部署策略、不同产品版本下的路由差异。


第四步:构建动态路由

src/router/buildRoutes.tsx

import React from 'react';
import type { BackendRouteItem, BuiltRoute, PermissionCode } from '../types/auth';
import routeRegistry from './routeRegistry';
import Guard from '../components/Guard';

function hasPermission(
  userPermissions: PermissionCode[],
  required?: PermissionCode
) {
  if (!required) return true;
  return userPermissions.includes(required);
}

export function buildRoutes(
  backendRoutes: BackendRouteItem[],
  userPermissions: PermissionCode[]
): BuiltRoute[] {
  return backendRoutes.flatMap((item) => {
    const registered = routeRegistry[item.routeKey];
    if (!registered) {
      console.warn(`未注册的 routeKey: ${item.routeKey}`);
      return [];
    }

    const permission = item.permission ?? registered.meta.permission;
    if (!hasPermission(userPermissions, permission)) {
      return [];
    }

    const Element = registered.element;

    const built: BuiltRoute = {
      key: item.routeKey,
      path: item.path,
      meta: {
        title: item.title || registered.meta.title,
        permission,
        menu: item.menu ?? registered.meta.menu,
      },
      element: (
        <Guard permission={permission}>
          <Element />
        </Guard>
      ),
      children: item.children
        ? buildRoutes(item.children, userPermissions)
        : undefined,
    };

    return [built];
  });
}

第五步:页面级权限守卫

src/components/Guard.tsx

import type { ReactNode } from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../store/auth';
import type { PermissionCode } from '../types/auth';

interface GuardProps {
  permission?: PermissionCode;
  children: ReactNode;
}

export default function Guard({ permission, children }: GuardProps) {
  const { isAuthenticated, hasPermission } = useAuth();

  if (!isAuthenticated) {
    return <Navigate to="/login" replace />;
  }

  if (permission && !hasPermission(permission)) {
    return <Navigate to="/403" replace />;
  }

  return <>{children}</>;
}

第六步:按钮级权限控制

src/hooks/usePermission.ts

import { useAuth } from '../store/auth';
import type { PermissionCode } from '../types/auth';

export function usePermission(permission?: PermissionCode) {
  const { hasPermission } = useAuth();
  if (!permission) return true;
  return hasPermission(permission);
}

src/components/PermissionButton.tsx

import type { ReactNode, ButtonHTMLAttributes } from 'react';
import { usePermission } from '../hooks/usePermission';
import type { PermissionCode } from '../types/auth';

interface PermissionButtonProps
  extends ButtonHTMLAttributes<HTMLButtonElement> {
  permission: PermissionCode;
  children: ReactNode;
  mode?: 'hide' | 'disable';
}

export default function PermissionButton({
  permission,
  children,
  mode = 'hide',
  ...rest
}: PermissionButtonProps) {
  const allowed = usePermission(permission);

  if (!allowed && mode === 'hide') {
    return null;
  }

  return (
    <button {...rest} disabled={!allowed || rest.disabled}>
      {children}
    </button>
  );
}

这里我建议保留两种模式:

  • hide:无权限时直接隐藏
  • disable:无权限时禁用但保留按钮

对于“删除”“导出”这类敏感操作,disable 有时更利于用户理解系统能力边界。


第七步:认证状态管理

为了不引入额外状态库,我们直接用 Context 实现。

src/store/auth.tsx

import {
  createContext,
  useContext,
  useMemo,
  useState,
  type ReactNode,
} from 'react';
import { fetchAdminProfile, fetchEditorProfile } from '../api/auth';
import { buildRoutes } from '../router/buildRoutes';
import type { BuiltRoute, PermissionCode, UserInfo } from '../types/auth';

interface AuthContextValue {
  user: UserInfo | null;
  routes: BuiltRoute[];
  isAuthenticated: boolean;
  loginAsAdmin: () => Promise<void>;
  loginAsEditor: () => Promise<void>;
  logout: () => void;
  hasPermission: (permission: PermissionCode) => boolean;
}

const AuthContext = createContext<AuthContextValue | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<UserInfo | null>(null);
  const [routes, setRoutes] = useState<BuiltRoute[]>([]);

  const loginAsAdmin = async () => {
    const { user, routes } = await fetchAdminProfile();
    setUser(user);
    setRoutes(buildRoutes(routes, user.permissions));
  };

  const loginAsEditor = async () => {
    const { user, routes } = await fetchEditorProfile();
    setUser(user);
    setRoutes(buildRoutes(routes, user.permissions));
  };

  const logout = () => {
    setUser(null);
    setRoutes([]);
  };

  const hasPermission = (permission: PermissionCode) => {
    return !!user?.permissions.includes(permission);
  };

  const value = useMemo(
    () => ({
      user,
      routes,
      isAuthenticated: !!user,
      loginAsAdmin,
      loginAsEditor,
      logout,
      hasPermission,
    }),
    [user, routes]
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) {
    throw new Error('useAuth 必须在 AuthProvider 内使用');
  }
  return ctx;
}

第八步:布局与菜单渲染

src/layout/AppLayout.tsx

import { Link, Outlet } from 'react-router-dom';
import { useAuth } from '../store/auth';

export default function AppLayout() {
  const { user, routes, logout } = useAuth();

  return (
    <div style={{ display: 'flex', minHeight: '100vh' }}>
      <aside
        style={{
          width: 220,
          borderRight: '1px solid #ddd',
          padding: 16,
          boxSizing: 'border-box',
        }}
      >
        <h3>菜单</h3>
        <ul>
          {routes
            .filter((route) => route.meta.menu)
            .map((route) => (
              <li key={route.key}>
                <Link to={route.path}>{route.meta.title}</Link>
              </li>
            ))}
        </ul>
      </aside>

      <main style={{ flex: 1, padding: 16 }}>
        <div style={{ marginBottom: 16 }}>
          <span>当前用户:{user?.name ?? '未登录'}</span>
          <button onClick={logout} style={{ marginLeft: 12 }}>
            退出登录
          </button>
        </div>
        <Outlet />
      </main>
    </div>
  );
}

第九步:将动态路由挂到 React Router

src/App.tsx

import { Navigate, Route, Routes } from 'react-router-dom';
import { useAuth } from './store/auth';
import AppLayout from './layout/AppLayout';
import Login from './pages/Login';
import Forbidden from './pages/Forbidden';
import NotFound from './pages/NotFound';

export default function App() {
  const { routes, isAuthenticated } = useAuth();

  return (
    <Routes>
      <Route path="/login" element={<Login />} />
      <Route path="/403" element={<Forbidden />} />

      <Route
        path="/"
        element={
          isAuthenticated ? <AppLayout /> : <Navigate to="/login" replace />
        }
      >
        <Route
          index
          element={
            routes.length > 0 ? (
              <Navigate to={routes[0].path} replace />
            ) : (
              <div>暂无可访问页面</div>
            )
          }
        />

        {routes.map((route) => (
          <Route key={route.key} path={route.path} element={route.element} />
        ))}
      </Route>

      <Route path="*" element={<NotFound />} />
    </Routes>
  );
}

这里为了示例简单,先演示一级路由。如果你有嵌套路由需求,可以把 children 递归转成 <Route> 节点。


第十步:入口文件

src/main.tsx

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './store/auth';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <BrowserRouter>
      <AuthProvider>
        <App />
      </AuthProvider>
    </BrowserRouter>
  </React.StrictMode>
);

到这里,项目已经可以跑起来了。


路由与权限关系图

classDiagram
    class BackendRouteItem {
      +routeKey: RouteKey
      +path: string
      +title: string
      +permission: PermissionCode
      +menu: boolean
    }

    class AppRouteConfig {
      +key: RouteKey
      +path: string
      +element: ComponentType
      +meta: AppRouteMeta
    }

    class BuiltRoute {
      +key: RouteKey
      +path: string
      +element: ReactNode
      +meta: AppRouteMeta
    }

    class UserInfo {
      +id: string
      +name: string
      +roles: string[]
      +permissions: PermissionCode[]
    }

    BackendRouteItem --> BuiltRoute
    AppRouteConfig --> BuiltRoute
    UserInfo --> BuiltRoute

逐步验证清单

你可以按这个顺序验证方案是否工作正常。

验证 1:管理员登录

预期结果:

  • 可以看到 仪表盘 / 用户管理 / 角色管理 / 订单管理
  • 在用户列表中可以看到:
    • 新增用户
    • 删除用户

验证 2:编辑者登录

预期结果:

  • 只能看到 仪表盘 / 用户管理
  • 用户列表中:
    • 可以看到“新增用户”
    • 看不到“删除用户”

验证 3:手动输入无权限路由

例如编辑者直接访问:

/roles

预期结果:

  • 路由本身通常不会被挂载
  • 或被页面守卫跳转到 /403

验证 4:注销后访问内部页面

预期结果:

  • 自动跳回 /login

常见坑与排查

这部分我建议认真看,很多“看起来像路由问题”的 bug,本质上是权限状态和渲染时机没处理好。

坑 1:页面刷新后动态路由丢失

现象:

  • 登录后一切正常
  • 浏览器刷新后,菜单没了,访问某些路径直接 404

原因:

动态路由保存在内存里,刷新后状态重置。

解决思路:

  • 把 token 持久化到 localStorage / cookie
  • 应用启动时重新拉取用户信息与权限
  • 在“权限数据未恢复”前显示 loading,而不是直接渲染路由

一个更完整的生产版会多一个 bootstrapping 状态:

if (loading) {
  return <div>权限初始化中...</div>;
}

坑 2:菜单能看到,但页面进不去

现象:

  • 菜单显示了“用户管理”
  • 点击后跳 403

常见原因:

  • 菜单使用的是后端菜单数据
  • 页面守卫使用的是另一套前端权限配置
  • 两边权限码不一致

建议:

让菜单和页面都尽量基于同一份路由构建结果生成,不要分叉。

坑 3:按钮隐藏了,但接口还能调用

这个非常常见,也非常危险。

前端隐藏按钮 ≠ 真正安全。

比如用户通过浏览器控制台、抓包工具,依然可能直接发请求。

正确认知:

  • 前端权限控制主要是体验层 + 弱约束层
  • 真正授权必须由后端校验

坑 4:后端返回了未知 routeKey

现象:

  • 某些环境下页面白屏
  • 控制台报错找不到组件

排查:

buildRoutes 是否对未知 routeKey 做了容错。
本文里我们做了:

if (!registered) {
  console.warn(`未注册的 routeKey: ${item.routeKey}`);
  return [];
}

生产环境里建议顺手加日志上报。

坑 5:权限判断写在 JSX 里到处都是

例如:

{user?.roles.includes('admin') && <button>删除</button>}

短期能跑,长期难维护。
建议统一改成:

  • usePermission
  • PermissionButton
  • Guard

这样后期改策略时,修改点更集中。


安全/性能最佳实践

这一节很关键。权限方案如果只顾“显示对了”,但忽略安全和性能,后期代价会很高。

安全最佳实践

1. 前端权限永远不是最终防线

必须强调一次:

  • 前端控制“显示”和“导航”
  • 后端控制“真实授权”

任何敏感接口都必须在服务端校验:

  • 当前用户身份
  • 当前租户范围
  • 当前资源归属
  • 当前操作权限

2. 后端只返回受控标识,不返回任意组件路径

推荐:

{ "routeKey": "userList" }

不推荐:

{ "component": "/src/pages/xxx.tsx" }

前者受控、可审计、可兜底;后者很容易造成配置和安全问题。

3. 权限码命名统一

最好采用统一格式:

资源:动作

例如:

  • user:list
  • user:create
  • user:update
  • user:delete

这样前后端、文档、测试都更容易协同。

4. 对关键页面做二次校验

即使页面通过了路由守卫,在进入某些高风险页面时,也可以再次校验关键上下文,例如:

  • 当前组织是否启用某功能
  • 当前数据是否属于本用户可管理范围

性能最佳实践

1. 路由表构建放在登录后或初始化阶段

不要每次渲染都重新 buildRoutes
本文中是在登录时构建一次,再放入状态里,这个做法是合理的。

2. 页面组件按需懒加载

如果页面很多,可以把注册表改成 React.lazy

import { lazy } from 'react';

const Dashboard = lazy(() => import('../pages/Dashboard'));

再配合 Suspense,首屏会更轻。

3. 权限集合可转为 Set

当权限点较多时,includes 每次线性查找会有一点浪费。可以在状态层缓存成 Set

const permissionSet = new Set(user.permissions);
permissionSet.has('user:create');

中小项目不一定急着上,但大后台值得做。

4. 菜单树和路由树尽量共用构建结果

不要:

  • 页面用一套过滤逻辑
  • 菜单再跑一套过滤逻辑

同一份构建结果复用,最省心,也最不容易出错。


进阶扩展建议

如果你的项目已经不是简单后台,而是更复杂的企业级应用,可以继续往下演进。

1. 支持嵌套路由与面包屑

BackendRouteItem 里保留 children,递归构建:

  • 嵌套路由
  • 菜单树
  • 面包屑数据

2. 支持租户级功能开关

有些页面不是“没权限”,而是“这个租户没开通”。
这时可以把判断拆成两类:

  • permission:用户是否可操作
  • featureFlag:系统能力是否开放

3. 支持资源级权限

例如用户有 order:list,但只能看“自己部门”的订单。
这种就不是单纯前端路由能解决的,需要后端返回更细的授权范围,并在接口层执行。

4. 接入 Zustand / Redux Toolkit

当权限状态和用户上下文更复杂时,可以把 AuthProvider 换成更成熟的状态管理方案。但核心设计思想不变:

  • 用户状态
  • 权限集合
  • 构建后的路由树
  • 统一的权限判断入口

一套更稳的落地原则

如果你要把本文方案带到真实项目,我建议记住下面 5 条:

  1. 判断权限尽量基于权限码,不直接绑死角色
  2. 后端返回 routeKey,前端做受控映射
  3. 菜单、路由、按钮权限尽量复用同一套元数据
  4. 前端负责体验,后端负责最终授权
  5. 把权限判断收口到 Hook、Guard、组件,别散写

这 5 条做到了,项目即使后面翻倍扩张,也还在可维护范围内。


总结

这篇文章我们从“中级实战”的角度,搭了一套可维护的权限控制与动态路由方案,核心目标不是炫技,而是解决真实项目里的三个长期问题:

  • 配置不一致
  • 权限散落难维护
  • 动态路由刷新易失效

整套思路可以概括为:

  • 权限码 替代硬编码角色判断
  • 前端路由注册表 承接页面组件
  • 后端 routeKey + 权限数据 驱动动态路由
  • Guard / Hook / PermissionButton 统一权限入口

如果你现在的项目还处在“菜单一套、路由一套、按钮一套”的状态,我建议先不要一次性大改。最现实的做法是:

  1. 先统一权限码命名
  2. 再做路由注册表
  3. 然后把按钮权限收口到组件或 Hook
  4. 最后补上刷新恢复和初始化加载态

这样改造风险最小,也最容易逐步上线。

如果只给一个落地建议,那就是:先把“权限判断逻辑”集中起来,再谈动态路由。
因为真正让项目变难维护的,往往不是“有没有动态路由”,而是“权限判断写得到处都是”。


分享到:

上一篇
《Web逆向实战:基于浏览器开发者工具定位并还原前端加密请求参数的完整方法》
下一篇
《Java 开发踩坑实战:排查与修复线程池误用导致的内存暴涨和请求堆积》