【Vue3+Pinia】中后台项目状态管理实战:从状态分层到选型判断,掌握全局/本地状态正确用法,告别滥用Pinia导致的维护混乱!
本文基于 Vue 3 + Pinia 编写,示例代码可直接在项目中运行。如有疑问,欢迎在评论区交流。

📑 文章目录
- 一、开篇:状态管理不是玄学,是「选型」
- [二、先搞清楚:Vue 里的「状态」分哪几类?](#二、先搞清楚:Vue 里的「状态」分哪几类?)
- 三、判断流程图:我该用哪种方式?
- 四、本地状态:最常用,也最容易忽略
- [4.1 完整示例:一个带本地状态的表单组件](#4.1 完整示例:一个带本地状态的表单组件)
- [五、跨组件共享:先考虑 props/emit 和 provide/inject](#五、跨组件共享:先考虑 props/emit 和 provide/inject)
- [5.1 父子之间:props + emit](#5.1 父子之间:props + emit)
- [5.2 层级多时:provide / inject](#5.2 层级多时:provide / inject)
- [六、什么时候该上 Pinia?](#六、什么时候该上 Pinia?)
- [6.1 安装与基础配置](#6.1 安装与基础配置)
- [6.2 定义 Store:用户 + 购物车](#6.2 定义 Store:用户 + 购物车)
- [6.3 在组件中使用](#6.3 在组件中使用)
- 七、踩坑:滥用全局状态
- [7.1 把「只在一个组件用的数据」放到 Pinia](#7.1 把「只在一个组件用的数据」放到 Pinia)
- [7.2 在 Store 里放 UI 状态](#7.2 在 Store 里放 UI 状态)
- [7.3 所有接口数据都塞进 Store](#7.3 所有接口数据都塞进 Store)
- 八、状态管理与路由的关系
- [8.1 路由参数 vs Store](#8.1 路由参数 vs Store)
- [8.2 示例:搜索结果页](#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 完整示例:一个带本地状态的表单组件
html
<template>
<div class="login-form">
<h3>登录</h3>
<form @submit.prevent="handleSubmit">
<div class="form-item">
<label>账号</label>
<input v-model="username" type="text" placeholder="请输入账号" />
<span v-if="errors.username" class="error">{{ errors.username }}</span>
</div>
<div class="form-item">
<label>密码</label>
<input v-model="password" type="password" placeholder="请输入密码" />
<span v-if="errors.password" class="error">{{ errors.password }}</span>
</div>
<button type="submit" :disabled="isSubmitting">登录</button>
</form>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
// ✅ 这些都是「本地状态」:只在这个组件内用
const username = ref('')
const password = ref('')
const isSubmitting = ref(false)
const errors = reactive({
username: '',
password: ''
})
function handleSubmit() {
// 清空之前的错误
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>
<style scoped>
.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
html
<!-- 父组件:App.vue -->
<template>
<div>
<p>购物车数量:{{ cartCount }}</p>
<ProductList :cart-count="cartCount" @add-to-cart="handleAddToCart" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import ProductList from './ProductList.vue'
// 父组件持有共享数据
const cartCount = ref(0)
function handleAddToCart() {
cartCount.value++
}
</script>
html
<!-- 子组件:ProductList.vue -->
<template>
<div>
<p>当前购物车:{{ cartCount }} 件</p>
<button @click="$emit('add-to-cart')">加入购物车</button>
</div>
</template>
<script setup>
defineProps(['cartCount'])
defineEmits(['add-to-cart'])
</script>
要点:父组件拥有数据,通过 props 下发、通过 emit 向上通知,适合 1~2 层父子关系。
[⬆ 返回目录](#⬆ 返回目录)
5.2 层级多时:provide / inject
当组件嵌套很深,一层层 props 会很麻烦,可以用 provide 往下传:
html
<!-- 祖先组件:App.vue -->
<script setup>
import { ref, provide } from 'vue'
const theme = ref('light') // 主题:light / dark
provide('theme', theme)
</script>
html
<!-- 任意层级子孙组件:DeepChild.vue -->
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
// 使用 theme.value 读取,或 theme.value = 'dark' 修改(需祖先暴露修改方法)
</script>
要点:适合「一处定义,多处使用」的配置类数据(主题、语言等),比层层 props 更简洁。
[⬆ 返回目录](#⬆ 返回目录)
六、什么时候该上 Pinia?
适合用 Pinia 的典型情况:
- 多个不相关组件(不是父子)需要同一份数据
- 跨路由共享数据(如用户信息、购物车)
- 状态逻辑较复杂,需要集中管理和复用
- 需要持久化(结合 localStorage 或接口)
下面是一个「用户信息 + 购物车」的 Pinia 示例。
6.1 安装与基础配置
bash
npm install pinia
js
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
[⬆ 返回目录](#⬆ 返回目录)
6.2 定义 Store:用户 + 购物车
js
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
const userInfo = ref(null)
const isLoggedIn = computed(() => !!userInfo.value)
function login(name, token) {
userInfo.value = { name, token }
localStorage.setItem('token', token)
}
function logout() {
userInfo.value = null
localStorage.removeItem('token')
}
return { userInfo, isLoggedIn, login, logout }
})
js
// stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
const items = ref([])
const totalCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
function addItem(product, quantity = 1) {
const exist = items.value.find(p => p.id === product.id)
if (exist) {
exist.quantity += quantity
} else {
items.value.push({ ...product, quantity })
}
}
function removeItem(productId) {
items.value = items.value.filter(p => p.id !== productId)
}
return { items, totalCount, addItem, removeItem }
})
[⬆ 返回目录](#⬆ 返回目录)
6.3 在组件中使用
html
<!-- 头部导航:显示用户和购物车数量 -->
<template>
<header>
<span v-if="userStore.isLoggedIn">{{ userStore.userInfo?.name }}</span>
<span v-else>未登录</span>
<span>购物车 ({{ cartStore.totalCount }})</span>
</header>
</template>
<script setup>
import { useUserStore } from '@/stores/user'
import { useCartStore } from '@/stores/cart'
const userStore = useUserStore()
const cartStore = useCartStore()
</script>
html
<!-- 商品列表页:加入购物车 -->
<template>
<div v-for="product in products" :key="product.id">
<span>{{ product.name }}</span>
<button @click="cartStore.addItem(product)">加入购物车</button>
</div>
</template>
<script setup>
import { useCartStore } from '@/stores/cart'
const cartStore = useCartStore()
const products = [/* ... */]
</script>
[⬆ 返回目录](#⬆ 返回目录)
七、踩坑:滥用全局状态
7.1 把「只在一个组件用的数据」放到 Pinia
js
// ❌ 不推荐:表单输入放到 store
export const useFormStore = defineStore('form', () => {
const username = ref('') // 只有登录页用,不需要全局
const password = ref('')
return { username, password }
})
后果:刷新会清空、难以复用组件、调试时要到处找。这类数据应放在组件内的 ref/reactive。
[⬆ 返回目录](#⬆ 返回目录)
7.2 在 Store 里放 UI 状态
js
// ❌ 不推荐
export const useUiStore = defineStore('ui', () => {
const isModalOpen = ref(false) // 某个弹窗的开闭
const currentTab = ref(0) // 某个 Tab 的索引
return { isModalOpen, currentTab }
})
弹窗、Tab 这类只和当前页面相关的 UI 状态,放在组件内部即可。
[⬆ 返回目录](#⬆ 返回目录)
7.3 所有接口数据都塞进 Store
js
// ❌ 不推荐
export const useDataStore = defineStore('data', () => {
const listData = ref([]) // 某个列表页的数据,别的页面根本不用
return { listData }
})
如果数据只被当前页面使用,用 ref 在页面组件里请求和保存即可,不必进 store。
[⬆ 返回目录](#⬆ 返回目录)
八、状态管理与路由的关系
8.1 路由参数 vs Store
- 路由参数(query、params) :适合「和 URL 强相关」的数据
- 例如:
/search?keyword=手机、/product/123 - 可收藏、可分享、刷新后仍存在
- 例如:
- Store :适合「和 URL 无关」的全局数据
- 例如:用户信息、购物车、主题
[⬆ 返回目录](#⬆ 返回目录)
8.2 示例:搜索结果页
html
<!-- 搜索结果页 -->
<template>
<div>
<p>搜索关键词:{{ keyword }}</p>
<div v-for="item in searchResults" :key="item.id">{{ item.name }}</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const keyword = ref(route.query.keyword || '')
const searchResults = ref([])
// 关键词来自 URL,变化时重新搜索
watch(() => route.query.keyword, (newKeyword) => {
keyword.value = newKeyword
fetchResults(newKeyword)
}, { immediate: true })
async function fetchResults(kw) {
const res = await fetch(`/api/search?keyword=${kw}`)
searchResults.value = await res.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,与你一起写规范、写优质代码,我们下篇干货见~