尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

RBAC前端架构:localStorage持久化与Vuex状态同步方案

RBAC前端架构:localStorage持久化与Vuex状态同步方案 1. 用户状态管理的初始问题为什么不能只用一个存储方案RBAC基于角色的访问控制做前端架构时绕不开一个灵魂拷问用户信息到底该放在哪里我接手过的项目里见过把用户信息塞满localStorage的也见过全程只用Vuex一份内存变量扛着页面刷新丢失风险的还有在两者之间复制来复制去最后数据不一致的。踩过这些坑之后我形成了一套相对清晰的存储逻辑今天把完整设计思路拆开讲。先说结论localStorage负责持久化Vuex负责运行时状态同步两者各管一段通过显式的读写动作衔接不搞隐式双向绑定。这个结论看起来简单实际操作中的细节远比一句话复杂。首先要搞清楚两个存储介质的本质差异。localStorage是浏览器提供的同步存储接口容量一般在5MB左右数据以字符串形式保存刷新页面、关闭浏览器都不会丢失除非用户手动清理或代码主动删除。它的特点是跨会话持久但不具备响应式能力——你把数据写进去页面上的其他组件不会感知到变化需要重新读取才能拿到最新值。Vuex则是应用内部的内存状态管理容器存储的是JavaScript对象本身天然具备响应式能力。组件里通过mapState或useStore读取状态后只要dispatch一个mutation去修改所有依赖该状态的视图会立刻更新。但它的致命弱点是页面一刷新整个Vuex实例被销毁内存数据全部清空。所以RBAC场景下的用户信息存储必须两者配合。用户登录成功后后端返回用户基本信息、角色列表、权限标识数组这些数据先写入Vuex再同步持久化到localStorage。刷新页面时应用初始化阶段从localStorage读取数据恢复Vuex状态。这个流程是RBAC前端架构的基石如果这一层没设计好后面路由守卫、权限指令、菜单动态生成全都跟着乱。我见过一种错误的做法业务组件直接读写localStorage来获取用户角色然后在模板里写一堆if/else判断。这种做法的直接后果是——用户修改角色后已经挂载的组件不会响应更新必须强制刷新页面才能看到变化。而正确的做法是组件只从Vuex取数据Vuex的数据一旦变化界面自动同步。下面我把整体存储链路展开从登录写入到路由守卫恢复再到退出清理按真实代码执行顺序逐步讲解。2. 登录认证后的写入链路Token与用户信息的分别处理2.1 登录接口返回的数据结构设计第一步先约定后端返回的数据结构。以我自己常用的接口约定为例{ code: 0, message: success, data: { token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..., tokenExpiresIn: 7200, userInfo: { userId: 10001, username: zhangsan, nickname: 张三, avatar: https://example.com/avatar/10001.png, email: zhangsanexample.com, roles: [admin], permissions: [system:user:add, system:user:edit, system:user:delete] } } }这里有个关键设计决策roles和permissions到底要不要随登录接口一起返回我的看法是如果项目规模不大、权限点就几十个登录时一起返回可以减少一次请求用户体验好。但如果是中大型系统权限数据动辄几百上千条建议登录接口只返回用户基本信息和角色标识前端拿到角色后再调用独立的/auth/permissions接口获取菜单权限和按钮权限。这样做的好处是权限变更时可以单独刷新不需要引导用户重新登录。但无论采用哪种方案用户信息在存储层的处理逻辑是一致的。核心原则就一条——Token走独自的存储键用户信息与权限数据合并在同一把键下。为什么因为Token的读取频率极高每次axios请求都要取而用户信息和权限虽然读取频率也不低但在语义上是“业务数据”和Token的安全性要求不同。分开存储便于各自设置生命周期和清理策略。2.2 封装storage模块避免散落裸调localStorage很多初学者喜欢直接在代码里写localStorage.setItem(token, res.data.token)一两处还好项目一大就失控了。键名拼写错误、忘记JSON.parse、存储前缀不一致、需要清理时漏掉某个键——全是这样散落的调用埋下的雷。我习惯了封装一个auth-storage模块统一管理键名和读写逻辑// src/utils/auth-storage.js const TOKEN_KEY myapp_token const USER_INFO_KEY myapp_user_info export function getToken() { return localStorage.getItem(TOKEN_KEY) } export function setToken(token) { localStorage.setItem(TOKEN_KEY, token) } export function removeToken() { localStorage.removeItem(TOKEN_KEY) } export function getUserInfo() { const raw localStorage.getItem(USER_INFO_KEY) if (!raw) return null try { return JSON.parse(raw) } catch (e) { removeUserInfo() return null } } export function setUserInfo(userInfo) { localStorage.setItem(USER_INFO_KEY, JSON.stringify(userInfo)) } export function removeUserInfo() { localStorage.removeItem(USER_INFO_KEY) } export function clearAuthStorage() { removeToken() removeUserInfo() }注意几个细节。第一键名加了项目前缀myapp_避免同一域名下部署多个应用时互相覆盖。一个域名下不是只能跑一个前端应用——老系统重构、多业务共存的情况很常见无前缀的键名分分钟互相惨案。第二getUserInfo()里做了JSON.parse的异常处理。localStorage里存的是字符串被用户或脚本手工改写成非法JSON的情况完全可能发生一个try/catch兜底并自动清理脏数据避免整个应用白屏。第三封装后所有业务的读写都不再感知键名未来想换存储方案从localStorage换到IndexedDB只需要改这一个文件。这就是分层带来的维护红利。2.3 Vuex模块划分用户模块的state设计Vuex这边我按业务域拆分模块用户的归属模块就叫user。它的state设计很直接// src/store/modules/user.js import { getUserInfo, setUserInfo, removeUserInfo, getToken, setToken, removeToken } from /utils/auth-storage const state { token: getToken() || , userInfo: getUserInfo() || {}, roles: [], permissions: [] } const mutations { SET_TOKEN(state, token) { state.token token }, SET_USER_INFO(state, userInfo) { state.userInfo userInfo state.roles userInfo.roles || [] state.permissions userInfo.permissions || [] }, CLEAR_USER(state) { state.token state.userInfo {} state.roles [] state.permissions [] } } const actions { login({ commit }, loginData) { return new Promise((resolve, reject) { loginApi(loginData).then(res { const { token, userInfo } res.data // 持久化先写localStorage setToken(token) setUserInfo(userInfo) // 内存态同步Vuex commit(SET_TOKEN, token) commit(SET_USER_INFO, userInfo) resolve() }).catch(error { reject(error) }) }) }, logout({ commit }) { // 业务代码调用后端注销接口无论成功与否都清理本地 clearAuthStorage() commit(CLEAR_USER) } } export default { namespaced: true, state, mutations, actions }state的初始化直接调用getToken()和getUserInfo()读取localStorage这一步非常关键。它保证了页面刷新后、任何异步逻辑执行之前用户状态已经被恢复路由守卫在做跳转判断时不会因为状态为空而误判为未登录。这里要专门说下为什么在SET_USER_INFO里同时拆分roles和permissions两个独立字段。表面上看冗余——userInfo里已经有这两个数组了。但实际开发里权限判断业务如指令权限v-permission、按钮显隐组件需要频繁读取permissions如果每次都要state.userInfo.permissions这种长链式访问代码可读性和排查难度都会上升。拆出来作为一个独立状态配合getters供全局使用后续某个业务要基于角色做判断时写起来非常清爽。2.4 dispatch还是commit登录动作不要直接commit注意上面的代码里登录走的是dispatch调actionaction内部再commit触发mutation。这个设计不是形式主义。登录不是一个单纯的同步赋值动作它的完整链路是调用接口→网络异常处理→数据校验→持久化→更新状态。这些步骤放action里可以让组件侧只关心“登录成功”或“登录失败”这两个结果业务细节都被吞进action内部。同时action天然支持异步可以返回Promise让调用方做后续跳转。也有项目一上来就所有地方直接commit mutation组件里写一堆this.$store.commit(SET_TOKEN, ...)之类的代码。小项目看着没什么一旦逻辑变复杂——比如登录里还要带上用户偏好设置、多语言切换标志——组件里的代码会不受控制地膨胀。我推荐不管项目大小至少对“登录”“退出”“获取用户信息”这3个动作统一走action。理由很简单审计跟踪和统一错误处理有明确落脚点未来在action里加埋点、加日志都很方便。2.5 登录成功后的落地动作组件层调用方式如下// src/views/login/index.vue片段 async function handleLogin() { loginFormRef.value.validate(async valid { if (!valid) return loading.value true try { await userStore.dispatch(user/login, { username: loginForm.username, password: loginForm.password }) // 跳转携带redirect参数登录前访问受限页面时可回跳 const redirect route.query.redirect || / router.push(redirect) } catch (e) { ElMessage.error(登录失败请检查用户名和密码) } finally { loading.value false } }) }用户走到这一步前端本地存储里应该有myapp_token和myapp_user_info两个键Vuex内存里也有对应的响应式状态。接下来要扛住的是最常见的一击——用户按F5刷新页面。3. 页面刷新后的状态恢复初始化流程里的localStorage读盘3.1 为什么刷新后Vuex数据一定会丢失要理解状态恢复的必要性先要看清楚Vuex的生命周期。Vuex实例挂在Vue应用实例上它的state本质上就是普通JavaScript对象存放于内存中。浏览器标签页一刷新JavaScript执行上下文被销毁内存中的一切数据被回收包括Vuex里的token、userInfo、roles、permissions。这就像你在一张便签纸上写下购物清单然后关灯离开房间。纸还在——前提是你把它夹进了笔记本localStorage如果只是口头记住了灯一关全忘光。所以刷新后恢复的唯一信息源就是localStorage。前面我们在state初始化时已经通过getToken()、getUserInfo()读了一次这里的执行时机是模块加载时早于路由守卫执行这是一个稳妥的顺序。但也有项目用了另一种方案——state里不预读而是启动时统一调用一次dispatch(user/getUserInfo)来做恢复。两种方案各有适用场景我分别说下。方案做法优点缺点预读式state初始化时同步从localStorage读取代码执行时序最靠前路由守卫做判断时状态已就绪可减少一次mutation触发刷新前的Vuex里可能有些动态计算的派生数据如基于权限过滤后的菜单无法直接从localStorage恢复派发式state初始为空应用启动后dispatch一个action去恢复恢复逻辑统一可以在action里做数据校验和二次拉取权限若路由守卫先于action完成前执行可能误判未登录导致跳转登录页我强烈倾向“预读式”为基底因为RBAC场景中路由守卫的核心职责是判断当前用户有没有权限进入目标页面它必须在最早的时间点拿到准确的登录态。若因此引入异步路由守卫要考虑未初始化完成的中间态复杂度上升不止一个量级。3.2 路由守卫中如何利用已恢复的状态路由守卫的典型逻辑// src/router/index.js router.beforeEach(async (to, from, next) { const token getToken() // 白名单路径直接放行不校验token if (whiteList.includes(to.path)) { next() return } if (!token) { // 未登录跳转登录页并带上redirect next(/login?redirect${encodeURIComponent(to.fullPath)}) return } // 已登录但用户信息缺失例如刷新后首次进入某个业务页 if (!store.getters[user/roles].length) { try { // 尝试从localStorage恢复若localStorage里也没有则重新拉取用户信息接口 await store.dispatch(user/getUserProfile) // 动态追加权限路由 const accessRoutes await store.dispatch(permission/generateRoutes, store.getters[user/roles]) accessRoutes.forEach(route router.addRoute(route)) next({ ...to, replace: true }) } catch (e) { // 用户信息拉取失败清理本地痕迹并踢回登录页 await store.dispatch(user/logout) next(/login?redirect${encodeURIComponent(to.fullPath)}) } } else { next() } })这里的状态恢复判断与前面模块初始化时的预读形成双层保险第一层在Vuex模块装载时能从localStorage读多少读多少第二层在路由守卫里确认roles是否为空如果为空说明localStorage里也没有——用户大概率是清理过浏览器存储或首次走深链进入——这时才调用远程接口拉取用户资料。这样设计绕开了一个性能陷阱。如果每次刷新都在守卫里无脑请求/auth/permissions接口哪怕localStorage里已经有完整数据也会产生大量无效请求。反过来如果只依赖localStorage而不做接口兜底那么用户Token依然有效、但localStorage数据被人为清除时整个前端会陷入“有Token却不知用户是谁”的尴尬僵局——菜单渲染不出来、按钮权限全失效。3.3 getUserProfile这个action是怎么把存储填回去的在路由守卫里出现的user/getUserProfile职责是完整重建用户状态。它的实现与登录action有个重要区别它不知道token也不需要拿token去换token它只拉用户信息和权限数据然后写入Vuex和localStorage。const actions { async getUserProfile({ commit }) { const { data } await getUserProfileApi() const userInfo { ...data.userInfo, roles: data.roles || [], permissions: data.permissions || [] } // 同步持久化确保下次刷新时localStorage里有完整数据 setUserInfo(userInfo) commit(SET_USER_INFO, userInfo) return data } }这套逻辑的精妙之处在于Token的持久化完全由登录动作负责它的生命周期与用户信息可能不一致。比如后端设置了Token有效期2小时用户信息接口返回403时路由守卫的catch块会调用logout清理一切。而如果Token没过期、localStorage里没有用户信息getUserProfile会补齐。关于“根据id删除localStorage数据”的搜索热词实际操作中尤其要注意键与用户ID的对应关系。有一种场景系统支持多账户切换你在同一浏览器标签页里先后登录过A、B两个账号localStorage里如果之前存过user_info_${userId}之类的按用户拆分的键退出A账号时一定要把A的键全部删干净否则下一个登录B的用户可能读到一个残留的A的存储片段。我见过的解决方案有两种一是统一用固定键覆盖写退出时走clearAuthStorage()直接清空二是按用户维度拆分键但清理时遍历删除所有已登录过的用户键。复杂度从低到高看你产品对多账号并存的要求有多高。常规后台管理系统按方案一就够切换账号走一次完整退出再登录不仅逻辑简单权限数据也不容易串。3.4 一个隐蔽的边界用户主动关闭标签页再恢复会话localStorage的持久性覆盖“同源”下的所有标签页。用户关闭了整个浏览器再重新打开输入网址localStorage数据仍然存在。这时只要Token没过期用户甚至不需要重新登录直接进入系统。这个特性对用户体验是加分项但也带来安全上的考量。后台管理系统如果涉及比较敏感的数据操作建议综合使用“记住我”选项或较短的Token有效期来控制风险。不要把Token过期时间设成7天甚至永久否则localStorage一旦被XSS脚本拿到攻击者拥有的是7天的完整访问权。RBAC前端架构不只是做权限渲染安全边界必须在存储这一层就考虑进去。4. 读取与使用的分发策略哪些数据走Vuex哪些可以直读localStorage4.1 高频响应式数据必须走Vuex在界面上需要“根据用户角色和权限动态变化”的数据必须走Vuex。典型有这几种当前用户角色标识比如导航菜单依据角色渲染不同菜单项管理员看到“系统管理”普通运营人员不显示按钮级权限控制比如列表页的“新增”“删除”按钮按权限数组判断是否渲染用户个人信息展示比如右上角的头像和昵称用户资料编辑后需要立刻刷新界面显示新值。这些数据有一种共性——它们的消费方是“视图”而视图需要响应式。localStorage改写了不会通知视图去更新但Vuex的state发生变更后依赖它的组件会重渲染。比如用户更新了自己的昵称后端的更新接口返回成功前端的computed如果直接读localStorage里的昵称界面纹丝不动但读Vuex里的userInfo.nickname界面马上同步。在Vue 3的组合式API里推荐的读取方式是这样import { useStore } from vuex import { computed } from vue const store useStore() const nickname computed(() store.state.user.userInfo.nickname || 未登录) const roles computed(() store.state.user.roles) const canEdit computed(() store.getters[user/hasPermission](system:user:edit))组件里甚至不该感知localStorage的存在。localStorage在“初始化恢复”和“写入持久化”两个环节露脸之外不应该被打扰。存储层的细节被Vuex模块吸收组件只面向Vuex编程这个边界清晰以后团队成员协作时很少发生“我在这个文件里该拿谁的数据”的困惑。4.2 非响应式的低频静态数据可以直接读写localStorage但不是所有用户相关数据都要绕一圈Vuex。有些数据属于“低频率、非响应式、单次读取后就够了”的类型比如用户的主题偏好暗黑模式还是亮色模式、语言设置、列表页每页条数、侧边栏折叠状态。这些数据不存在其他组件依赖它做联动更新的场景放开手用localStorage存就完了完全没必要塞进Vuex。存这些数据时依然建议走自己的模块比如一个叫settings-storage的工具模块键名设计为myapp_theme和myapp_lang等。Vuex里有个app模块state虽然也叫theme但在持久化时不过多纠缠应用加载时从localStorage一次性读入state修改时写localStorage并同步更新state。这里有个容易搞混的点要特别说明主题这类数据常常被做成Vuex状态因为它可能影响多个组件的呈现需要响应式联动但它的“初值”来自localStorage变化的终点也是localStorage。所以不是“所有localStorage数据都不进Vuex”而是“低频数据可以选择一次性同步而不必像用户信息那样作为核心auth领域每天被频繁读写”。4.3 getters的作用不要到处写判断逻辑RBAC的权限校验逻辑如果散落在每个组件里后续权限点更名或改变判断规则时全局搜索修改能改到怀疑人生。把所有校验收敛成getter是一个基本功。// src/store/modules/user.js const getters { isLoggedIn: state !!state.token, hasRole: (state) (role) state.roles.includes(role), hasPermission: (state) (permission) state.permissions.includes(permission) }组件里直接这样用el-button v-ifstore.getters[user/hasPermission](system:user:delete) typedanger clickhandleDelete(row) 删除/el-button这种方案比纯手工在组件里比对数组要清晰得多。权限点改名只在getter内部改一处或者从后端映射调整前端业务代码纹丝不动。顺带提一个问题有同事喜欢用一个自定义指令v-permission来做按钮级控制初衷是模板上少写v-if但缺陷也很明显——指令只能控制元素渲染与否不好做“禁用但可见”的状态。若是要表现“能看到按钮但置灰带提示”指令就不够用了还是得回到计算属性配合v-if控制两套DOM的方案。这个属于使用层面的决策看你产品对按钮交互的要求来定架构上两种方式都不冲突。4.4 权限数据的两种更新时机对应的存储刷新策略权限数据不可能永远不变。实际项目中权限更新场景主要有两种存储层的处理也略有不同。场景一用户自己的角色或权限被管理员调整了。用户的client端不能感知这一变化除非后端做了WebSocket推送常规方案是引导用户重新登录或者提供一个“刷新权限”按钮。重新登录会走登录write链路localStorage和Vuex全部覆盖刷新最干净。刷新权限按钮则是一次性调用getUserProfile重走一次拉取和写入。场景二同一账号在不同设备登录其中一台设备改了密码或封禁账号。这时另一台设备的axios请求会因为Token失效返回401。在axios拦截器里捕获401后自动清理localStorage并强制跳转登录页就是必须动作。具体实现// src/utils/request.js片段 http.interceptors.response.use( response response, error { if (error.response error.response.status 401) { // 清除本地认证数据 clearAuthStorage() // 跳转登录页且保留当前路径便于登录后回跳 window.location.href /login?redirect encodeURIComponent(window.location.pathname) } return Promise.reject(error) } )这里直接用了window.location.href而不是Vue Router的push原因在于401场景下应用状态可能已经处于不稳定状态Vuex数据与后端不一致整页刷新、应用重新初始化反而是最彻底的恢复手段。5. 退出登录与多账户切换时如何彻底清理localStorage5.1 退出动作的标准清理流程退出登录的完整处理不只是调后端注销接口更关键的是把持久层和内存层的用户痕迹全部抹掉。我的标准清理动作分三层第一层调后端logout接口让服务端把Token加入黑名单或失效处理这一步保障的是Token在服务端的不可用性。即使是纯前端的Token失效逻辑也建议做这一步——后端无状态的JWT方案里Token在黑名单机制启用前理论上是“永活”的不清服务端等于留了一个持续可用的钥匙。第二层清除localStorage里所有认证相关键。如果统一走了封装模块一个clearAuthStorage()就搞定不需要关心具体键名。第三层重置Vuex用户模块的状态恢复初始值避免内存里残留上一个用户的角色信息和权限数组那会造成权限串号的诡异问题。退出action实现const actions { async logout({ commit }) { try { await logoutApi() } catch (e) { // 接口异常不阻断本地清理兜底继续 console.warn(logout api error:, e) } finally { clearAuthStorage() commit(CLEAR_USER) resetRouter() } } }注意finally的用法——即使后端注销接口超时或报错本地清理也必须执行。不然用户点了退出接口异常结果前端还停留在登录后的页面里Token也还在用户会陷入“到底退没退出去”的困惑。resetRouter()是动态路由管理中经常被遗漏的一个点。登录时根据角色动态注册的权限路由在退出后如果不移除下一个用户登录时再注册一遍菜单里会出现重复路由甚至上一个用户有权限的页面在下一个用户退出后依然能通过URL直接访问。重置路由的方式跟路由实例的创建方式有关核心逻辑是移除动态添加的route记录把路由表还原到仅剩公共路由的状态。5.2 根据用户ID做精准清理的思考搜索热词里有“根据id删除localStorage数据”这在多账号体系里是一个真实诉求。假设产品允许“记住多个账号”并在登录页做账号快速切换那么localStorage的数据结构大概会长这样myapp_user_10001 - { nickname: 张三, roles: [admin], ... } myapp_user_10002 - { nickname: 李四, roles: [editor], ... }切换账号时或者退出特定账号时要从localStorage里精准移除某个ID对应的数据。实现思路是通过遍历storage拿到所有键再按命名规则匹配// src/utils/auth-storage.js export function removeUserInfoById(userId) { // 场景1用户信息按ID隔离存储 const key myapp_user_${userId} localStorage.removeItem(key) // 场景2补充如果某些业务数据键中也嵌入了用户ID循环匹配删除 const prefix myapp_${userId}_ const keysToRemove [] for (let i 0; i localStorage.length; i) { const key localStorage.key(i) if (key key.indexOf(prefix) 0) { keysToRemove.push(key) } } keysToRemove.forEach(k localStorage.removeItem(k)) }这个函数在“用户中心-退出当前账号”的交互里很有用。但也有个边界要注意用户信息只要按固定键覆盖存储而不按ID拆键就不存在“根据ID精准删除”的必要性——退出时一个清空函数就够了。架构设计上要克制不要为了灵活而把存储结构设计得过于复杂。我在中小型后台项目里基本都采用固定键覆盖写多账户切换走完整退出再登录。只有像C端电商、多角色工作台需要“一键切换身份”的产品才值得引入按ID管理存储键的方案。5.3 私密浏览模式与存储不可用的兜底localStorage有几个非常规场景容易让程序白屏值得提前做好防御一是用户开启了Safari的私密浏览旧版本Safari对localStorage.setItem的调用会直接抛异常二是浏览器安全策略禁用了站点存储三是用户手动清理了站点数据。前两者会导致“写入即报错”如果写入发生在登录页的handleLogin里而且没有try/catch用户会卡在登录成功但界面无法跳转的尴尬环节。防御手段是为storage模块加一层可用性探测和降级策略// src/utils/storage-guard.js export function isStorageAvailable(type localStorage) { const testKey __storage_test__ try { const storage window[type] storage.setItem(testKey, 1) storage.removeItem(testKey) return true } catch (e) { return false } }如探测到不可用可以降级为内存存储Map对象并明确提示用户“当前浏览器环境下登录状态无法持久化刷新可能丢失”。这套兜底在常规后台管理开发中属于低频需求但碰上就是事故级别的体验问题值得提前写入架构。6. 针对RBAC场景的进阶建议与一套可直接借鉴的目录结构6.1 什么时候你可以放心简化流程如果项目属于内部工具、原型验证、Demo演示用户信息就固定一份我觉得上面那套完整流程确实有点重可以大刀阔斧地简化——localStorage直接存一份完整用户信息对象需要的时候读出来用不需要Vuex不做响应式同步退出直接removeItem。这类场景的核心诉求是“能跑就行”给团队降低学习成本比架构洁癖更有价值。但如果项目处于以下任一情况完整方案就值得认真落地系统包含多个角色且角色对菜单和按钮的影响呈结构性差异比如运营、审计、超管完全是三套界面用户有自助修改个人资料或切换账号的需求团队有多名前端协作需要约定统一的状态管理边界业务在未来明确要横向加模块加一个“订单管理”模块就要对应一批权限点。6.2 从安全维度审视存储逻辑用户信息里如果包含角色和权限这些数据放在localStorage意味着用户能直接在控制台里修改。有人会担心把roles: [admin]改成roles: [super_admin]是不是就提权了答案是不会——前提是你的权限校验逻辑没有把客户端数据当唯一依据。后端所有接口都必须做权限校验前端存储的角色和权限只服务于界面表达渲染哪些菜单、显示哪些按钮。用户手工改了localStorage里的角色最多只能欺骗自己的浏览器让界面上多渲染几个他没权限按钮一点后端接口返回的仍然是403。这是RBAC架构里最底层的原则前端权限是体验优化后端权限才是安全边界。所以我在前面所有设计里都在做“界面如何正确表达权限”而不是“如何用前端代码保护权限”。6.3 一套直接可用的目录结构参考聊了这么多设计上的考量最后给一套我实际项目里沉淀下来的目录与核心文件清单你照着迁移就能少走弯路src/ ├── api/ │ ├── auth.js # 登录、注销、获取用户资料等接口 ├── store/ │ ├── index.js # Vuex根实例启用modules │ ├── modules/ │ │ ├── user.js # 用户状态模块本章核心 │ │ └── permission.js # 动态路由权限模块 ├── utils/ │ ├── auth-storage.js # localStorage封装getToken/setToken/getUserInfo/setUserInfo/clearAuthStorage │ ├── storage-guard.js # 存储可用性探测与降级 │ ├── request.js # axios实例含401统一处理、token请求头注入 ├── router/ │ ├── index.js # 路由实例与静态路由 │ ├── dynamic-routes.js # 动态注册的权限路由映射表 │ └── guards.js # 全局守卫登录态校验、权限恢复 └── directives/ └── permission.js # v-permission指令可选关键依赖关系如下api/auth.js只负责网络请求不碰任何存储纯函数化设计便于替换。utils/auth-storage.js只负责localStorage读写不被任何组件直接引用只被store里的user模块和request.js引用。store/modules/user.js是业务逻辑的中枢连接api层和storage层对组件暴露dispatch和getters。router/guards.js在初始化阶段消费user模块里的getters和actions不允许反向依赖router。这个方向做的好处是新人接手代码时先看目录就明白数据的流向不会出现一个组件里既调接口又操作localStorage又改Vuex的三层混杂鬼代码。6.4 RBAC权限管理设计的本质最后把视角往上抬一点。RBAC权限管理设计的关键从来不是前端某个具体表单怎么布、下拉框怎么联动而是数据建模与信任边界。前端要管理的是“可信展示”后端管理的是“严肃权限”。localStorage和Vuex的分工本质上服务的是前者让用户在不同时机看到和他角色匹配的界面同时保证刷新、退出、切换账号这些过程中不串数据。我在实际项目里的体会是存储逻辑写得是否干净直接决定了后续动态路由模块、权限指令模块能省多少心。如果把用户状态的写入、恢复、清理这套基本功打磨好等接到菜单权限和按钮权限的业务时你会发现自己只是在消费user模块已经暴露好的roles和permissions新功能顺手就长出来了。反过来基础存储做得七零八落哪怕权限判断再严谨菜单偶尔闪一下或串一个账号的数据用户对你的系统信任度就会掉一大截。storage这块的坑多半不是一次性踩中的而是慢慢渗透出来的。建议你过一遍手上的登录逻辑把localStorage的裸调都收拢到标准模块里给用户信息在Vuex里安排一个稳定的家。这个重构改动不大性价比却是在RBAC前端架构里最高的一笔。
返回列表