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

《前端中级实战:基于 React 与 TypeScript 构建可维护的权限路由与菜单系统》

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

前端中级实战:基于 React 与 TypeScript 构建可维护的权限路由与菜单系统

做后台管理系统时,权限路由和菜单系统几乎是绕不过去的一关。刚开始项目小、角色少时,大家常常直接在页面里写几个 if (role === 'admin'),看起来挺快;但一旦页面增多、角色细分、菜单嵌套、按钮权限接进来,代码很快就会变成“谁也不敢动”的区域。

我自己在中型后台项目里踩过不少坑:

  • 路由配置和菜单配置写了两份,后面必然不同步
  • 页面能访问,但菜单不显示;或者菜单显示了,点进去 403
  • 类型约束不够,后端一改字段,前端静悄悄出错
  • 权限判断散落在组件里,后面做审计和排查非常痛苦

这篇文章从架构设计角度,带你做一套可维护、可扩展、类型安全的权限路由与菜单系统。技术栈使用 React + TypeScript + React Router,重点不只是“能跑”,而是“后面还能改”。


背景与问题

先看几个常见场景:

  1. 同一路由,不同角色看到不同菜单

    • 管理员可见“用户管理”
    • 普通运营只能看到“内容审核”
  2. 菜单来自统一配置,但页面访问要二次校验

    • 菜单隐藏不等于安全
    • 用户手输 URL 仍然可能访问页面
  3. 权限不只有角色

    • 有的是角色型权限:admineditor
    • 有的是资源型权限:user.readuser.edit
    • 有的是按钮级权限:audit:approve
  4. 后端返回用户权限,前端要动态生成可访问路由

    • 登录后根据当前用户权限裁剪路由树
    • 页面刷新后能恢复
    • 菜单和路由保持同源

这些问题背后,本质上是三个目标:

  • 单一数据源:路由、菜单、权限规则尽量统一配置
  • 类型安全:配置字段、权限字段、组件映射可检查
  • 运行时可控:支持登录后动态生成、支持 403/404、支持懒加载

方案概览与取舍分析

在中后台项目里,常见有三种做法。

方案一:纯前端硬编码权限

把所有角色和权限规则写在前端配置里。

优点

  • 上手快
  • 本地开发简单
  • 类型约束容易做

缺点

  • 权限变更要发版
  • 与后端规则容易不一致
  • 多系统共享权限时维护成本高

方案二:后端返回完整菜单树,前端只渲染

登录后后端直接返回当前用户菜单和权限。

优点

  • 权限中心化
  • 前后端规则更统一
  • 后端可按用户精确下发

缺点

  • 前端路由组件映射复杂
  • 若只返回菜单,不返回明确权限模型,按钮权限难统一
  • 类型约束偏弱,尤其是组件路径映射

方案三:前端维护路由元数据,后端返回权限集

也就是本文推荐的折中方案:

  • 前端维护全量路由配置
  • 后端返回当前用户权限集/角色集
  • 前端根据规则裁剪出可访问路由树和菜单树

优点

  • 页面组件和路由强绑定,类型更稳
  • 菜单和路由同源
  • 支持后端动态权限,又不完全依赖后端下发菜单结构

缺点

  • 前端要维护一份全量配置
  • 路由裁剪逻辑要设计好
  • 与后端要约定统一权限编码规范

如果你的项目是 React 中后台,页面数量中等到较多,我建议优先考虑方案三。它在可维护性和工程复杂度之间比较平衡。


核心原理

这套系统可以拆成四层:

  1. 权限模型层
    定义角色、权限点、判断规则

  2. 路由配置层
    在路由节点上声明 meta 信息,例如标题、图标、权限要求、是否显示在菜单中

  3. 裁剪与生成层
    根据当前用户权限,从全量路由树中筛出可访问路由树和菜单树

  4. 运行时守卫层
    在页面渲染时再次校验,防止用户手输 URL 进入无权限页面


架构图

flowchart TD
    A[用户登录] --> B[后端返回角色集/权限集]
    B --> C[前端加载全量路由配置]
    C --> D[权限过滤器裁剪路由树]
    D --> E[生成 React Router 路由]
    D --> F[生成侧边菜单]
    E --> G[访问页面时路由守卫二次校验]
    G -->|通过| H[渲染业务页面]
    G -->|拒绝| I[跳转 403]
classDiagram
    class AppRoute {
      +string path
      +string key
      +ReactNode element
      +AppRoute[] children
      +RouteMeta meta
    }

    class RouteMeta {
      +string title
      +boolean menu
      +string icon
      +string[] roles
      +string[] permissions
      +boolean hidden
    }

    class AuthContextValue {
      +string[] roles
      +string[] permissions
      +boolean hasRole()
      +boolean hasPermission()
      +boolean canAccess()
    }

    AppRoute --> RouteMeta

权限模型设计

先不要急着写路由。第一步是把权限模型设计清楚。

1. 角色与权限点分离

角色适合做粗粒度控制,比如:

  • admin
  • operator
  • auditor

权限点适合做细粒度控制,比如:

  • dashboard.view
  • user.read
  • user.edit
  • audit.approve

一个比较稳妥的经验是:

  • 页面级控制:角色 + 权限点都支持
  • 按钮级控制:优先使用权限点

2. 路由元信息建议

每个路由节点除了 pathelement,建议增加:

  • title: 菜单标题
  • icon: 菜单图标标识
  • menu: 是否参与菜单渲染
  • hidden: 是否隐藏
  • roles: 允许访问的角色
  • permissions: 允许访问的权限点
  • order: 菜单排序
  • keepAlive / lazy:按项目需要扩展

3. 权限判断规则

最常见的规则有两类:

  • 满足任一角色即可访问
  • 满足任一权限点即可访问

rolespermissions 同时存在时,要提前定规则。本文采用:

只要满足 rolespermissions 中任意一类即可访问;
如果两者都没配置,则认为公开可访问。

你也可以改成更严格的“同时满足”,但要统一,不然团队里每个人理解都不同。


实战代码(可运行)

下面给一套可落地的最小实现。为了聚焦核心逻辑,我使用 react-router-dom@6 风格。

目录结构示例

src/
  app/
    router.tsx
  auth/
    auth-context.tsx
    access.ts
  routes/
    route-config.tsx
    route-filter.ts
  pages/
    login.tsx
    dashboard.tsx
    user-list.tsx
    audit.tsx
    forbidden.tsx
    not-found.tsx
  menu/
    side-menu.tsx
  main.tsx

第一步:定义类型

// src/routes/route-config.tsx
import React from 'react';

export type Role = 'admin' | 'operator' | 'auditor';
export type Permission =
  | 'dashboard.view'
  | 'user.read'
  | 'user.edit'
  | 'audit.read'
  | 'audit.approve';

export interface RouteMeta {
  title: string;
  menu?: boolean;
  hidden?: boolean;
  icon?: string;
  roles?: Role[];
  permissions?: Permission[];
  order?: number;
}

export interface AppRoute {
  key: string;
  path: string;
  element?: React.ReactNode;
  children?: AppRoute[];
  meta: RouteMeta;
}

这里我建议不要偷懒用 string[]。把权限点做成联合类型,IDE 自动提示会非常香,也能减少拼写错误。


第二步:定义页面

// src/pages/dashboard.tsx
export default function DashboardPage() {
  return <div>仪表盘</div>;
}
// src/pages/user-list.tsx
export default function UserListPage() {
  return <div>用户列表</div>;
}
// src/pages/audit.tsx
export default function AuditPage() {
  return <div>审核中心</div>;
}
// src/pages/forbidden.tsx
export default function ForbiddenPage() {
  return <div>403 Forbidden</div>;
}
// src/pages/not-found.tsx
export default function NotFoundPage() {
  return <div>404 Not Found</div>;
}
// src/pages/login.tsx
export default function LoginPage() {
  return <div>登录页</div>;
}

第三步:定义全量路由配置

// src/routes/route-config.tsx
import React from 'react';
import DashboardPage from '../pages/dashboard';
import UserListPage from '../pages/user-list';
import AuditPage from '../pages/audit';

export type Role = 'admin' | 'operator' | 'auditor';
export type Permission =
  | 'dashboard.view'
  | 'user.read'
  | 'user.edit'
  | 'audit.read'
  | 'audit.approve';

export interface RouteMeta {
  title: string;
  menu?: boolean;
  hidden?: boolean;
  icon?: string;
  roles?: Role[];
  permissions?: Permission[];
  order?: number;
}

export interface AppRoute {
  key: string;
  path: string;
  element?: React.ReactNode;
  children?: AppRoute[];
  meta: RouteMeta;
}

export const appRoutes: AppRoute[] = [
  {
    key: 'dashboard',
    path: '/dashboard',
    element: <DashboardPage />,
    meta: {
      title: '仪表盘',
      menu: true,
      icon: 'dashboard',
      permissions: ['dashboard.view'],
      order: 1,
    },
  },
  {
    key: 'user',
    path: '/users',
    element: <UserListPage />,
    meta: {
      title: '用户管理',
      menu: true,
      icon: 'user',
      roles: ['admin'],
      permissions: ['user.read'],
      order: 2,
    },
  },
  {
    key: 'audit',
    path: '/audit',
    element: <AuditPage />,
    meta: {
      title: '审核中心',
      menu: true,
      icon: 'audit',
      roles: ['admin', 'auditor'],
      permissions: ['audit.read'],
      order: 3,
    },
  },
];

这里有一个关键点:菜单配置不要单独再写一份
直接把菜单所需元数据挂在路由上,后面菜单树从路由树推导出来。


第四步:实现权限判断函数

// src/auth/access.ts
import type { AppRoute, Permission, Role } from '../routes/route-config';

export interface UserAuthInfo {
  roles: Role[];
  permissions: Permission[];
}

export function hasAnyRole(userRoles: Role[], routeRoles?: Role[]) {
  if (!routeRoles || routeRoles.length === 0) return false;
  return routeRoles.some((role) => userRoles.includes(role));
}

export function hasAnyPermission(
  userPermissions: Permission[],
  routePermissions?: Permission[]
) {
  if (!routePermissions || routePermissions.length === 0) return false;
  return routePermissions.some((permission) =>
    userPermissions.includes(permission)
  );
}

export function canAccessRoute(route: AppRoute, auth: UserAuthInfo) {
  const { roles, permissions } = route.meta;

  const noRoleLimit = !roles || roles.length === 0;
  const noPermissionLimit = !permissions || permissions.length === 0;

  if (noRoleLimit && noPermissionLimit) return true;

  return (
    hasAnyRole(auth.roles, roles) ||
    hasAnyPermission(auth.permissions, permissions)
  );
}

这部分逻辑很值得单独抽出来。因为它会被:

  • 路由过滤
  • 页面守卫
  • 按钮权限
  • 单元测试

重复使用。权限逻辑一旦散落,就很难改。


第五步:过滤可访问路由树

// src/routes/route-filter.ts
import type { AppRoute } from './route-config';
import type { UserAuthInfo } from '../auth/access';
import { canAccessRoute } from '../auth/access';

export function filterRoutes(routes: AppRoute[], auth: UserAuthInfo): AppRoute[] {
  return routes
    .filter((route) => canAccessRoute(route, auth))
    .map((route) => {
      const children = route.children
        ? filterRoutes(route.children, auth)
        : undefined;

      return {
        ...route,
        children,
      };
    });
}

export function getMenuRoutes(routes: AppRoute[]): AppRoute[] {
  return routes
    .filter((route) => route.meta.menu && !route.meta.hidden)
    .sort((a, b) => (a.meta.order ?? 0) - (b.meta.order ?? 0))
    .map((route) => ({
      ...route,
      children: route.children ? getMenuRoutes(route.children) : undefined,
    }));
}

注意这里是先过滤权限,再生成菜单
不要反过来,否则容易出现“菜单没问题,但路由还能访问”的分裂状态。


第六步:建立认证上下文

// src/auth/auth-context.tsx
import React, { createContext, useContext, useMemo } from 'react';
import type { Permission, Role } from '../routes/route-config';
import { hasAnyPermission, hasAnyRole } from './access';

interface AuthContextValue {
  roles: Role[];
  permissions: Permission[];
  hasRole: (roles: Role[]) => boolean;
  hasPermission: (permissions: Permission[]) => boolean;
}

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

interface Props {
  children: React.ReactNode;
  roles: Role[];
  permissions: Permission[];
}

export function AuthProvider({ children, roles, permissions }: Props) {
  const value = useMemo<AuthContextValue>(
    () => ({
      roles,
      permissions,
      hasRole: (targetRoles) => hasAnyRole(roles, targetRoles),
      hasPermission: (targetPermissions) =>
        hasAnyPermission(permissions, targetPermissions),
    }),
    [roles, permissions]
  );

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

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

第七步:实现路由守卫

React Router v6 没有传统意义上的全局 beforeEach,所以通常做法是封装一个权限组件。

// src/app/router.tsx
import React from 'react';
import {
  BrowserRouter,
  Navigate,
  Route,
  Routes,
} from 'react-router-dom';
import { appRoutes, type AppRoute } from '../routes/route-config';
import { filterRoutes } from '../routes/route-filter';
import { useAuth } from '../auth/auth-context';
import { canAccessRoute } from '../auth/access';
import LoginPage from '../pages/login';
import ForbiddenPage from '../pages/forbidden';
import NotFoundPage from '../pages/not-found';

function GuardedRoute({ route }: { route: AppRoute }) {
  const auth = useAuth();

  if (!canAccessRoute(route, auth)) {
    return <Navigate to="/403" replace />;
  }

  return <>{route.element}</>;
}

function renderRoutes(routes: AppRoute[]) {
  return routes.map((route) => (
    <Route
      key={route.key}
      path={route.path}
      element={<GuardedRoute route={route} />}
    />
  ));
}

function AppRouterInner() {
  const auth = useAuth();
  const accessibleRoutes = filterRoutes(appRoutes, auth);

  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      {renderRoutes(accessibleRoutes)}
      <Route path="/403" element={<ForbiddenPage />} />
      <Route path="*" element={<NotFoundPage />} />
    </Routes>
  );
}

export default function AppRouter() {
  return (
    <BrowserRouter>
      <AppRouterInner />
    </BrowserRouter>
  );
}

严格来说,这里有一点“过滤 + 守卫”的重复判断。但这是值得的:

  • 过滤:用于菜单和路由树生成
  • 守卫:用于最终访问兜底

我通常把它理解为:一个做体验,一个做边界


第八步:渲染侧边菜单

// src/menu/side-menu.tsx
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { appRoutes } from '../routes/route-config';
import { filterRoutes, getMenuRoutes } from '../routes/route-filter';
import { useAuth } from '../auth/auth-context';

export default function SideMenu() {
  const auth = useAuth();
  const location = useLocation();

  const menuRoutes = getMenuRoutes(filterRoutes(appRoutes, auth));

  return (
    <aside>
      <ul>
        {menuRoutes.map((route) => (
          <li key={route.key}>
            <Link
              to={route.path}
              style={{
                fontWeight: location.pathname === route.path ? 'bold' : 'normal',
              }}
            >
              {route.meta.title}
            </Link>
          </li>
        ))}
      </ul>
    </aside>
  );
}

第九步:入口整合

模拟一个当前登录用户。实际项目里,这部分通常来自登录接口或用户信息接口。

// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import AppRouter from './app/router';
import { AuthProvider } from './auth/auth-context';
import type { Permission, Role } from './routes/route-config';

const roles: Role[] = ['auditor'];
const permissions: Permission[] = ['dashboard.view', 'audit.read'];

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <AuthProvider roles={roles} permissions={permissions}>
      <AppRouter />
    </AuthProvider>
  </React.StrictMode>
);

这时候这个用户能访问:

  • /dashboard
  • /audit

不能访问:

  • /users

权限与访问时序

sequenceDiagram
    participant U as 用户
    participant FE as 前端应用
    participant BE as 后端接口
    participant G as 权限守卫

    U->>FE: 登录
    FE->>BE: 请求用户信息/权限集
    BE-->>FE: roles + permissions
    FE->>FE: 过滤路由树与菜单树
    U->>FE: 点击菜单或输入URL
    FE->>G: 校验当前路由权限
    G-->>FE: 允许/拒绝
    FE-->>U: 页面内容或403

进阶:支持嵌套路由与布局

实际项目中,路由往往不是平铺的,而是:

  • 顶层 Layout
  • 子路由挂在布局下
  • 部分页面隐藏菜单但可访问详情页

一个简单设计是:

  • 父节点负责布局与菜单分组
  • 子节点负责页面级权限
  • 详情页 hidden: true,但保留访问能力

示例:

// 片段示例
const nestedRoutes: AppRoute[] = [
  {
    key: 'system',
    path: '/system',
    element: <div>System Layout Outlet</div>,
    meta: {
      title: '系统管理',
      menu: true,
      roles: ['admin'],
    },
    children: [
      {
        key: 'system-users',
        path: '/system/users',
        element: <div>用户列表</div>,
        meta: {
          title: '用户列表',
          menu: true,
          permissions: ['user.read'],
        },
      },
      {
        key: 'system-user-detail',
        path: '/system/users/:id',
        element: <div>用户详情</div>,
        meta: {
          title: '用户详情',
          hidden: true,
          menu: false,
          permissions: ['user.read'],
        },
      },
    ],
  },
];

这里要注意一个架构决策:

父子权限是继承还是独立?

常见有两种:

  1. 父节点限制 + 子节点再限制

    • 更安全
    • 适合层级明确的管理系统
  2. 子节点完全独立判断

    • 更灵活
    • 容易出现父节点不可见但子节点可访问的情况

我的经验是:
菜单层看父节点,访问层看当前节点 + 可选叠加祖先权限。
如果系统偏严谨,建议做“祖先链权限合并校验”。


方案容量与复杂度估算

如果你的后台系统规模大概是:

  • 路由 50~150 个
  • 角色 5~20 个
  • 权限点 100~500 个

那么本文这套方案在前端依然是可控的,复杂度主要在:

  • 路由元数据维护
  • 权限编码命名规范
  • 页面/按钮粒度的一致性

什么时候该升级架构?

如果出现以下特征,可以考虑接入更完整的权限中心或后端动态菜单:

  • 多个前端系统共用一套权限模型
  • 权限变更非常频繁,不能依赖前端发版
  • 需要租户隔离、数据域隔离、字段级权限
  • 需要审计谁在何时拥有哪些权限

也就是说,本文方案很适合单系统或少量系统的中后台,但不是权限平台的终点形态。


常见坑与排查

这一部分我尽量写得接地气一点,因为真正让人头疼的,往往不是“不会写”,而是“看起来都对,就是不工作”。

1. 菜单显示正常,但手输 URL 还能访问

现象

  • 侧边栏没有“用户管理”
  • 但直接输入 /users 能打开页面

原因

  • 只做了菜单过滤,没有做页面守卫
  • 或者守卫写在布局层,子页面漏掉了

排查方式

  • 看是否存在 GuardedRoute
  • 看访问时是否再次调用 canAccessRoute

建议

  • 菜单过滤和页面守卫一定都要做
  • 把权限判断函数集中在一个模块里

2. 路由和菜单配置分离,后期一定打架

现象

  • 新增页面时要改两处甚至三处
  • 菜单标题、排序、图标和路由路径经常不一致

原因

  • 菜单树单独维护
  • 路由树单独维护
  • 权限点还可能再维护一份

建议

  • 使用“路由即菜单元数据源”
  • 菜单只做投影,不做主配置

3. 父路由被过滤后,子路由也丢了

现象

  • 明明子页面有权限,但因为父节点无权限,整个树都没了

原因

  • 递归过滤逻辑按父节点先剪掉了整个分支

排查思路

  • 看你的权限语义:父权限是否必须拥有?
  • 看过滤逻辑是否允许“父不可见、子可见”

建议

  • 先统一团队规则,再写代码
  • 对后台管理系统,我更建议父节点有基础权限,子节点做细化限制

4. 权限点拼写错误,运行时才发现

现象

  • user.reda 这种拼写,页面直接丢权限
  • 代码不报错

原因

  • 权限字段用了裸 string

建议

  • 权限编码尽量用 TypeScript 联合类型或常量枚举
  • 至少在开发期加校验工具

例如:

export const PERMISSIONS = {
  DASHBOARD_VIEW: 'dashboard.view',
  USER_READ: 'user.read',
  USER_EDIT: 'user.edit',
  AUDIT_READ: 'audit.read',
  AUDIT_APPROVE: 'audit.approve',
} as const;

export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];

5. 刷新页面后权限丢失

现象

  • 登录后菜单正常
  • 浏览器刷新后跳回 403 或空菜单

原因

  • 权限数据只存在内存里
  • 页面刷新时用户信息还没恢复完成

建议

  • 使用 token + 用户信息恢复流程
  • 路由渲染前增加初始化态,例如 loading skeleton
  • 在权限数据未就绪前不要提前裁剪路由

安全最佳实践

这一段很重要,因为很多人会误以为“前端做了权限控制就安全了”。实际上并不是。

1. 前端权限控制本质上是体验层,不是最终安全边界

前端能做的是:

  • 隐藏无权限菜单
  • 阻止普通用户误操作
  • 降低页面暴露度

但真正的安全边界必须在后端:

  • 接口鉴权
  • 数据范围校验
  • 操作审计

结论很明确:页面看不见,不代表接口安全。


2. 页面权限和接口权限编码尽量统一

如果前端用的是:

  • user.read
  • user.edit

后端最好也按同样的权限编码返回和校验。这样有几个好处:

  • 联调时认知一致
  • 文档一致
  • 排查权限问题更快

最怕的是前端写 user.read,后端是 GET_USER_LIST,中间再加一个映射层,维护成本会逐渐上升。


3. 对敏感页面使用懒加载 + 守卫组合

对一些管理页面、审核页面,可以使用懒加载减少首屏负担,同时在加载前就做权限判断。

import React, { lazy, Suspense } from 'react';

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

function AuditRoute() {
  return (
    <Suspense fallback={<div>加载中...</div>}>
      <AuditPage />
    </Suspense>
  );
}

这样做的好处:

  • 减少首次加载体积
  • 非授权用户不会频繁加载无关页面资源
  • 大型后台体验更稳

4. 不要把超复杂权限表达式硬塞进路由层

比如:

  • A 角色在工作日可访问
  • B 角色只能看自己部门数据
  • C 角色审批额度不能超过某阈值

这些规则已经不是简单的页面路由权限,而是业务授权逻辑
路由层只适合放“能否进入页面”的粗粒度判断,复杂规则应放到:

  • 页面内部
  • 接口返回
  • 后端授权系统

性能最佳实践

权限系统看起来只是几次数组判断,但项目一大,性能问题就会慢慢冒出来。

1. 路由过滤结果做缓存或记忆化

如果用户权限不变,就没必要每次渲染都重新过滤整棵路由树。

const accessibleRoutes = React.useMemo(() => {
  return filterRoutes(appRoutes, auth);
}, [auth.roles, auth.permissions]);

2. 权限集合优先用 Set

当权限点很多时,用 includes 在数组里查找会有额外开销。可以在运行时转成 Set

export function hasAnyPermissionWithSet(
  userPermissionSet: Set<string>,
  routePermissions?: string[]
) {
  if (!routePermissions || routePermissions.length === 0) return false;
  return routePermissions.some((p) => userPermissionSet.has(p));
}

对中等规模项目,这不是必须;但如果按钮权限很多、页面里频繁判断,会更稳一些。


3. 按钮级权限判断别重复创建函数

例如在一个大表格里每行都渲染“编辑/删除/审核”按钮,如果每次 render 都临时创建判断逻辑,可能会引起额外渲染。

建议:

  • 统一封装 PermissionButton
  • 配合 memo
  • 权限数据稳定化

例如:

// src/components/permission-button.tsx
import React from 'react';
import { useAuth } from '../auth/auth-context';
import type { Permission } from '../routes/route-config';

interface Props {
  need: Permission[];
  children: React.ReactNode;
}

export function PermissionButton({ need, children }: Props) {
  const auth = useAuth();
  const allowed = need.some((p) => auth.permissions.includes(p));

  if (!allowed) return null;
  return <>{children}</>;
}

边界条件与可执行建议

如果你准备在现有项目里重构权限路由,我建议按下面顺序做,不要一步到位全改:

第一阶段:先统一路由元数据

目标:

  • 菜单和路由同源
  • 页面访问守卫补齐

第二阶段:抽离权限判断中心

目标:

  • 所有 role/permission 判断都走统一函数
  • 减少散落在组件内的硬编码

第三阶段:接入按钮级权限

目标:

  • 封装 PermissionButtonusePermission
  • 页面内细粒度控制统一化

第四阶段:与后端权限编码对齐

目标:

  • 权限点命名统一
  • 联调与审计更容易

如果你的系统还很小,只有 5~10 个页面,不必把架构搞得过重;
但只要你已经出现“多个角色、多个菜单分组、详情页隐藏、按钮权限”这些特征,就值得尽早做这层抽象。


总结

可维护的权限路由与菜单系统,关键不在于“写几个判断”,而在于建立一套稳定的工程约束:

  • 全量路由统一配置
  • 菜单从路由元数据派生
  • 权限判断集中封装
  • 页面守卫和菜单过滤同时存在
  • 前端做体验控制,后端做最终安全校验
  • TypeScript 提供权限字段的类型约束

如果只记住一句话,我建议是:

不要把权限逻辑散落在页面里,要把它收敛成“配置 + 过滤 + 守卫”的统一体系。

这样做的好处不是今天少写几行代码,而是三个月后你再回头改角色、加菜单、接按钮权限时,系统不会轻易失控。

如果你正在做的是一个 React 中后台,这套方案基本足够覆盖中级项目的大部分场景;再往上走,才是多系统共享权限中心、租户隔离和更复杂授权模型的话题。


分享到:

上一篇
《Spring Boot 中基于 Actuator + Micrometer + Prometheus 的应用监控体系实战搭建与告警优化》
下一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实践-250》