Vue3 状态管理:全局状态树设计实战 | 状态管理篇
【Pinia/Vuex】前端全局状态管理从业务域拆分到落地实操彻底搞懂全局状态树的最佳写法避开权限/字典/持久化高频坑 文章目录一、先搞清楚什么是全局状态为什么要设计1.1 一句话理解1.2 用 Vuex / Pinia 做什么二、整体设计思路按业务域拆分模块三、模块一用户状态User3.1 存什么3.2 基本结构示例3.3 常见坑点四、模块二权限状态Permission4.1 存什么4.2 常见后端数据结构示例4.3 权限 Store 示例4.4 按钮级权限自定义指令4.5 常见坑点五、模块三字典状态Dict5.1 存什么5.2 设计要点5.3 字典 Store 示例5.4 在组件中使用5.5 常见坑点六、模块四布局配置Layout6.1 存什么6.2 布局 Store 示例6.3 常见坑点七、整体挂载与初始化顺序7.1 Pinia 入口7.2 路由守卫中的初始化流程八、小结一份自检清单 本系列专栏导航同学们好我是 Eugene尤金一个拥有多年中后台开发经验的前端工程师~Eugene 发音很简单/juːˈdʒiːn/大家怎么顺口怎么叫就好你是否也有过明明学过很多技术一到关键时候却讲不出来、甚至写不出来你是否也曾怀疑自己是不是太笨了明明感觉会却总差一口气就算想沉下心从头梳理可工作那么忙回家还要陪伴家人。一天只有24小时时间永远不够用常常感到力不从心。技术行业本就是逆水行舟不进则退。如果你也有同样的困扰别慌。从现在开始跟着我一起心态归零利用碎片时间来一次彻彻底底的基础扫盲。这一次我们一起慢慢来扎扎实实变强。不搞花里胡哨的理论堆砌只分享看得懂、用得上的前端干货咱们一起稳步积累真正摆脱面向搜索引擎写代码的尴尬。一、先搞清楚什么是全局状态为什么要设计1.1 一句话理解全局状态多个页面、多个组件都需要读取和修改的同一份数据放在一个统一的“仓库”里统一管理。典型场景登录后的用户信息很多页面都要用按钮/菜单权限决定能否显示或操作字典数据如性别、状态、类型下拉框到处用到侧边栏是否折叠、主题色等布局配置如果每个组件各自发请求、各自存一份就会重复请求、浪费资源数据不同步容易出 bug刷新页面后数据丢失把这些数据抽成全局状态树统一管理可以只请求一次多处复用单一数据源更新一致持久化后刷新也能恢复⬆ 返回目录1.2 用 Vuex / Pinia 做什么VuexVue 2 官方状态库概念多state、mutation、action、module。PiniaVue 3 官方推荐API 更简洁支持 TypeScript。后面示例会以 Pinia 为主思路和模块划分同样适用于 Vuex。⬆ 返回目录二、整体设计思路按业务域拆分模块不要把用户、权限、字典、布局全部塞进一个 store 里建议按业务域拆成模块store/ ├── modules/ │ ├── user.ts # 用户信息 │ ├── permission.ts # 权限路由、按钮 │ ├── dict.ts # 字典 │ └── layout.ts # 布局配置 ├── index.ts # 入口挂载所有模块原则每个模块只负责一类数据模块间尽量少耦合跨模块逻辑放在 action 里或单独的 service 中⬆ 返回目录三、模块一用户状态User3.1 存什么基本信息id、username、nickname、avatar、email等登录态token、tokenExpire组织信息若有多租户orgId、orgName等⬆ 返回目录3.2 基本结构示例// store/modules/user.tsimport{defineStore}frompiniaexportinterfaceUserInfo{id:stringusername:stringnickname:stringavatar?:stringemail?:stringphone?:string}exportconstuseUserStoredefineStore(user,{state:()({token:asstring,userInfo:nullasUserInfo|null,// 可选记录登录时间用于 token 续期判断loginTime:0asnumber}),getters:{isLoggedIn:(state)!!state.token,displayName:(state)state.userInfo?.nickname||state.userInfo?.username||},actions:{setToken(token:string){this.tokentoken},setUserInfo(info:UserInfo|null){this.userInfoinfo},login(token:string,userInfo:UserInfo){this.tokentokenthis.userInfouserInfothis.loginTimeDate.now()},logout(){this.tokenthis.userInfonullthis.loginTime0}},// 持久化token 和 userInfo 要持久化刷新后还能用persist:{key:user-store,storage:localStorage,paths:[token,userInfo]}})⬆ 返回目录3.3 常见坑点坑现象建议token 不持久化刷新页面就掉线用pinia-plugin-persistedstate等持久化token敏感信息进 localStoragetoken 可能被 XSS 利用敏感系统用 httpOnly cookie 存 tokenuserInfo 和 token 不同步有 token 没 userInfo或反之登录时一起设置退出时一起清空⬆ 返回目录四、模块二权限状态Permission4.1 存什么路由权限用户可访问的路由/菜单用于动态路由和侧边栏按钮权限如user:add、user:edit用于控制按钮显隐⬆ 返回目录4.2 常见后端数据结构示例// 后端可能返回的权限结构{menus:[{path:/user,name:用户管理,children:[{path:/user/list,name:用户列表,perms:[user:list]},{path:/user/add,name:新增用户,perms:[user:add]}]}],permissions:[user:list,user:add,user:edit,user:delete]}⬆ 返回目录4.3 权限 Store 示例// store/modules/permission.tsimport{defineStore}frompiniaexportinterfaceMenuItem{path:stringname:stringicon?:stringperms?:string[]children?:MenuItem[]}exportconstusePermissionStoredefineStore(permission,{state:()({menus:[]asMenuItem[],permissions:[]asstring[]// 扁平化的权限码用于按钮级控制}),getters:{hasPermission:(state)(code:string){// 超级管理员通常拥有所有权限if(state.permissions.includes(*))returntruereturnstate.permissions.includes(code)}},actions:{setMenus(menus:MenuItem[]){this.menusmenus},setPermissions(perms:string[]){this.permissionsperms},reset(){this.menus[]this.permissions[]}}})⬆ 返回目录4.4 按钮级权限自定义指令// directives/permission.tsimporttype{Directive}fromvueimport{usePermissionStore}from/store/modules/permissionexportconstvPermission:Directive{mounted(el,binding){constpermissionStoreusePermissionStore()constcodebinding.valueasstringif(!code)returnif(!permissionStore.hasPermission(code)){// 无权限直接移除 DOMel.parentNode?.removeChild(el)}}}// main.ts 或入口文件注册// app.directive(permission, vPermission)使用示例templateel-buttonv-permissionuser:add新增用户/el-buttonel-buttonv-permissionuser:edit编辑/el-button/template⬆ 返回目录4.5 常见坑点坑现象建议刷新后权限丢失侧边栏空了或路由 403登录后把 menus/permissions 持久化或刷新后重新拉取指令在 store 未初始化时执行报错或误判在路由守卫中确保权限已加载再渲染页面权限码不统一前端user:add后端user_add和后端约定统一格式⬆ 返回目录五、模块三字典状态Dict5.1 存什么下拉选项、状态文案等如gender男/女、userStatus启用/禁用等。⬆ 返回目录5.2 设计要点按dictType分组存储支持懒加载用到再请求适当缓存减少请求⬆ 返回目录5.3 字典 Store 示例// store/modules/dict.tsimport{defineStore}frompiniaexportinterfaceDictItem{label:stringvalue:string|number[key:string]:any}exportconstuseDictStoredefineStore(dict,{state:()({// dictType - DictItem[]dictMap:{}asRecordstring,DictItem[],// 记录哪些类型已加载过避免重复请求loadedTypes:[]asstring[]}),getters:{getDict:(state)(dictType:string){returnstate.dictMap[dictType]||[]},getDictLabel:(state)(dictType:string,value:string|number){constliststate.dictMap[dictType]||[]constitemlist.find((d)d.valuevalue)returnitem?.label??value}},actions:{asyncloadDict(dictType:string){if(this.loadedTypes.includes(dictType)){returnthis.dictMap[dictType]}// 实际项目中替换为你的 API 请求constresawaitfetch(/api/dict/${dictType}).then((r)r.json())constlistres.data||[]this.dictMap[dictType]listthis.loadedTypes.push(dictType)returnlist},setDict(dictType:string,list:DictItem[]){this.dictMap[dictType]listif(!this.loadedTypes.includes(dictType)){this.loadedTypes.push(dictType)}}}})⬆ 返回目录5.4 在组件中使用templateel-selectv-modelform.genderplaceholder请选择性别el-optionv-foritem in dictStore.getDict(gender):keyitem.value:labelitem.label:valueitem.value//el-select!-- 或用于展示根据 value 显示 label --span{{ dictStore.getDictLabel(gender, form.gender) }}/span/templatescriptsetuplangtsimport{useDictStore}from/store/modules/dictimport{onMounted}fromvueconstdictStoreuseDictStore()onMounted(async(){awaitdictStore.loadDict(gender)})/script⬆ 返回目录5.5 常见坑点坑现象建议一进来就拉所有字典首屏慢按需loadDict用到再加载value 类型不一致选项选了不显示统一用string或number和接口一致字典更新不生效改了后台前端还是旧数据提供clearDict(type)或刷新逻辑必要时登出清缓存⬆ 返回目录六、模块四布局配置Layout6.1 存什么侧边栏折叠、主题、标签页、语言等这类配置通常需要持久化。⬆ 返回目录6.2 布局 Store 示例// store/modules/layout.tsimport{defineStore}frompiniaexportconstuseLayoutStoredefineStore(layout,{state:()({sidebarCollapsed:false,theme:lightaslight|dark,// 多标签页的页面栈可选visitedViews:[]as{path:string;title:string}[]}),getters:{sidebarWidth:(state)(state.sidebarCollapsed?64px:220px)},actions:{toggleSidebar(){this.sidebarCollapsed!this.sidebarCollapsed},setTheme(theme:light|dark){this.themetheme document.documentElement.setAttribute(data-theme,theme)},addVisitedView(route:{path:string;meta?:{title?:string}}){constview{path:route.path,title:route.meta?.title||未命名}constexistthis.visitedViews.find((v)v.pathroute.path)if(!exist)this.visitedViews.push(view)}},persist:{key:layout-store,storage:localStorage,paths:[sidebarCollapsed,theme]}})⬆ 返回目录6.3 常见坑点坑现象建议持久化体积过大visitedViews 太多只持久化sidebarCollapsed、theme等必要字段主题切换闪烁先亮后暗在 HTML 最前面根据存储的 theme 设置 class或做骨架屏⬆ 返回目录七、整体挂载与初始化顺序7.1 Pinia 入口// store/index.tsimport{createPinia}frompiniaimportpiniaPluginPersistedstatefrompinia-plugin-persistedstateconstpiniacreatePinia()pinia.use(piniaPluginPersistedstate)exportdefaultpinia⬆ 返回目录7.2 路由守卫中的初始化流程// router/index.tsrouter.beforeEach(async(to,from,next){constuserStoreuseUserStore()constpermissionStoreusePermissionStore()if(userStore.isLoggedIn){// 已登录确保权限已加载if(permissionStore.permissions.length0){try{awaitfetchUserPermissions()// 调用你的接口// 动态添加路由...}catch(e){userStore.logout()next(/login)return}}next()}else{if(to.path/login){next()}else{next(/login)}}})退出登录时建议统一清理asyncfunctionlogout(){constuserStoreuseUserStore()constpermissionStoreusePermissionStore()userStore.logout()permissionStore.reset()router.push(/login)}⬆ 返回目录八、小结一份自检清单设计全局状态树时可以按下面检查按业务域拆分user、permission、dict、layout 等独立模块。明确持久化范围token、userInfo、布局配置要持久化权限按需持久化或登录后拉取。权限与路由联动登录后加载权限 → 动态路由 → 再渲染页面。字典按需加载用到再loadDict并做好缓存。退出时清理logout 时重置 user、permission 等避免脏数据。⬆ 返回目录 本系列专栏导航 状态管理Vuex 、Pinia 扫盲一、《Vuex vs Pinia 实战对比Vue3 状态管理选型与迁移指南状态管理篇》二、《Vue3 状态管理全局状态树设计实战状态管理篇》三、《Vue3 状态管理Pinia 组件优雅使用实战状态管理篇》四、《Vue3 状态管理Pinia 常见坑与调试实战状态管理篇》 跟着系列慢慢学把技术功底扎扎实实地打牢 更多内容TS/Vue/工程化等共47篇已整理成「全体系总目录」收藏后可一站式学习 系列总览想系统学习Vue3中后台开发收藏这份「全体系指南」47篇干货从基础到工程化全覆盖《前端基础实战JS/TS与Vue体系化扫盲47 篇完整目录 避坑》 每篇都配套实战场景避坑指南帮你摆脱「面向搜索引擎写代码」的尴尬⬆ 返回目录学习本就是一场持久战不需要急着一口吃成胖子。哪怕今天你只记住了一点点这都是实打实的进步。后续我还会继续用这种大白话、讲实战方式带大家扫盲更多前端基础。关注我不迷路咱们把那些曾经模糊的知识点一个个彻底搞清楚。如果你觉得这篇内容对你有帮助不妨点赞收藏下次写代码卡壳时拿出来翻一翻比搜引擎更靠谱。我是 Eugene你的电子学友我们下一篇干货见