Vue3 Pinia 状态管理规范:状态拆分、Actions 写法、持久化实战,避坑状态污染|状态管理与路由规范篇

Vue3 Pinia 状态管理规范:状态拆分、Actions 写法、持久化实战,避坑状态污染|状态管理与路由规范篇 【Pinia 状态管理】【中后台前端实战】从状态拆分、actions 规范到持久化落地掌握可维护状态管理写法避开状态污染与全局状态地狱Pinia 官方文档 文章目录一、开篇为什么还要学 Pinia 规范二、Pinia 基础速览5 分钟扫盲2.1 和 Vuex 的区别你只需要记住这些2.2 一个最小的 Store 长什么样三、核心规范一状态拆分 —— 按领域还是按功能3.1 问题一个 store 塞满所有状态会怎样3.2 推荐做法按业务领域拆分3.3 什么时候需要跨 store 拿数据四、核心规范二Actions 怎么写才不乱4.1 三个原则4.2 标准异步 Action 写法4.3 在组件里怎么用4.4 容易踩的坑直接解构 store五、核心规范三持久化 —— 刷新不丢数据5.1 什么需要持久化5.2 方案一pinia-plugin-persistedstate推荐5.3 方案二自己封装理解原理5.4 持久化需要注意的点六、核心规范四避免状态污染 —— 深浅拷贝要分清6.1 什么是状态污染6.2 正确写法深拷贝、明确归属6.3 Getter 返回引用时要注意七、完整示例用户 应用配置 持久化八、小结速查表九、延伸阅读建议 系列模块导航同学们好我是 Eugene尤金一名多年中后台前端开发工程师。Eugene 发音 /juːˈdʒiːn/大家怎么顺口怎么叫就好很多前端开发者都会遇到一个瓶颈代码能跑但不够规范功能能实现但维护起来特别痛苦一个人写没问题一到团队协作就各种混乱、踩坑、返工。想写出干净、优雅、可维护的专业代码靠的不是天赋而是体系化的规范 真实实战经验。这一系列《前端规范实战》我会用大白话 真实业务场景不讲玄学、不堆理论只分享能直接落地的规范、标准与避坑指南。帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。一、开篇为什么还要学 Pinia 规范Vue 3 的推荐状态管理是 Pinia。如果只会写业务但不清楚怎么拆分 store、怎么设计 actions、如何做持久化和避免污染项目一复杂就会变成“全局状态地狱”。这篇文章从日常开发怎么选、为什么这么选、容易踩哪些坑三个角度帮你把 Pinia 用得更稳、更清晰。适用读者会写 JS但对状态管理概念还比较模糊的人想从零搭建 Vue 3 项目的初学者有经验但想梳理一遍习惯的开发者⬆ 返回目录二、Pinia 基础速览5 分钟扫盲2.1 和 Vuex 的区别你只需要记住这些对比项VuexPiniaAPI 风格mutation / action 分离只有 actions模块化靠modules注册每个 store 是独立文件TypeScript支持一般支持更好包体积较大更小组合式写法需要 mapState 等直接用storeToRefs一句话Pinia 更简单功能不弱官方推荐优先用它。⬆ 返回目录2.2 一个最小的 Store 长什么样// stores/counter.jsimport{defineStore}frompiniaexportconstuseCounterStoredefineStore(counter,{state:()({count:0,}),getters:{doubleCount:(state)state.count*2,},actions:{increment(){this.count},},})在组件里用scriptsetupimport{useCounterStore}from/stores/counterconstcounterStoreuseCounterStore()/scripttemplatediv{{ counterStore.count }}/divbuttonclickcounterStore.increment1/button/templatedefineStore第一个参数是 store 的唯一 id第二个是配置对象或 setup 函数。下文会围绕这个基础讲怎么拆、怎么改、怎么持久化。⬆ 返回目录三、核心规范一状态拆分 —— 按领域还是按功能3.1 问题一个 store 塞满所有状态会怎样文件超大难以维护修改一处到处影响团队协作容易冲突难以做按需持久化⬆ 返回目录3.2 推荐做法按业务领域拆分原则一个 store 只管一类业务边界清晰。示例项目结构stores/ ├── index.js # 统一导出 ├── user.js # 用户登录、权限、资料 ├── app.js # 应用主题、语言、布局 ├── cart.js # 购物车 └── product.js # 商品列表、搜索条件错误示范// ❌ 错误一个 store 包揽所有exportconstuseMainStoredefineStore(main,{state:()({user:null,token:,theme:light,cartItems:[],productList:[],// ... 越来越多}),})正确示范// ✅ stores/user.js - 只管用户相关exportconstuseUserStoredefineStore(user,{state:()({userInfo:null,token:,permissions:[],}),// ...})// ✅ stores/app.js - 只管应用配置exportconstuseAppStoredefineStore(app,{state:()({theme:light,language:zh-CN,sidebarCollapsed:false,}),// ...})⬆ 返回目录3.3 什么时候需要跨 store 拿数据用 getter 引用其他 store// stores/cart.jsimport{defineStore}frompiniaimport{useUserStore}from./userexportconstuseCartStoredefineStore(cart,{state:()({items:[],}),getters:{// 跨 store 获取用户是否登录canCheckout:(){constuserStoreuseUserStore()returnuserStore.tokenuserStore.userInfo},},})要点只在 getter 里读其他 store不要在 action 里直接改别的 store 的 state。⬆ 返回目录四、核心规范二Actions 怎么写才不乱4.1 三个原则异步放在 actions 里请求、副作用都集中到 actions状态只通过 actions 改组件不要直接改 state一个 action 只做一件事粒度小、好测试、易复用⬆ 返回目录4.2 标准异步 Action 写法// stores/user.jsimport{defineStore}frompiniaimport{loginApi,getUserInfoApi}from/api/userexportconstuseUserStoredefineStore(user,{state:()({userInfo:null,token:,loading:false,error:null,}),actions:{asynclogin(credentials){this.loadingtruethis.errornulltry{constresawaitloginApi(credentials)this.tokenres.data.tokenthis.userInfores.data.userreturn{success:true}}catch(err){this.errorerr.message||登录失败return{success:false,message:this.error}}finally{this.loadingfalse}},asyncfetchUserInfo(){if(!this.token)returntry{constresawaitgetUserInfoApi()this.userInfores.data}catch(err){this.errorerr.message// 可以考虑清除 token触发重新登录}},logout(){this.tokenthis.userInfonullthis.errornull},},})要点用loading/error描述过程用try/catch/finally统一处理。⬆ 返回目录4.3 在组件里怎么用scriptsetupimport{storeToRefs}frompiniaimport{useUserStore}from/stores/userconstuserStoreuseUserStore()// 只把需要响应式的拿出来避免整个 store 被解构const{userInfo,loading,error}storeToRefs(userStore)asyncfunctionhandleLogin(){constresultawaituserStore.login({username,password})if(result.success){// 跳转首页等}}/scripttemplateformsubmit.preventhandleLogindivv-iferrorclasserror{{ error }}/divbutton:disabledloading{{ loading ? 登录中... : 登录 }}/button/form/template⬆ 返回目录4.4 容易踩的坑直接解构 store// ❌ 错误直接解构会丢失响应式const{count,increment}useCounterStore()// count 是普通值改了不会更新视图// ✅ 正确用 storeToRefs 拿 state/getterconst{count}storeToRefs(useCounterStore())// actions 直接从 store 上取const{increment}useCounterStore()⬆ 返回目录五、核心规范三持久化 —— 刷新不丢数据5.1 什么需要持久化需要持久化token、主题、语言、布局偏好、购物车一般不需要临时列表、弹窗状态、加载状态⬆ 返回目录5.2 方案一pinia-plugin-persistedstate推荐安装npminstallpinia-plugin-persistedstate配置// main.jsimport{createApp}fromvueimport{createPinia}frompiniaimportpiniaPluginPersistedstatefrompinia-plugin-persistedstateimportAppfrom./App.vueconstpiniacreatePinia()pinia.use(piniaPluginPersistedstate)createApp(App).use(pinia).mount(#app)在 store 里开启// stores/user.jsexportconstuseUserStoredefineStore(user,{state:()({token:,userInfo:null,}),persist:true,// 全部持久化})更细粒度的配置exportconstuseUserStoredefineStore(user,{state:()({token:,userInfo:null,tempData:null,// 不想持久化}),persist:{key:my-app-user,// 存到 localStorage 的 keystorage:localStorage,// 默认就是 localStoragepaths:[token,userInfo],// 只持久化这两个字段},})⬆ 返回目录5.3 方案二自己封装理解原理// utils/persist.jsexportfunctionusePersist(store,keystore.$id){// 初始化时从 localStorage 恢复constsavedlocalStorage.getItem(key)if(saved){try{store.$patch(JSON.parse(saved))}catch(e){console.warn(恢复状态失败,e)}}// 监听变化并保存store.$subscribe((mutation,state){localStorage.setItem(key,JSON.stringify(state))})}在main.js或入口里按需调用constuserStoreuseUserStore()usePersist(userStore,my-user)⬆ 返回目录5.4 持久化需要注意的点不要存敏感数据token 可以用但尽量用 httpOnly cookie 更安全注意体积避免把大数组、大对象全丢进 localStorage版本兼容结构变了要做兼容或清空旧数据⬆ 返回目录六、核心规范四避免状态污染 —— 深浅拷贝要分清6.1 什么是状态污染组件或接口拿到的是 state 的引用直接改了这个引用就会污染 store导致状态难追踪撤销/重做不好做时间旅行调试失效典型错误// ❌ 污染直接改传入的对象actions:{updateUser(updates){// updates 可能是外部传入的直接合并会污染Object.assign(this.userInfo,updates)},}// 组件里constform{name:张三,age:20}userStore.updateUser(form)// form 可能被 store 内部修改影响⬆ 返回目录6.2 正确写法深拷贝、明确归属actions:{updateUser(updates){if(!this.userInfo)return// 基于当前 state 做拷贝再合并this.userInfo{...this.userInfo,...JSON.parse(JSON.stringify(updates)),}},setCartItems(newItems){// 用拷贝避免外部直接改 storethis.items[...newItems]},}复杂对象可以用structuredClone或lodash.cloneDeep。⬆ 返回目录6.3 Getter 返回引用时要注意getters:{// ❌ 返回内部数组引用外部修改会污染cartItems:(state)state.items,// ✅ 返回拷贝外部改不影响 storecartItemsSafe:(state)[...state.items],}如果 getter 只是做过滤、映射建议返回新数组/新对象。⬆ 返回目录七、完整示例用户 应用配置 持久化下面是一个可直接拷贝、改名的示例。// stores/user.jsimport{defineStore}frompiniaimport{loginApi,getUserInfoApi}from/api/userexportconstuseUserStoredefineStore(user,{state:()({token:,userInfo:null,loading:false,error:null,}),getters:{isLoggedIn:(state)!!state.token,userName:(state)state.userInfo?.name??,},actions:{asynclogin(credentials){this.loadingtruethis.errornulltry{constresawaitloginApi(credentials)this.tokenres.data.tokenawaitthis.fetchUserInfo()return{success:true}}catch(err){this.errorerr.message||登录失败return{success:false,message:this.error}}finally{this.loadingfalse}},asyncfetchUserInfo(){if(!this.token)returntry{constresawaitgetUserInfoApi()this.userInfo{...res.data}}catch{this.logout()}},logout(){this.tokenthis.userInfonullthis.errornull},},persist:{key:app-user,paths:[token,userInfo],},})// stores/app.jsimport{defineStore}frompiniaexportconstuseAppStoredefineStore(app,{state:()({theme:light,sidebarCollapsed:false,}),actions:{toggleTheme(){this.themethis.themelight?dark:light},toggleSidebar(){this.sidebarCollapsed!this.sidebarCollapsed},},persist:true,})!-- components/UserProfile.vue --scriptsetupimport{storeToRefs}frompiniaimport{useUserStore}from/stores/userconstuserStoreuseUserStore()const{userInfo,loading,error}storeToRefs(userStore)functionhandleLogout(){userStore.logout()}/scripttemplatedivv-ifuserInfop欢迎{{ userInfo.name }}/pbutton:disabledloadingclickhandleLogout退出/button/divpv-iferrorclasserror{{ error }}/p/template⬆ 返回目录八、小结速查表场景推荐做法避免拆分按业务领域拆 store一个 store 包所有状态异步统一在 actions 里用 loading/error在组件里散落请求解构storeToRefs拿 state/getter直接解构 store持久化pinia-plugin-persistedstate paths全量持久化大对象防污染用拷贝更新 stategetter 返回拷贝直接改引用、getter 返回内部引用⬆ 返回目录 系列模块导航 状态管理与路由规范一、《Vue3 Pinia 状态管理规范状态拆分、Actions 写法、持久化实战避坑状态污染状态管理与路由规范篇》二、《Vue3 Pinia 状态管理规范何时用 Pinia 何时用本地状态状态管理与路由规范篇》三、《Vue Router 实战规范path/name/meta 配置 动态 / 嵌套路由统一团队标准状态管理与路由规范篇》四、《Vue3 Vue Router Pinia 路由守卫规范beforeEach 应做 / 不应做避死循环、防重复请求状态管理与路由规范篇》五、《Vue keep-alive 实战避坑include/exclude 路由 meta 标记中后台路由缓存精准可控状态管理与路由规范篇》 跟着系列慢慢学把技术功底扎扎实实地打牢 系列总览「前端规范实战系列」正在持续更新中后续会整理一篇《前端规范实战系列全系列目录导航》包含每篇文章简介 直达链接方便大家按顺序、体系化学习。更新中敬请期待⬆ 返回目录技术成长从来不是比谁写得快而是比谁写得稳、规范、可维护。哪怕每次只吃透一条规范长期下来差距会非常明显。后续我会持续更新前端规范、工程化、可维护代码相关实战干货帮你告别面条代码、维护噩梦在开发与面试中更有底气。觉得有用欢迎点赞 收藏 关注不错过每一篇实战内容。我是 Eugene与你一起写规范、写优质代码我们下篇干货见