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

发布时间:2026/7/6 4:37:06
Vuex Modules 分层:UI状态、用户状态、权限状态如何各管各的 一个大中后台系统几十个状态变量怎么分Vuex 官方文档告诉你用modules拆分 store但没告诉你按什么原则分。全部放一个 module 里能跑但随着页面增多一个store/index.js可能膨胀到 500 行——找状态靠CtrlF改一个 mutation 要翻半屏。这套经典的中后台模板给出了一个非常清晰的划分标准按谁产生的分不按谁用的分。四个 module 的职责地图src/store/ ├── index.js ← require.context 自动注册所有 module ├── getters.js ← 全局 getter所有组件使用的统一入口 └── modules/ ├── app.js ← UI 状态层 ├── user.js ← 认证状态层 ├── permission.js ← 权限控制层 └── tagsView.js ← 标签页状态层module 1app.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.openedfalsestate.sidebar.withoutAnimationwithoutAnimation},TOGGLE_DEVICE(state,device){state.devicedevice}}注意一个细节sidebar不是简单的boolean而是一个对象{ opened, withoutAnimation }。为什么因为折叠和展开需要的动画控制不同——移动端点击遮罩关闭时不需要动画要立刻消失PC 端点击 hamburger 按钮时要动画过渡。如果sidebar是裸boolean你需要在调用方传animation参数侵入到每个组件。包成对象后调用方只需dispatch(app/closeSideBar, { withoutAnimation: true })内部处理。判断标准如果一个状态的消费者多且不同消费者需要不同的附带信息就包成对象如果只有一个消费者用裸值就行。module 2user.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)})}}用户模块的特点是它是唯一一个涉及异步请求的 modulelogin、logout、getInfo 都调 API。其他三个 moduleapp、permission、tagsView都是同步的 UI 状态管理。这是因为用户认证是应用的生命周期门槛——其他一切状态都在用户已登录的前提下存在。把认证逻辑单独隔离意味着如果认证方式换了JWT → OAuth只改这一个 module如果加新认证信息如permissions字段不影响 UI 层module 3permission.js—— 管你能看什么权限模块只干一件事根据用户角色从全部路由里筛出该用户有权访问的// store/modules/permission.js简化conststate{routes:[]// 动态计算后的可访问路由}// 递归过滤只保留用户角色匹配的路由functionfilterByRole(routes,roles){returnroutes.filter(route{if(route.meta?.roles){if(!roles.some(rroute.meta.roles.includes(r)))returnfalse}if(route.children){route.childrenfilterByRole(route.children,roles)}returntrue})}constactions{generateRoutes({commit},roles){letaccessedRoutesif(roles.includes(admin)){accessedRoutesasyncRoutes// admin 看全部}else{accessedRoutesfilterByRole(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 4tagsView.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 :includecachedViews。两者是同一个标签页在UI 显示和性能缓存两个维度的投影放在同一个 module 里方便同步增删。module 间通信的两种方式方式一dispatch 跨模块调用如上dispatch(permission/generateRoutes,roles,{root:true})方式二getters 跨模块取值所有 module 的状态都通过全局getters.js暴露组件不直接访问state.app.sidebar而是通过 getter// store/getters.jsconstgetters{sidebar:statestate.app.sidebar,device:statestate.app.device,token:statestate.user.token,roles:statestate.user.roles,menuRoutes:statestate.permission.routes,visitedViews:statestate.tagsView.visitedViews,}这个文件的价值在于组件不需要知道sidebar在哪个 module 里。组件只写mapGetters([sidebar])至于 sidebar 是state.app.sidebar还是state.ui.sidebargetters.js 承担了映射。如果将来重构把app.js拆成ui.js和layout.js你只需要改getters.js里的一行映射所有组件不受影响。自动注册 modulerequire.context注意store/index.js里没有import app from ./modules/app而是用 webpack 的require.context自动扫描constmodulesFilesrequire.context(./modules,true,/\.js$/)constmodulesmodulesFiles.keys().reduce((modules,modulePath){constmoduleNamemodulePath.replace(/^\.\/(.*)\.\w$/,$1)constvaluemodulesFiles(modulePath)modules[moduleName]value.defaultreturnmodules},{})conststorenewVuex.Store({modules,getters})这段代码的效果你往modules/目录里加一个analytics.js文件不需要改任何入口文件module 自动生效。文件名即 module 名analytics.js→ namespaceanalytics。这个设计解决了多人同时开发时store/index.js 频繁冲突的问题——每个人在自己的 module 文件里开发不需要碰入口文件。我的验证复现同样的 module 分层为了验证这套分层逻辑我搭了一个最小 demo// store/index.jsimportVuefromvueimportVuexfromvuexconststorenewVuex.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.tokentoken}}}},getters:{sidebar:ss.ui.sidebar,token:ss.auth.token,}})然后在组件里验证了两点// 验证1组件不感知 module 位置computed:{...mapGetters([sidebar])// 不知道它来自 ui module}// 验证2跨 module dispatchthis.$store.dispatch(auth/login,credentials,{root:true})两个验证都通过。核心结论只要 getters 层封住了 module 边界上层组件完全不需要知道 store 的内部结构。总结三个分层原则按谁产生的分 module不按谁用的分。sidebar 是 UI 产生的归 ui moduletoken 是认证产生的归 auth module。组件可以同时消费两个 module 的数据但产生方是唯一的。getters 是 module 边界的封装层。组件永远通过 getter 取值不直接访问state.xxx.yyy。这给未来的重构留了退路。跨 module 通信必须显式声明{ root: true }。这是 Vuex namespaced 的安全机制——防止一个 module 意外修改另一个 module 的数据。