
第一次真正弄懂 Umi 权限系统是因为一个后台项目被客户投诉非管理员账号居然能看到管理菜单点进去才提示 403。那时候代码里全是if (role admin)的散弹式判断加一个新页面就要改一堆地方。后来我把这套逻辑统一迁到 Umi 的 access 方案上才算是把权限这件事从“临时打补丁”变成了“配置式声明”。这篇文章就把我踩过的坑、重新梳理过的权限模型以及完整接入过程写出来给正准备做权限模块的同学一份能直接照着用的参考。全文不会绕弯子只讲实际能落地的配置和思路。1. 权限系统到底在解决什么问题1.1 从“到处 if 判断”到统一权限声明权限控制是后台系统里绕不开的话题。页面能不能访问、按钮要不要展示、接口请求允不允许本质上都是“当前用户是否有资格做某件事”。很多项目刚起步时权限逻辑都散落在组件内部。比如一个删除按钮是否显示直接在 render 方法里写判断{ currentUser.role admin Button onClick{handleDelete}删除/Button }这种写法的毛病不是“不能用”而是“难维护”。一旦角色体系变复杂比如出现超管、运营、编辑、访客多个角色你需要在每个用到权限的地方复制同一套判断。需求变一次全局搜索替换一遍漏改一处就是线上事故。我之前见过一个项目运营角色在 A 页面能删除数据在 B 页面却看不到删除按钮原因就是两个页面的判断条件写得不一样。这种问题在权限逻辑分散时几乎无法彻底避免。Umi 权限系统的核心是把“权限判断”抽象成一份可复用的声明式配置让权限规则和业务组件解耦。你只需要在统一的src/access.ts中定义“当前用户能做什么”然后在路由或组件里声明“这块需要什么权限”剩下的匹配过程交给框架。这样权限规则就变成了一个单一事实来源团队协作时也不用每个人各自维护一套判断逻辑。权限由一个人或一个小组统一维护其他人只声明“这里需要 canDelete”读代码的时候也更直观。1.2 Umi 权限方案的核心设计思路Umi 的权限方案本质上是一条链先通过getInitialState拉取用户信息把结果作为参数传给access函数access函数返回一个权限对象这个对象被消费方使用。消费方分两类一类是路由通过access字段控制页面能否访问另一类是组件通过useAccess或Access组件控制元素是否渲染。这条链路可以浓缩成一句话一个入口获取用户一份 access 文件定义规则路由和组件消费规则。这样做的好处是数据流单向且清晰用户信息只入口一次权限规则集中定义UI 层只做声明。带来的直接收益是新加一个权限点时不需要动业务代码权限规则变更时只需要改access.ts菜单、路由、按钮的权限判断口径完全一致不会出现页面能进但按钮看不到的错位。我还想补充一个容易被忽略的点这套方案把权限规则从组件里抽离出来之后规则本身变得可以单测。你可以直接输入不同的initialState断言access函数返回的权限对象是否符合预期。这在权限规则复杂的项目里非常有价值至少比在组件里找 zero 散落的if判断要靠谱得多。1.3 适用场景与边界Umi 权限方案适合大多数中后台管理项目尤其是多角色、多菜单、需要路由和按钮联动控制的场景。比如常规的电商后台、内容管理后台、运营平台都很适合。但也要清楚它的边界如果你的系统权限完全由后端动态下发每个用户看到的菜单结构都不同而且变化频率特别高那纯静态的前端权限声明就不够用你可能需要做动态路由或从服务端拿菜单树。另外公开访问的营销页面、面向 C 端的简单页面没必要引入完整的权限体系过度设计反而拖慢开发效率。我个人的判断标准是只要项目里出现两个以上角色并且不同的角色访问的页面有明显差异就值得上这套方案。如果全站只有一个 admin 角色那直接用一个全局路由拦截就行不需要引入 access。2. 权限接入前的准备初始化状态与角色模型2.1 getInitialState 与全局数据注入在 Umi 里权限的源头是当前用户信息。框架约定在src/app.ts中导出getInitialState它会在应用启动前执行返回值会被保存为全局状态这个状态就是后面所有权限判断的输入。我的做法是登录成功后把 token 存到本地getInitialState里带上 token 请求一次“获取当前用户信息”的接口拿到角色、权限点列表等数据。示例代码// src/app.ts export async function getInitialState() { const token localStorage.getItem(token); if (!token) { return { currentUser: null }; } try { const currentUser await fetch(/api/currentUser).then(res res.json()); return { currentUser }; } catch (err) { return { currentUser: null }; } }注意getInitialState不是只运行一次。刷新页面、重新打开应用都会执行所以你不用把用户信息存到本地再取直接以接口为准。这个函数如果抛错会阻塞项目启动所以一定要 catch并且对“未登录”的情况返回一个默认结构。这里有一个很常见的误区直接在localStorage里存一份用户角色然后getInitialState直接读本地。这样确实能减少一次接口请求但角色变更、账号被禁用这些情况都无法及时反映。我的建议是缓存 token不缓存用户信息。用户信息永远从服务端拿最多在内存里存一份刷新后重新获取。2.2 access 函数的设计权限点怎么组织access.ts是权限规则的核心文件。框架约定默认导出一个函数参数就是getInitialState的返回值。你可以在里面做任何同步运算最后返回一个对象对象的每个 key 都是一个权限名值是 boolean 或 boolean 函数。// src/access.ts export default function access(initialState: { currentUser?: API.CurrentUser | null }) { const { currentUser } initialState; const role currentUser?.role; return { canAdmin: role admin, canUser: role user, canDelete: currentUser?.permissions?.includes(delete) ?? false, }; }关键点权限函数里不要写异步逻辑。因为access是同步执行的异步请求、时间戳判断都不适合放在这里。如果有比较复杂的权限计算比如“部门 A 的人只能看到部门 A 的数据”建议把部门、角色、权限点列表都放进initialState里然后在access.ts里做纯计算。这样权限判断是完全可预测的。在权限点命名上不要直接用中文也不要用太笼统的名字。推荐用“能力 对象”的方式比如canDeleteOrder、canExportReport而不是canEdit、canManage。原因很简单权限点越具体越不容易被误用。一个canManage可能会被同时用在订单、用户、物流三个模块上后续想拆开就麻烦了。2.3 角色模型与权限点模型的选择后台系统里最常见的权限模型是 RBAC也就是给角色配权限点给用户配角色。Umi 的access函数本身不限制你用哪种模型它只负责把initialState映射成一堆布尔值。所以你需要自己做决策是用“角色判断”还是“权限点判断”我建议优先用权限点而不是直接用角色。比如角色是 admin不等于所有地方都能操作更合理的做法是 admin 角色拥有全部权限点然后在前端判断permissions.includes(order:delete)。如果直接写role admin一旦后期出现一个需要“能删除但不能导出”的特殊角色你就得回去把所有判断都改成role admin || role operator非常痛苦。当然如果项目简单只有两三个角色直接用角色判断也没什么问题。但至少要把角色判断收拢到access.ts里不要让业务组件里去比较角色字符串。这样未来升级成权限点模型时只需要改一处。3. 路由级权限让未授权页面直接消失3.1 在路由表里声明 access 字段路由级权限是后台系统最常用的能力。做法是给路由配置加一个access字段值是access.ts里定义过的权限名。当用户访问这个路由时Umi 会检查对应权限是否为 true如果为 false会走到预设的 403 页面或者 404 页面而不是渲染组件。// config/routes.ts export const routes [ { path: /login, component: ./Login }, { path: /dashboard, component: ./Dashboard }, { path: /admin, component: ./Admin, access: canAdmin, }, ];这里我想强调一个经验access字段建议只对应access.ts里返回的第一层权限名不要写成表达式。虽然有些写法支持复杂逻辑但为了可读性尽量保持“一个权限点控制一个路由区块”的对应关系。如果某个页面需要同时满足两个权限就在access.ts里先合成一个组合权限再引用那个组合权限。还有一个容易踩的坑父路由没有加access但子路由加了。比如/admin路由本身没有权限限制子路由/admin/users设置了access: canAdmin。这样用户虽然进不去子页面但依然能看到父路由对应的布局和菜单。正确的做法是在父路由上也加上access把整个区块整体控制住。不过要注意如果父路由是BasicLayout这类纯布局组件加了权限后未授权用户可能连布局都看不到这通常不是你想要的效果。所以更精细的做法是布局路由保持公开真正的内容路由单独加权限同时通过菜单配置隐藏无权限菜单项。3.2 菜单与面包屑的自动过滤机制Umi 根据路由配置生成菜单时同样会读取access字段。凡是当前用户不具备权限的路由会自动从菜单中隐藏。这意味着你不需要专门写“根据 role 过滤菜单”的逻辑框架在渲染菜单时就把没有权限的路由过滤掉了。这对后台系统来说非常省事。但有一个前提你的菜单是用 Umi 默认的路由转菜单机制生成的。如果你用了自定义的menuRender那就需要自己读取权限对象手动过滤菜单项。自定义菜单时我通常会在外层用useAccess拿到权限对象然后在渲染菜单项之前做一次filterconst access useAccess(); const filteredMenu menuData.filter(item { return !item.access || access[item.access]; });这里要注意自定义菜单时如果不做过滤就会出现“页面进不去但菜单还显示”的尴尬情况。另外面包屑也是基于路由生成的Umi 一般会跟随路由权限自动处理但如果你的面包屑是自己拼的同样要走一遍过滤逻辑。3.3 动态路由与后端返回菜单的取舍有些项目要求菜单由后端配置比如不同角色看到不同菜单甚至同一个角色在不同项目里看到的菜单都不一样。这时候静态路由表就满足不了需求。我的做法是后端返回菜单树前端把它转换成 Umi 的 routes 格式然后再动态 render。这样可以做到完全由后端控制路由权限但代价是类型检查和代码分割没那么方便。如果项目阶段不需要这么激进建议先用静态路由加access字段等真正出现“同一角色在不同项目里菜单差异很大”的需求再上动态路由。动态路由的坑很多比如刷新时菜单还没从后端拉回来路由就已经匹配结束了导致用户被重定向到 404。这些情况都需要额外处理。所以我的建议是在权限方案稳定之前先用静态路由把业务跑起来比一上来就搞动态路由要稳妥得多。4. 元素级权限页面里的按钮和区块怎么控制4.1 useAccess 与 Access 组件的适配场景路由级权限管的是“页面能不能进”但页面内部的删除按钮、导出按钮、高级筛选区通常需要更细粒度的控制。Umi 提供了两种方式useAccesshook 和Access组件。useAccess适合在已有逻辑里动态判断比如import { useAccess, Access } from umi; export default function UserListPage() { const access useAccess(); return ( div {access.canDelete ? ( button onClick{handleDelete}批量删除/button ) : null} Access accessible{access.canExport} fallback{div请联系管理员开通导出权限/div} button onClick{handleExport}导出Excel/button /Access /div ); }两者的区别useAccess是纯逻辑判断适合和其他条件组合比如access.canDelete list.length 0Access组件适合控制一块局部 UI自带 fallback 能力。我的习惯是单个按钮用useAccess区块级别的权限用Access组件。因为单个按钮通常不需要 fallback直接不渲染就行而一个区块如果无权限需要给用户一个明确提示用Access组件更清晰。4.2 fallback 与占位状态的选择Access组件的 fallback 可以传入任意 ReactNode。很多人喜欢直接传 null但更好的方案是给出“无权限”的提示尤其是后台系统完全空白会让用户误以为是 bug。但也别做过头每个按钮都显示“无权限”文案界面会很吵。我的经验是主动操作类按钮无权限时用 Tooltip 提示比如鼠标悬停显示“当前角色无删除权限”平时不占空间被动展示类区块无权限时用 fallback 展示一段说明文字。这样既告知了用户又不干扰正常操作。举个例子订单详情页里有一个“成本核算”区块如果运营角色没有权限直接显示空区块很容易让人疑惑我会放一行小字“该模块已隐藏如需访问请联系管理员”。这样处理过几个系统后用户的疑惑明显少了很多。4.3 权限点颗粒度与后端联调按钮级权限最容易踩的坑是“权限点定义得太粗”。比如只定义了一个canEdit结果编辑页里包含了“修改价格”和“修改库存”两个功能权限完全不同。我在设计权限点时会遵循一个原则权限点对应“一次用户可感知的操作”而不是“一个页面区块”。可以导出、可以删除、可以审批、可以重置密码这些都应该单独成立。宁可权限点数量多一些也别为了省事把几个操作绑在一起后患无穷。当后端接口已经返回权限码时前后端经常会遇到命名不一致的问题。前端叫canDelete后端返回的权限码是order:delete。解决办法是建立一张映射表把后端权限码翻译成前端权限对象const permissionMap { order:delete: canDelete, order:export: canExport, user:reset:pwd: canResetPwd, }; export default function access(initialState) { const { currentUser } initialState; const permissions currentUser?.permissions ?? []; const result {}; for (const code of permissions) { const key permissionMap[code]; if (key) result[key] true; } return result; }这样前端页面里只认canDelete这类稳定的权限名后端接口权限码变更时只需要改映射表不需要动业务组件。这张映射表最好是前后端一起维护的权限字典放在一个独立文件里作为共识存在。5. 实操全流程一个后台管理系统的权限改造5.1 初始接入步骤清单为了让你有个整体感觉我把一次完整接入梳理成步骤清单。假设现有项目已经接入 Umi并且有登录逻辑现在要把权限体系加进来。在src/app.ts里补充getInitialState从服务端获取当前用户信息。创建src/access.ts定义权限函数把角色或权限点映射为权限对象。在路由表里给受保护页面添加access字段。在组件里改造按钮级权限用useAccess或Access组件替换原来的散装if (role xxx)。本地验证不同角色登录后的效果重点检查菜单、路由、按钮三类场景。提测前检查一遍所有权限点是否和后端权限字典一致。这套流程看起来简单但真正执行时第 4 步的改造量往往最大。如果老项目里已经写了很多散落的判断我建议先不要一次性全改按页面优先级逐个迁移至少保证新页面从第一天就走新的权限模型。5.2 关键代码与配置示例拿一个模拟的订单管理系统举例用户角色分 admin、operator、viewer。admin 能看到全部页面operator 能访问订单管理但不能删除viewer 只能看报表。先定义用户信息接口返回结构包含角色和权限码列表。然后在access.ts里做映射export default function access(initialState) { const { currentUser } initialState; if (!currentUser) { return { canDashboard: false, canOrderList: false, canOrderDelete: false, canReport: false, }; } const permissions currentUser.permissions ?? []; return { canDashboard: permissions.includes(dashboard:view), canOrderList: permissions.includes(order:list), canOrderDelete: permissions.includes(order:delete), canReport: permissions.includes(report:view), }; }对应路由export const routes [ { path: /login, component: ./Login }, { path: /, component: ./BasicLayout, routes: [ { path: /dashboard, component: ./Dashboard, access: canDashboard }, { path: /order, component: ./OrderList, access: canOrderList }, { path: /report, component: ./Report, access: canReport }, ], }, ];按钮层面function OrderListPage() { const access useAccess(); return ( div {access.canOrderDelete Button typedanger删除订单/Button} /div ); }这里的权限判断全部来自同一个access函数不会出现两边判断不一致的问题。需要注意的是permissions列表最好在后端登录接口里就返回这样access函数是纯同步计算不会有额外请求。5.3 本地验证与联调技巧本地开发时我习惯在getInitialState里做一次临时的角色切换用 URL 参数或者本地配置模拟不同登录态。比如在src/app.ts中加一段开发环境才执行的逻辑if (process.env.NODE_ENV development localStorage.getItem(mockRole)) { return { currentUser: { roles: [localStorage.getItem(mockRole)], permissions: getMockPermissions(localStorage.getItem(mockRole)), }, }; }这样不用每次都登出登录来切换角色开发效率高很多。等接口联调时再把这个开关去掉避免把临时逻辑带到生产环境。另外权限改动后记得强刷一次因为access.ts被 Umi 监听改动会热更新但有时候浏览器缓存还会保留旧的initialState。我遇到过不止一次“改完权限不生效”最后发现是浏览器缓存的问题清了缓存就正常了。联调时还有一个建议先让后端把所有权限码数据结构固定下来前端侧用一个常量文件维护权限码字典。这样联调时如果发现权限对不上先看前端字典再看后端返回值能快速定位是谁的问题。6. 常见问题与排查技巧实录6.1 权限函数不生效路由白屏最常见的问题是access函数里没有处理currentUser为空的情况。用户未登录或 token 过期时initialState返回的是undefinedaccess里直接读取currentUser.role会抛错导致路由渲染不出来。解决方法是像上面代码那样先判断currentUser是否存在给所有权限点一个默认 false。这个处理不是可选项而是必选项。我见过很多团队第一次接权限系统时都栽在这。一旦报错整个应用都会白屏而且控制台报错信息往往指向access.ts排查起来很快但一开始预防更省事。6.2 刷新页面后 initial state 丢失如果你的项目用了动态路由或者getInitialState依赖异步接口刷新后可能遇到权限还没返回路由已经被判定为无权限的情况。结果就是明明登录了刷新一下反而被踢到登录页或者 404 页。解决办法是保证应用在拿到用户信息之后再渲染路由。Umi 的getInitialState本身是在渲染前执行的但如果你的权限判断依赖了动态菜单那就要把菜单请求也放到getInitialState里或者在页面里加全局 loading 状态。不要依赖本地存储的用户信息来兜底它可能落后于服务端的角色变更刷新一次就把问题暴露出来了。6.3 按钮权限与后端权限点对不齐前端权限点命名和后端权限点编码不一致是联调时最头疼的问题。我遇到过的例子是前端叫canDelete后端接口返回的权限码是order:delete。如果两边各管各的前端很难知道后端到底下发了什么权限。解决办法是在前端维护一个权限码映射表把后端的权限码统一翻译成前端权限对象。如果后端数据里有一些前端不认识的权限码不要直接忽略最好记录日志或打点方便后续同步。反过来如果前端有一个权限点后端永远不下发说明要么是前端定义多余要么是后端漏配置需要及时找后端对齐。6.4 权限中心更新后页面没有实时响应权限基本是在登录时获取因此用户权限变更后通常要重新登录才生效。如果产品要求实时生效单纯靠前端权限判断还不够后端接口也要做二次校验。我自己的原则是前端权限管界面展示后端权限管数据安全。前端即使漏了某个按钮的权限判断后端依然要拦截没有权限的请求。不要让前端权限系统承担接口越权的责任接口层必须有自己独立的鉴权逻辑。如果你确实需要让权限变更立刻反映到前端可以考虑在getInitialState里轮询用户信息或者用 WebSocket 推送一个“用户状态变更”事件触发重新拉取用户信息和权限。但这种方式会增加复杂度一般只在权限强依赖的场景下用。6.5 调试权限问题的小技巧调试权限问题时先在浏览器控制台打印initialState和access函数返回的对象确认数据源头是否正确。接着看路由配置里access字段是否和权限对象 key 一一对应。最容易出错的往往是大小写比如权限对象里写了canAdmin路由里写了canadminUmi 匹配不到就直接当无权限处理。这种错误特别好排查但隐蔽性很强我吃过亏。还有一个技巧在access.ts的 return 之前临时加一行console.log打印initialState和将要返回的对象。开发环境里每次路由切换都能看到非常直观。调完记得删掉避免污染生产日志。这套方案我用了挺长一段时间最大的体会是权限系统的核心不是某个框架的 API而是你愿不愿意把规则从业务代码里抽出来。刚开始改写的时候确实要动不少老页面但改完之后新页面只需要在路由表里加一个字段在组件里加一个判断整体维护成本反而低了很多。如果你也在做类似的后台系统我建议先别急着上动态路由、按钮权限全面铺开这些高级功能先把基础链路跑通一个入口获取用户一份 access 文件定义规则路由和组件消费规则。这条链路稳定了后面再加权限点就是水到渠成的事。