Vue3 Pinia 状态管理规范:何时用 Pinia 何时用本地状态|状态管理与路由规范篇

【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>

要点

  • usernamepasswordisSubmittingerrors 都不需要给别的组件用,放在组件内即可。
  • 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 的典型情况:

  1. 多个不相关组件(不是父子)需要同一份数据
  2. 跨路由共享数据(如用户信息、购物车)
  3. 状态逻辑较复杂,需要集中管理和复用
  4. 需要持久化(结合 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,与你一起写规范、写优质代码,我们下篇干货见~

相关推荐
ONE_SIX_MIX1 小时前
lancedb 表名 编解码 与 转译 python
开发语言·python
Moment1 小时前
TypeScript 要换芯了,6.0 竟是旧编译器的最后一舞
前端·javascript·github
Rabbit_QL1 小时前
【Claude Code 循环登录】浏览器显示成功,CLI 永远 Not logged in
开发语言
C++ 老炮儿的技术栈2 小时前
Qt 开发机器人客户端程序
c语言·开发语言·c++·windows·qt·机器人
Cg136269159742 小时前
Element-入门
前端
Maguyusi2 小时前
Debian13(trixie) 安装php8.5 php-fpm8.5
开发语言·php·lsky pro
马猴烧酒.2 小时前
【面试八股|计算机网络】计算机网络常见面试题详解笔记
java·开发语言·网络·笔记·计算机网络·算法·面试
萝卜白菜。2 小时前
TongWeb7.0配置tongweb-web.xml修改jsessionid名及其值的长度
xml·前端·word
同元软控2 小时前
同元“AI工程七步法”实践:把桌面CAD搬到Web
前端·人工智能