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 的区别(你只需要记住这些)
| 对比项 | Vuex | Pinia |
|---|---|---|
| API 风格 | mutation / action 分离 | 只有 actions |
| 模块化 | 靠modules注册 | 每个 store 是独立文件 |
| TypeScript | 支持一般 | 支持更好 |
| 包体积 | 较大 | 更小 |
| 组合式写法 | 需要 mapState 等 | 直接用storeToRefs |
一句话:Pinia 更简单,功能不弱,官方推荐优先用它。
⬆ 返回目录
2.2 一个最小的 Store 长什么样?
// stores/counter.jsimport{defineStore}from'pinia'exportconstuseCounterStore=defineStore('counter',{state:()=>({count:0,}),getters:{doubleCount:(state)=>state.count*2,},actions:{increment(){this.count++},},})在组件里用:
<scriptsetup>import{useCounterStore}from'@/stores/counter'constcounterStore=useCounterStore()</script><template><div>{{ counterStore.count }}</div><button@click="counterStore.increment">+1</button></template>defineStore第一个参数是 store 的唯一 id,第二个是配置对象(或 setup 函数)。下文会围绕这个基础,讲怎么拆、怎么改、怎么持久化。
⬆ 返回目录
三、核心规范一:状态拆分 —— 按领域还是按功能?
3.1 问题:一个 store 塞满所有状态会怎样?
- 文件超大,难以维护
- 修改一处,到处影响
- 团队协作容易冲突
- 难以做按需持久化
⬆ 返回目录
3.2 推荐做法:按业务领域拆分
原则:一个 store 只管一类业务,边界清晰。
示例项目结构:
stores/ ├── index.js # 统一导出 ├── user.js # 用户:登录、权限、资料 ├── app.js # 应用:主题、语言、布局 ├── cart.js # 购物车 └── product.js # 商品列表、搜索条件错误示范:
// ❌ 错误:一个 store 包揽所有exportconstuseMainStore=defineStore('main',{state:()=>({user:null,token:'',theme:'light',cartItems:[],productList:[],// ... 越来越多}),})正确示范:
// ✅ stores/user.js - 只管用户相关exportconstuseUserStore=defineStore('user',{state:()=>({userInfo:null,token:'',permissions:[],}),// ...})// ✅ stores/app.js - 只管应用配置exportconstuseAppStore=defineStore('app',{state:()=>({theme:'light',language:'zh-CN',sidebarCollapsed:false,}),// ...})⬆ 返回目录
3.3 什么时候需要跨 store 拿数据?
用 getter 引用其他 store:
// stores/cart.jsimport{defineStore}from'pinia'import{useUserStore}from'./user'exportconstuseCartStore=defineStore('cart',{state:()=>({items:[],}),getters:{// 跨 store 获取用户是否登录canCheckout:()=>{constuserStore=useUserStore()returnuserStore.token&&userStore.userInfo},},})要点:只在 getter 里读其他 store,不要在 action 里直接改别的 store 的 state。
⬆ 返回目录
四、核心规范二:Actions 怎么写才不乱?
4.1 三个原则
- 异步放在 actions 里:请求、副作用都集中到 actions
- 状态只通过 actions 改:组件不要直接改 state
- 一个 action 只做一件事:粒度小、好测试、易复用
⬆ 返回目录
4.2 标准异步 Action 写法
// stores/user.jsimport{defineStore}from'pinia'import{loginApi,getUserInfoApi}from'@/api/user'exportconstuseUserStore=defineStore('user',{state:()=>({userInfo:null,token:'',loading:false,error:null,}),actions:{asynclogin(credentials){this.loading=truethis.error=nulltry{constres=awaitloginApi(credentials)this.token=res.data.tokenthis.userInfo=res.data.userreturn{success:true}}catch(err){this.error=err.message||'登录失败'return{success:false,message:this.error}}finally{this.loading=false}},asyncfetchUserInfo(){if(!this.token)returntry{constres=awaitgetUserInfoApi()this.userInfo=res.data}catch(err){this.error=err.message// 可以考虑清除 token,触发重新登录}},logout(){this.token=''this.userInfo=nullthis.error=null},},})要点:用loading/error描述过程,用try/catch/finally统一处理。
⬆ 返回目录
4.3 在组件里怎么用?
<scriptsetup>import{storeToRefs}from'pinia'import{useUserStore}from'@/stores/user'constuserStore=useUserStore()// 只把需要响应式的拿出来,避免整个 store 被解构const{userInfo,loading,error}=storeToRefs(userStore)asyncfunctionhandleLogin(){constresult=awaituserStore.login({username,password})if(result.success){// 跳转首页等}}</script><template><form@submit.prevent="handleLogin"><divv-if="error"class="error">{{ error }}</div><button:disabled="loading">{{ 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}from'vue'import{createPinia}from'pinia'importpiniaPluginPersistedstatefrom'pinia-plugin-persistedstate'importAppfrom'./App.vue'constpinia=createPinia()pinia.use(piniaPluginPersistedstate)createApp(App).use(pinia).mount('#app')在 store 里开启:
// stores/user.jsexportconstuseUserStore=defineStore('user',{state:()=>({token:'',userInfo:null,}),persist:true,// 全部持久化})更细粒度的配置:
exportconstuseUserStore=defineStore('user',{state:()=>({token:'',userInfo:null,tempData:null,// 不想持久化}),persist:{key:'my-app-user',// 存到 localStorage 的 keystorage:localStorage,// 默认就是 localStoragepaths:['token','userInfo'],// 只持久化这两个字段},})⬆ 返回目录
5.3 方案二:自己封装(理解原理)
// utils/persist.jsexportfunctionusePersist(store,key=store.$id){// 初始化时从 localStorage 恢复constsaved=localStorage.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或入口里按需调用:
constuserStore=useUserStore()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}from'pinia'import{loginApi,getUserInfoApi}from'@/api/user'exportconstuseUserStore=defineStore('user',{state:()=>({token:'',userInfo:null,loading:false,error:null,}),getters:{isLoggedIn:(state)=>!!state.token,userName:(state)=>state.userInfo?.name??'',},actions:{asynclogin(credentials){this.loading=truethis.error=nulltry{constres=awaitloginApi(credentials)this.token=res.data.tokenawaitthis.fetchUserInfo()return{success:true}}catch(err){this.error=err.message||'登录失败'return{success:false,message:this.error}}finally{this.loading=false}},asyncfetchUserInfo(){if(!this.token)returntry{constres=awaitgetUserInfoApi()this.userInfo={...res.data}}catch{this.logout()}},logout(){this.token=''this.userInfo=nullthis.error=null},},persist:{key:'app-user',paths:['token','userInfo'],},})// stores/app.jsimport{defineStore}from'pinia'exportconstuseAppStore=defineStore('app',{state:()=>({theme:'light',sidebarCollapsed:false,}),actions:{toggleTheme(){this.theme=this.theme==='light'?'dark':'light'},toggleSidebar(){this.sidebarCollapsed=!this.sidebarCollapsed},},persist:true,})<!-- components/UserProfile.vue --><scriptsetup>import{storeToRefs}from'pinia'import{useUserStore}from'@/stores/user'constuserStore=useUserStore()const{userInfo,loading,error}=storeToRefs(userStore)functionhandleLogout(){userStore.logout()}</script><template><divv-if="userInfo"><p>欢迎,{{ userInfo.name }}</p><button:disabled="loading"@click="handleLogout">退出</button></div><pv-if="error"class="error">{{ error }}</p></template>⬆ 返回目录
八、小结速查表
| 场景 | 推荐做法 | 避免 |
|---|---|---|
| 拆分 | 按业务领域拆 store | 一个 store 包所有状态 |
| 异步 | 统一在 actions 里,用 loading/error | 在组件里散落请求 |
| 解构 | storeToRefs拿 state/getter | 直接解构 store |
| 持久化 | pinia-plugin-persistedstate+ paths | 全量持久化大对象 |
| 防污染 | 用拷贝更新 state,getter 返回拷贝 | 直接改引用、getter 返回内部引用 |
⬆ 返回目录
🔍 系列模块导航
📝 状态管理与路由规范
一、《Vue3 Pinia 状态管理规范:状态拆分、Actions 写法、持久化实战,避坑状态污染|状态管理与路由规范篇》
二、《Vue3 Pinia 状态管理规范:何时用 Pinia 何时用本地状态|状态管理与路由规范篇》
三、《Vue Router 实战规范:path/name/meta 配置 + 动态 / 嵌套路由,统一团队标准|状态管理与路由规范篇》
四、《Vue3 + Vue Router + Pinia 路由守卫规范:beforeEach 应做 / 不应做,避死循环、防重复请求|状态管理与路由规范篇》
五、《Vue keep-alive 实战避坑:include/exclude + 路由 meta 标记,中后台路由缓存精准可控|状态管理与路由规范篇》
👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~
📚 系列总览
「前端规范实战系列」正在持续更新中,后续会整理一篇《前端规范实战系列全系列目录导航》,包含每篇文章简介 + 直达链接,方便大家按顺序、体系化学习。
更新中,敬请期待~
⬆ 返回目录
技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护。
哪怕每次只吃透一条规范,长期下来,差距会非常明显。
后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。
觉得有用欢迎点赞 + 收藏 + 关注,不错过每一篇实战内容。
我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~
