Vue3 Pinia 状态管理规范:何时用 Pinia 何时用本地状态|状态管理与路由规范篇
【Vue3+Pinia】中后台项目状态管理实战:从状态分层到选型判断,掌握全局/本地状态正确用法,告别滥用Pinia导致的维护混乱!
本文基于 Vue 3 + Pinia 编写,示例代码可直接在项目中运行。如有疑问,欢迎在评论区交流。
📑 文章目录
- 一、开篇:状态管理不是玄学,是「选型」
- 二、先搞清楚:Vue 里的「状态」分哪几类?
- 三、判断流程图:我该用哪种方式?
- 四、本地状态:最常用,也最容易忽略
- 4.1 完整示例:一个带本地状态的表单组件
- 五、跨组件共享:先考虑 props/emit 和 provide/inject
- 5.1 父子之间:props + emit
- 5.2 层级多时:provide / inject
- 六、什么时候该上 Pinia?
- 6.1 安装与基础配置
- 6.2 定义 Store:用户 + 购物车
- 6.3 在组件中使用
- 七、踩坑:滥用全局状态
- 7.1 把「只在一个组件用的数据」放到 Pinia
- 7.2 在 Store 里放 UI 状态
- 7.3 所有接口数据都塞进 Store
- 八、状态管理与路由的关系
- 8.1 路由参数 vs Store
- 8.2 示例:搜索结果页
- 九、快速对照表
- 十、总结
- 🔍 系列模块导航
同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。
(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)
很多前端开发者都会遇到一个瓶颈:
代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。
想写出干净、优雅、可维护的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验。
这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。
帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。
一、开篇:状态管理不是玄学,是「选型」
很多人一提到 Vue 状态管理,就会想到 Pinia / Vuex,很容易把「状态管理」理解成「一定要用全局 store」。
实际上,状态管理 = 决定「数据放哪儿」和「谁可以访问」,核心是先判断「这个数据适合放在哪一层」,再考虑用不用 Pinia。
本文帮你建立一套简单可操作的判断方式:什么时候用 Pinia,什么时候用本地状态,避免滥用全局状态,顺便理清和路由的关系。
⬆ 返回目录
二、先搞清楚:Vue 里的「状态」分哪几类?
在 Vue 里,数据大致可以分成三类:
| 类型 | 特点 | 典型场景 |
|---|---|---|
| 本地状态 | 只在一个组件内部使用,和别的组件无关 | 输入框内容、弹窗是否打开、某个开关 |
| 跨组件共享状态 | 多个组件需要读/写同一份数据 | 购物车数量、用户信息、主题 |
| 需要持久化/跨会话的状态 | 刷新后要保留,或需要在多页面之间保持 | 登录 token、用户偏好、语言设置 |
选型原则:能用本地状态就不用共享,能跨组件共享就优先考虑 props + emit 或 provide/inject,只有确实需要「多组件共享 + 复杂读写逻辑」时,才用 Pinia 这类全局 store。
⬆ 返回目录
三、判断流程图:我该用哪种方式?
可以用下面这个流程来快速决策:
你的数据需要被多个组件使用吗? │ ├─ 否 → 用【本地状态】(ref/reactive) ✓ │ └─ 是 → 组件是父子关系吗? │ ├─ 是 → 层级少?用 props + emit │ 层级多?用 provide/inject │ └─ 否(兄弟、跨路由等)→ 需要持久化/跨会话吗? │ ├─ 是 → 用 Pinia + localStorage(或后端) │ └─ 否 → 用 Pinia ✓下面按「本地状态 → 跨组件共享 → Pinia」的顺序,结合完整示例说明。
⬆ 返回目录
四、本地状态:最常用,也最容易忽略
什么时候用:数据只在一个组件内使用,不传给父、子、兄弟组件。
典型例子:表单输入、弹窗显示与否、Tab 当前选中项、本地临时计算结果。
4.1 完整示例:一个带本地状态的表单组件
<template><divclass="login-form"><h3>登录</h3><form@submit.prevent="handleSubmit"><divclass="form-item"><label>账号</label><inputv-model="username"type="text"placeholder="请输入账号"/><spanv-if="errors.username"class="error">{{ errors.username }}</span></div><divclass="form-item"><label>密码</label><inputv-model="password"type="password"placeholder="请输入密码"/><spanv-if="errors.password"class="error">{{ errors.password }}</span></div><buttontype="submit":disabled="isSubmitting">登录</button></form></div></template><scriptsetup>import{ref,reactive}from'vue'// ✅ 这些都是「本地状态」:只在这个组件内用constusername=ref('')constpassword=ref('')constisSubmitting=ref(false)consterrors=reactive({username:'',password:''})functionhandleSubmit(){// 清空之前的错误errors.username=''errors.password=''// 简单校验if(!username.value.trim()){errors.username='请输入账号'return}if(!password.value){errors.password='请输入密码'return}isSubmitting.value=true// 这里可以发请求...setTimeout(()=>{isSubmitting.value=false},1000)}</script><stylescoped>.error{color:red;font-size:12px;}.form-item{margin-bottom:16px;}</style>要点:
username、password、isSubmitting、errors都不需要给别的组件用,放在组件内即可。- 用
ref存简单值,用reactive存对象。 - 表单和校验逻辑都写在本组件里,不要放到 Pinia 或全局。
⬆ 返回目录
五、跨组件共享:先考虑 props/emit 和 provide/inject
什么时候用:多个组件需要同一份数据,且存在清晰的父子或祖孙关系。
5.1 父子之间:props + emit
<!-- 父组件:App.vue --><template><div><p>购物车数量:{{ cartCount }}</p><ProductList:cart-count="cartCount"@add-to-cart="handleAddToCart"/></div></template><scriptsetup>import{ref}from'vue'importProductListfrom'./ProductList.vue'// 父组件持有共享数据constcartCount=ref(0)functionhandleAddToCart(){cartCount.value++}</script><!-- 子组件:ProductList.vue --><template><div><p>当前购物车:{{ cartCount }} 件</p><button@click="$emit('add-to-cart')">加入购物车</button></div></template><scriptsetup>defineProps(['cartCount'])defineEmits(['add-to-cart'])</script>要点:父组件拥有数据,通过 props 下发、通过 emit 向上通知,适合 1~2 层父子关系。
⬆ 返回目录
5.2 层级多时:provide / inject
当组件嵌套很深,一层层 props 会很麻烦,可以用provide往下传:
<!-- 祖先组件:App.vue --><scriptsetup>import{ref,provide}from'vue'consttheme=ref('light')// 主题:light / darkprovide('theme',theme)</script><!-- 任意层级子孙组件:DeepChild.vue --><scriptsetup>import{inject}from'vue'consttheme=inject('theme')// 使用 theme.value 读取,或 theme.value = 'dark' 修改(需祖先暴露修改方法)</script>要点:适合「一处定义,多处使用」的配置类数据(主题、语言等),比层层 props 更简洁。
⬆ 返回目录
六、什么时候该上 Pinia?
适合用 Pinia 的典型情况:
- 多个不相关组件(不是父子)需要同一份数据
- 跨路由共享数据(如用户信息、购物车)
- 状态逻辑较复杂,需要集中管理和复用
- 需要持久化(结合 localStorage 或接口)
下面是一个「用户信息 + 购物车」的 Pinia 示例。
6.1 安装与基础配置
npminstallpinia// main.jsimport{createApp}from'vue'import{createPinia}from'pinia'importAppfrom'./App.vue'constapp=createApp(App)app.use(createPinia())app.mount('#app')⬆ 返回目录
6.2 定义 Store:用户 + 购物车
// stores/user.jsimport{defineStore}from'pinia'import{ref,computed}from'vue'exportconstuseUserStore=defineStore('user',()=>{constuserInfo=ref(null)constisLoggedIn=computed(()=>!!userInfo.value)functionlogin(name,token){userInfo.value={name,token}localStorage.setItem('token',token)}functionlogout(){userInfo.value=nulllocalStorage.removeItem('token')}return{userInfo,isLoggedIn,login,logout}})// stores/cart.jsimport{defineStore}from'pinia'import{ref,computed}from'vue'exportconstuseCartStore=defineStore('cart',()=>{constitems=ref([])consttotalCount=computed(()=>items.value.reduce((sum,item)=>sum+item.quantity,0))functionaddItem(product,quantity=1){constexist=items.value.find(p=>p.id===product.id)if(exist){exist.quantity+=quantity}else{items.value.push({...product,quantity})}}functionremoveItem(productId){items.value=items.value.filter(p=>p.id!==productId)}return{items,totalCount,addItem,removeItem}})⬆ 返回目录
6.3 在组件中使用
<!-- 头部导航:显示用户和购物车数量 --><template><header><spanv-if="userStore.isLoggedIn">{{ userStore.userInfo?.name }}</span><spanv-else>未登录</span><span>购物车 ({{ cartStore.totalCount }})</span></header></template><scriptsetup>import{useUserStore}from'@/stores/user'import{useCartStore}from'@/stores/cart'constuserStore=useUserStore()constcartStore=useCartStore()</script><!-- 商品列表页:加入购物车 --><template><divv-for="product in products":key="product.id"><span>{{ product.name }}</span><button@click="cartStore.addItem(product)">加入购物车</button></div></template><scriptsetup>import{useCartStore}from'@/stores/cart'constcartStore=useCartStore()constproducts=[/* ... */]</script>⬆ 返回目录
七、踩坑:滥用全局状态
7.1 把「只在一个组件用的数据」放到 Pinia
// ❌ 不推荐:表单输入放到 storeexportconstuseFormStore=defineStore('form',()=>{constusername=ref('')// 只有登录页用,不需要全局constpassword=ref('')return{username,password}})后果:刷新会清空、难以复用组件、调试时要到处找。这类数据应放在组件内的ref/reactive。
⬆ 返回目录
7.2 在 Store 里放 UI 状态
// ❌ 不推荐exportconstuseUiStore=defineStore('ui',()=>{constisModalOpen=ref(false)// 某个弹窗的开闭constcurrentTab=ref(0)// 某个 Tab 的索引return{isModalOpen,currentTab}})弹窗、Tab 这类只和当前页面相关的 UI 状态,放在组件内部即可。
⬆ 返回目录
7.3 所有接口数据都塞进 Store
// ❌ 不推荐exportconstuseDataStore=defineStore('data',()=>{constlistData=ref([])// 某个列表页的数据,别的页面根本不用return{listData}})如果数据只被当前页面使用,用ref在页面组件里请求和保存即可,不必进 store。
⬆ 返回目录
八、状态管理与路由的关系
8.1 路由参数 vs Store
- 路由参数(query、params):适合「和 URL 强相关」的数据
- 例如:
/search?keyword=手机、/product/123 - 可收藏、可分享、刷新后仍存在
- 例如:
- Store:适合「和 URL 无关」的全局数据
- 例如:用户信息、购物车、主题
⬆ 返回目录
8.2 示例:搜索结果页
<!-- 搜索结果页 --><template><div><p>搜索关键词:{{ keyword }}</p><divv-for="item in searchResults":key="item.id">{{ item.name }}</div></div></template><scriptsetup>import{ref,watch}from'vue'import{useRoute}from'vue-router'constroute=useRoute()constkeyword=ref(route.query.keyword||'')constsearchResults=ref([])// 关键词来自 URL,变化时重新搜索watch(()=>route.query.keyword,(newKeyword)=>{keyword.value=newKeywordfetchResults(newKeyword)},{immediate:true})asyncfunctionfetchResults(kw){constres=awaitfetch(`/api/search?keyword=${kw}`)searchResults.value=awaitres.json()}</script>这里keyword来自路由,searchResults是页面内部状态,都不需要放进 Pinia。
⬆ 返回目录
九、快速对照表
| 场景 | 推荐方式 | 不推荐 |
|---|---|---|
| 表单输入、弹窗开关、Tab 索引 | 组件内 ref/reactive | 放进 Pinia |
| 父子 1~2 层共享 | props + emit | 直接用 Pinia |
| 深层嵌套共享 | provide/inject | 层层 props |
| 跨组件、跨路由共享 | Pinia | 复杂的事件总线 |
| 用户信息、购物车等全局数据 | Pinia | 多层 props |
| 需要持久化(token、偏好) | Pinia + localStorage | 只放内存 |
| URL 相关(搜索关键词、详情 id) | 路由 query/params | 放 Pinia |
⬆ 返回目录
十、总结
- 本地状态:能放组件内就放组件内,简单、好维护。
- 跨组件共享:先看是不是父子,再决定 props/emit 或 provide/inject。
- Pinia:只有在「多组件、跨路由、复杂逻辑或需持久化」时使用,避免把一切数据都塞进 store。
记住一句话:先问「这数据真的需要被多个不相关的组件用吗?」——如果不需要,就别上 Pinia。
⬆ 返回目录
🔍 系列模块导航
📝 状态管理与路由规范
一、《Vue3 Pinia 状态管理规范:状态拆分、Actions 写法、持久化实战,避坑状态污染|状态管理与路由规范篇》
二、《Vue3 Pinia 状态管理规范:何时用 Pinia 何时用本地状态|状态管理与路由规范篇》
三、《Vue Router 实战规范:path/name/meta 配置 + 动态 / 嵌套路由,统一团队标准|状态管理与路由规范篇》
四、《Vue3 + Vue Router + Pinia 路由守卫规范:beforeEach 应做 / 不应做,避死循环、防重复请求|状态管理与路由规范篇》
五、《Vue keep-alive 实战避坑:include/exclude + 路由 meta 标记,中后台路由缓存精准可控|状态管理与路由规范篇》
👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~
📚 系列总览
「前端规范实战系列」正在持续更新中,后续会整理一篇《前端规范实战系列全系列目录导航》,包含每篇文章简介 + 直达链接,方便大家按顺序、体系化学习。
更新中,敬请期待~
⬆ 返回目录
技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护。
哪怕每次只吃透一条规范,长期下来,差距会非常明显。
后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。
觉得有用欢迎点赞 + 收藏 + 关注,不错过每一篇实战内容。
我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~
