Vuex Modules 分层:UI状态、用户状态、权限状态如何各管各的

📅 2026/7/6 4:42:21 👁️ 阅读次数 📝 编程学习
Vuex Modules 分层:UI状态、用户状态、权限状态如何各管各的

一个大中后台系统,几十个状态变量,怎么分?

Vuex 官方文档告诉你用modules拆分 store,但没告诉你按什么原则分。全部放一个 module 里能跑,但随着页面增多,一个store/index.js可能膨胀到 500 行——找状态靠Ctrl+F,改一个 mutation 要翻半屏。

这套经典的中后台模板给出了一个非常清晰的划分标准:按"谁产生的"分,不按"谁用的"分

四个 module 的职责地图

src/store/ ├── index.js ← require.context 自动注册所有 module ├── getters.js ← 全局 getter,所有组件使用的统一入口 └── modules/ ├── app.js ← UI 状态层 ├── user.js ← 认证状态层 ├── permission.js ← 权限控制层 └── tagsView.js ← 标签页状态层

module 1:app.js—— 只管界面长什么样

这是纯 UI 状态,不涉及任何业务数据:

// store/modules/app.js(简化)conststate={sidebar:{opened:true,// 侧边栏展开/折叠withoutAnimation:false// 是否跳过动画},device:'desktop',// 当前设备类型:desktop / mobilesize:'medium'// 全局组件尺寸:medium / small / mini}constmutations={TOGGLE_SIDEBAR(state){state.sidebar.opened=!state.sidebar.opened},CLOSE_SIDEBAR(state,withoutAnimation){state.sidebar.opened=falsestate.sidebar.withoutAnimation=withoutAnimation},TOGGLE_DEVICE(state,device){state.device=device}}

注意一个细节:sidebar不是简单的boolean,而是一个对象{ opened, withoutAnimation }。为什么?

因为折叠和展开需要的动画控制不同——移动端点击遮罩关闭时不需要动画(要立刻消失),PC 端点击 hamburger 按钮时要动画过渡。如果sidebar是裸boolean,你需要在调用方传animation参数,侵入到每个组件。包成对象后,调用方只需dispatch('app/closeSideBar', { withoutAnimation: true }),内部处理。

判断标准:如果一个状态的消费者多,且不同消费者需要不同的"附带信息",就包成对象;如果只有一个消费者,用裸值就行。

module 2:user.js—— 只管你是谁

用户模块存认证相关的全部状态:

// store/modules/user.js(简化)conststate={token:'',// 登录凭证name:'',// 用户姓名avatar:'',// 头像 URLroles:[],// 角色列表:['admin', 'editor']}constactions={login({commit},userInfo){returnapi.login(userInfo).then(res=>{commit('SET_TOKEN',res.token)})},logout({commit}){commit('SET_TOKEN','')},getInfo({commit}){returnapi.getUserInfo().then(res=>{commit('SET_NAME',res.name)commit('SET_AVATAR',res.avatar)commit('SET_ROLES',res.roles)})}}

用户模块的特点是:它是唯一一个涉及异步请求的 module(login、logout、getInfo 都调 API)。其他三个 module(app、permission、tagsView)都是同步的 UI 状态管理。

这是因为用户认证是应用的生命周期门槛——其他一切状态都在"用户已登录"的前提下存在。把认证逻辑单独隔离,意味着:

  • 如果认证方式换了(JWT → OAuth),只改这一个 module
  • 如果加新认证信息(如permissions字段),不影响 UI 层

module 3:permission.js—— 管你能看什么

权限模块只干一件事:根据用户角色,从全部路由里筛出"该用户有权访问的":

// store/modules/permission.js(简化)conststate={routes:[]// 动态计算后的可访问路由}// 递归过滤:只保留用户角色匹配的路由functionfilterByRole(routes,roles){returnroutes.filter(route=>{if(route.meta?.roles){if(!roles.some(r=>route.meta.roles.includes(r)))returnfalse}if(route.children){route.children=filterByRole(route.children,roles)}returntrue})}constactions={generateRoutes({commit},roles){letaccessedRoutesif(roles.includes('admin')){accessedRoutes=asyncRoutes// admin 看全部}else{accessedRoutes=filterByRole(asyncRoutes,roles)// 按角色过滤}commit('SET_ROUTES',accessedRoutes)returnaccessedRoutes}}

权限模块为什么值得独立?因为路由过滤逻辑在运行时变化。用户登录后才知道角色,角色决定路由——这条链路在user/loginaction 返回后触发permission/generateRoutes,两个 module 之间通过 dispatch 通信:

// 在 user.js 的 login action 里login({commit,dispatch},userInfo){returnapi.login(userInfo).then(res=>{commit('SET_TOKEN',res.token)dispatch('permission/generateRoutes',res.roles,{root:true})// ↑ 跨 module 调用,必须加 { root: true }})}

{ root: true }是 Vuex namespaced module 的跨模块调用标记。忘记加这个,Vuex 会在当前 module 里找permission/generateRoutes,找不到就静默失败——这是一个非常容易踩的坑。

module 4:tagsView.js—— 管理打开的标签页

标签页导航的增删改:

// store/modules/tagsView.js(简化)conststate={visitedViews:[],// 已打开的标签页列表cachedViews:[]// 通过 keep-alive 缓存的组件名列表}constmutations={ADD_VISITED_VIEW(state,view){...},DEL_VISITED_VIEW(state,view){...},DEL_CACHED_VIEW(state,view){...},}

visitedViews存的是路由对象(path、title 等),cachedViews存的是组件名(用于<keep-alive :include="cachedViews">)。两者是同一个标签页在"UI 显示"和"性能缓存"两个维度的投影,放在同一个 module 里方便同步增删。

module 间通信的两种方式

方式一:dispatch 跨模块调用(如上)

dispatch('permission/generateRoutes',roles,{root:true})

方式二:getters 跨模块取值

所有 module 的状态都通过全局getters.js暴露,组件不直接访问state.app.sidebar,而是通过 getter:

// store/getters.jsconstgetters={sidebar:state=>state.app.sidebar,device:state=>state.app.device,token:state=>state.user.token,roles:state=>state.user.roles,menuRoutes:state=>state.permission.routes,visitedViews:state=>state.tagsView.visitedViews,}

这个文件的价值在于:组件不需要知道sidebar在哪个 module 里。组件只写mapGetters(['sidebar']),至于 sidebar 是state.app.sidebar还是state.ui.sidebar,getters.js 承担了映射。

如果将来重构把app.js拆成ui.jslayout.js,你只需要改getters.js里的一行映射,所有组件不受影响。

自动注册 module:require.context

注意store/index.js里没有import app from './modules/app',而是用 webpack 的require.context自动扫描:

constmodulesFiles=require.context('./modules',true,/\.js$/)constmodules=modulesFiles.keys().reduce((modules,modulePath)=>{constmoduleName=modulePath.replace(/^\.\/(.*)\.\w+$/,'$1')constvalue=modulesFiles(modulePath)modules[moduleName]=value.defaultreturnmodules},{})conststore=newVuex.Store({modules,getters})

这段代码的效果:你往modules/目录里加一个analytics.js文件,不需要改任何入口文件,module 自动生效。文件名即 module 名analytics.js→ namespaceanalytics)。

这个设计解决了"多人同时开发时,store/index.js 频繁冲突"的问题——每个人在自己的 module 文件里开发,不需要碰入口文件。

我的验证:复现同样的 module 分层

为了验证这套分层逻辑,我搭了一个最小 demo:

// store/index.jsimportVuefrom'vue'importVuexfrom'vuex'conststore=newVuex.Store({modules:{ui:{namespaced:true,state:{sidebar:{opened:true},device:'desktop'},mutations:{TOGGLE_SIDEBAR(s){s.sidebar.opened=!s.sidebar.opened}}},auth:{namespaced:true,state:{token:'',roles:[]},mutations:{SET_TOKEN(s,token){s.token=token}}}},getters:{sidebar:s=>s.ui.sidebar,token:s=>s.auth.token,}})

然后在组件里验证了两点:

// 验证1:组件不感知 module 位置computed:{...mapGetters(['sidebar'])// 不知道它来自 ui module}// 验证2:跨 module dispatchthis.$store.dispatch('auth/login',credentials,{root:true})

两个验证都通过。核心结论:只要 getters 层封住了 module 边界,上层组件完全不需要知道 store 的内部结构

总结:三个分层原则

  1. 按"谁产生的"分 module,不按"谁用的"分。sidebar 是 UI 产生的,归 ui module;token 是认证产生的,归 auth module。组件可以同时消费两个 module 的数据,但产生方是唯一的。

  2. getters 是 module 边界的封装层。组件永远通过 getter 取值,不直接访问state.xxx.yyy。这给未来的重构留了退路。

  3. 跨 module 通信必须显式声明{ root: true }。这是 Vuex namespaced 的安全机制——防止一个 module 意外修改另一个 module 的数据。