前端中级实战:基于 React 与 TypeScript 构建可维护的权限控制与动态路由方案
很多团队在项目初期做权限控制时,都会先来一版“能跑就行”的实现:
- 路由表里塞一个
role: 'admin' - 登录后把用户角色放到全局状态
- 每次渲染页面时判断一下能不能进
- 菜单和路由各维护一份,后面再同步
一开始确实很快,但项目一大,问题会立刻冒出来:
- 菜单显示和路由访问不一致
- 按钮级权限散落各处,难以追踪
- 后端返回动态菜单后,前端类型全丢了
- 新增角色或权限点时,改动面非常大
- 页面刷新后动态路由丢失,白屏或 404
我自己在中型后台项目里踩过最深的坑,就是“菜单、路由、权限三套配置各管各的”。当页面超过 30 个以后,维护成本会明显失控。
这篇文章我们不讲“最简单 demo”,而是从一个中级项目能长期维护的角度,带你做一套:
- React + TypeScript
- 可扩展的权限模型
- 支持动态路由注入
- 菜单、页面访问、按钮权限统一管理
- 可运行的示例代码
背景与问题
在中后台系统里,权限控制通常不是只有一种需求,而是几层叠加:
-
页面级权限
某个路由用户能不能访问。 -
菜单级权限
左侧菜单是否显示。 -
操作级权限
比如“新增用户”“删除订单”“导出报表”按钮是否可见或可点击。 -
动态路由
登录后根据用户身份、租户、组织、功能开通情况,动态生成路由。
如果只靠“写死在前端”的角色判断,通常会出现两个问题:
- 可维护性差:角色越来越多,判断条件越来越复杂
- 灵活性差:一旦权限策略变化,要发版才能生效
所以更稳妥的方案通常是:
- 前端维护一份标准路由注册表
- 后端返回用户权限或菜单树
- 前端根据权限结果过滤并生成最终可访问路由
- 页面内按钮通过统一 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. 权限模型:优先使用“权限码”,角色只做聚合
很多项目喜欢直接判断角色:
if (user.role === 'admin') { ... }
这样短期简单,长期会很痛。更好的方式是:
- 角色是一个“权限集合”
- 前端真正判断的是权限码
例如:
dashboard:viewuser:listuser:createuser:deleterole:listorder:list
这样做的好处是:
- 角色变化时,前端判断逻辑不用大改
- 更适合按钮级细粒度控制
- 后端更容易统一授权模型
2. 动态路由不是“后端直接返回组件”,而是“返回路由 key”
这是一个很重要的边界。
不要让后端直接返回组件路径再让前端动态 import 任意文件。
更稳妥的方式是:
- 前端维护一份受控注册表
- 后端只返回
routeKey - 前端根据
routeKey从注册表中找到对应组件
例如后端返回:
[
{ "routeKey": "dashboard", "path": "/dashboard" },
{ "routeKey": "userList", "path": "/users" }
]
前端注册表:
{
dashboard: Dashboard,
userList: UserList
}
这样可以避免:
- 任意路径注入
- 配置失控
- 类型缺失
- 组件加载不受控
3. 一份元数据,驱动菜单、路由、权限
最容易失控的点,是:
- 路由配置一份
- 菜单配置一份
- 权限配置一份
更好的实践是:让一份路由元数据承担更多职责。
例如每条路由都带上:
titlemenupermissionchildren
最终:
- 菜单从路由树生成
- 页面守卫看
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>}
短期能跑,长期难维护。
建议统一改成:
usePermissionPermissionButtonGuard
这样后期改策略时,修改点更集中。
安全/性能最佳实践
这一节很关键。权限方案如果只顾“显示对了”,但忽略安全和性能,后期代价会很高。
安全最佳实践
1. 前端权限永远不是最终防线
必须强调一次:
- 前端控制“显示”和“导航”
- 后端控制“真实授权”
任何敏感接口都必须在服务端校验:
- 当前用户身份
- 当前租户范围
- 当前资源归属
- 当前操作权限
2. 后端只返回受控标识,不返回任意组件路径
推荐:
{ "routeKey": "userList" }
不推荐:
{ "component": "/src/pages/xxx.tsx" }
前者受控、可审计、可兜底;后者很容易造成配置和安全问题。
3. 权限码命名统一
最好采用统一格式:
资源:动作
例如:
user:listuser:createuser:updateuser: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 条:
- 判断权限尽量基于权限码,不直接绑死角色
- 后端返回 routeKey,前端做受控映射
- 菜单、路由、按钮权限尽量复用同一套元数据
- 前端负责体验,后端负责最终授权
- 把权限判断收口到 Hook、Guard、组件,别散写
这 5 条做到了,项目即使后面翻倍扩张,也还在可维护范围内。
总结
这篇文章我们从“中级实战”的角度,搭了一套可维护的权限控制与动态路由方案,核心目标不是炫技,而是解决真实项目里的三个长期问题:
- 配置不一致
- 权限散落难维护
- 动态路由刷新易失效
整套思路可以概括为:
- 用 权限码 替代硬编码角色判断
- 用 前端路由注册表 承接页面组件
- 用 后端 routeKey + 权限数据 驱动动态路由
- 用 Guard / Hook / PermissionButton 统一权限入口
如果你现在的项目还处在“菜单一套、路由一套、按钮一套”的状态,我建议先不要一次性大改。最现实的做法是:
- 先统一权限码命名
- 再做路由注册表
- 然后把按钮权限收口到组件或 Hook
- 最后补上刷新恢复和初始化加载态
这样改造风险最小,也最容易逐步上线。
如果只给一个落地建议,那就是:先把“权限判断逻辑”集中起来,再谈动态路由。
因为真正让项目变难维护的,往往不是“有没有动态路由”,而是“权限判断写得到处都是”。