Vue状态管理扫盲篇:Vuex 到 Pinia | 为什么大家都在迁移?核心用法对比

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零 ,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱"面向搜索引擎写代码"的尴尬。

一、先搞清楚:Vuex 和 Pinia 到底是啥?

1.1 一句话认识它们

  • Vuex:Vue 2 时代的官方状态管理库,通过集中式存储管理应用的全部状态。
  • Pinia:Vue 3 时代的推荐状态管理库,作者和 Vue 核心团队同一人,被当作 Vuex 5 的正式版。

1.2 为什么大家纷纷从 Vuex 迁到 Pinia?

对比维度 Vuex Pinia
心智负担 概念多(state/mutations/actions/getters) 概念简单(一个 store,其余都是普通函数)
TypeScript 类型支持一般 原生支持好
模块化 需自己设计 modules 天然多 store,无嵌套
Composition API 需配合 useStore 天然适配 setup
打包体积 相对大 更小
官方推荐 Vue 2 主力,Vue 3 仍可用 Vue 3 推荐首选

一句话:Pinia 更简单、更贴近 Vue 3,写起来更像普通 JS。

二、Vuex 核心用法:四大金刚

2.1 整体结构回顾

Vuex 的数据流可以记成:View → Actions → Mutations → State → View

  • state:唯一数据源
  • getters:可理解为"计算属性"
  • mutations:唯一能改 state 的地方(必须同步)
  • actions:可以异步,内部再 commit mutations

2.2 完整示例:用户购物车

js 复制代码
// store/index.js (Vuex)
import { createStore } from 'vuex'

export default createStore({
  state: {
    cartItems: [],      // 购物车商品
    user: null          // 当前用户
  },
  
  getters: {
    // 购物车商品数量
    cartCount(state) {
      return state.cartItems.reduce((sum, item) => sum + item.quantity, 0)
    },
    // 购物车总价
    cartTotal(state) {
      return state.cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
    }
  },
  
  mutations: {
    addToCart(state, product) {
      const exist = state.cartItems.find(item => item.id === product.id)
      if (exist) {
        exist.quantity++
      } else {
        state.cartItems.push({ ...product, quantity: 1 })
      }
    },
    setUser(state, user) {
      state.user = user
    }
  },
  
  actions: {
    // 异步:模拟登录
    async login({ commit }, credentials) {
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      })
      const user = await res.json()
      commit('setUser', user)
      return user
    }
  }
})

2.3 在组件里怎么用?

html 复制代码
<template>
  <div>
    <p>购物车:{{ cartCount }} 件,总价:{{ cartTotal }}</p>
    <button @click="addProduct">加入购物车</button>
  </div>
</template>

<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'

const store = useStore()

// 读 state / getters
const cartCount = computed(() => store.getters.cartCount)
const cartTotal = computed(() => store.getters.cartTotal)

// 触发 mutation(同步)
function addProduct() {
  store.commit('addToCart', { id: 1, name: '商品A', price: 99 })
}

// 触发 action(异步)
async function handleLogin() {
  await store.dispatch('login', { username: 'admin', password: '123' })
}
</script>

2.4 Vuex 容易踩的坑

  1. 忘记 mutations :直接 state.xxx = xxx 在严格模式下会报错,只能通过 mutation 修改。
  2. 在 mutation 里写异步:理论上必须同步,写异步会导致难以追踪、调试困难。
  3. 命名冲突:多个 module 时,getter/mutation/action 可能重名,需要命名空间。

三、Pinia 核心用法:一个 Store 搞定

3.1 设计思路

Pinia 不再区分 mutations 和 actions,只有:

  • state:数据
  • getters:计算属性
  • actions:既可以同步也可以异步,直接改 state

3.2 完整示例:同一需求用 Pinia 写

js 复制代码
// stores/cart.js (Pinia - Options 风格)
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    cartItems: [],
    user: null
  }),
  
  getters: {
    cartCount(state) {
      return state.cartItems.reduce((sum, item) => sum + item.quantity, 0)
    },
    cartTotal(state) {
      return state.cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0)
    }
  },
  
  actions: {
    addToCart(product) {
      const exist = this.cartItems.find(item => item.id === product.id)
      if (exist) {
        exist.quantity++
      } else {
        this.cartItems.push({ ...product, quantity: 1 })
      }
    },
    async login(credentials) {
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify(credentials)
      })
      const user = await res.json()
      this.user = user  // 直接改 state,不需要 mutation!
      return user
    }
  }
})

3.3 Setup Store 风格(更贴近 Composition API)

js 复制代码
// stores/cart.js (Pinia - Setup Store 风格)
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCartStore = defineStore('cart', () => {
  // state:用 ref/reactive
  const cartItems = ref([])
  const user = ref(null)
  
  // getters:用 computed
  const cartCount = computed(() => 
    cartItems.value.reduce((sum, item) => sum + item.quantity, 0)
  )
  const cartTotal = computed(() => 
    cartItems.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
  )
  
  // actions:普通函数
  function addToCart(product) {
    const exist = cartItems.value.find(item => item.id === product.id)
    if (exist) {
      exist.quantity++
    } else {
      cartItems.value.push({ ...product, quantity: 1 })
    }
  }
  
  async function login(credentials) {
    const res = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })
    const data = await res.json()
    user.value = data
    return data
  }
  
  // 必须 return 出去,组件才能用
  return {
    cartItems,
    user,
    cartCount,
    cartTotal,
    addToCart,
    login
  }
})

3.4 在组件里怎么用?

html 复制代码
<template>
  <div>
    <p>购物车:{{ cartStore.cartCount }} 件,总价:{{ cartStore.cartTotal }}</p>
    <button @click="cartStore.addToCart({ id: 1, name: '商品A', price: 99 })">
      加入购物车
    </button>
  </div>
</template>

<script setup>
import { useCartStore } from '@/stores/cart'

const cartStore = useCartStore()

// 用 storeToRefs 解构,保持响应式(getters 和 state 需要)
// 如果直接解构 const { cartCount } = cartStore,会丢失响应式!
import { storeToRefs } from 'pinia'
const { cartCount, cartTotal } = storeToRefs(cartStore)

// actions 直接解构没问题
const { addToCart, login } = cartStore
</script>

四、核心概念对照表

概念 Vuex Pinia (Options) Pinia (Setup)
定义数据 state: { } state: () => ({ }) ref() / reactive()
计算属性 getters: { } getters: { } computed()
修改数据(同步) mutations actions 里直接改 this.xxx 直接改 ref.value
修改数据(异步) actions + commit actions 里直接改 在函数里直接改
组件调用 store.commit() / store.dispatch() store.xxx() store.xxx()

五、迁移时的常见坑

5.1 解构 store 丢响应式

javascript 复制代码
// ❌ 错误:直接解构会丢失响应式
const { cartCount } = useCartStore()

// ✅ 正确:用 storeToRefs
const { cartCount } = storeToRefs(useCartStore())

5.2 Setup Store 忘记 return

javascript 复制代码
// ❌ 错误:没 return,组件拿不到
export const useCartStore = defineStore('cart', () => {
  const count = ref(0)
  function add() { count.value++ }
  // 忘记 return!
})

// ✅ 正确
return { count, add }

5.3 多个 store 之间互相调用

关于这个坑的问题,我在初学的时候看到这个概念其实并不理解。所以我决定在这里展开的说一说。

在 Pinia 中,多个 store 互相调用是开发中很常见的需求(比如「订单 store」需要用到「购物车 store」的商品数据),但新手很容易因为调用时机不对写出"死锁代码"。

本节会用「大白话+实战代码」,教你安全调用的核心规则避坑要点 ,以及新手最易踩的雷,保证看完就能上手。

一、核心结论(先记重点)

多个 store 之间可以互相调用,但必须遵守一个黄金法则:

只存引用,延迟使用 :在 store 的 setup 函数顶层,只能获取另一个 store 的「实例引用」;读取数据、调用方法 的操作,必须放到函数(action)内部执行。

❌ 绝对禁止:在 setup 顶层直接读取另一个 store 的数据(会触发"互相等待"的死锁)。

二、安全写法(直接抄作业)

以「订单 store(order.js)」调用「购物车 store(cart.js)」为例,实现下单时获取购物车商品的功能。

步骤1:创建被调用的购物车 store(cart.js)
js 复制代码
// stores/cart.js
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useCartStore = defineStore('cart', () => {
  // 购物车商品列表(state)
  const cartItems = ref([
    { id: 1, name: '新手小白入门教程', price: 99 },
    { id: 2, name: 'Pinia 避坑手册', price: 59 }
  ])

  // 简单的方法(action),方便后续被调用
  function clearCart() {
    cartItems.value = []
  }

  return { cartItems, clearCart }
})
步骤2:创建调用方订单 store(order.js)
js 复制代码
// stores/order.js
import { defineStore } from 'pinia'
// 1. 导入购物车 store 的创建函数
import { useCartStore } from './cart'

export const useOrderStore = defineStore('order', () => {
  // ✅ 安全操作:在 setup 顶层只获取 cartStore 的「实例引用」
  // 此时只是"记下来购物车的地址",不会读取数据、不会触发死锁
  const cartStore = useCartStore() 

  // 2. 核心:把"使用 cartStore"的逻辑,放到 action 函数内部
  function checkout() {
    // 🎯 延迟使用:只有调用 checkout 时,才会真正读取 cartStore 的数据
    // 此时两个 store 都已初始化完成,数据可正常获取
    console.log('下单商品:', cartStore.cartItems)
    
    // 也可以调用另一个 store 的方法
    if (cartStore.cartItems.length > 0) {
      console.log('下单成功,清空购物车!')
      cartStore.clearCart()
    } else {
      console.log('购物车为空,无法下单!')
    }
  }

  return { checkout }
})
步骤3:在组件中使用(验证效果)
html 复制代码
<template>
  <button @click="handleCheckout">点击下单</button>
</template>

<script setup>
// 导入订单 store
import { useOrderStore } from '@/stores/order'

const orderStore = useOrderStore()

// 点击按钮触发下单逻辑
const handleCheckout = () => {
  orderStore.checkout()
}
</script>

点击按钮后,控制台会输出:

Plain 复制代码
下单商品: [{ id: 1, ... }, { id: 2, ... }]
下单成功,清空购物车!

三、为什么要这样写?(大白话讲透"死锁")

新手最疑惑的是:为什么不能在 setup 顶层直接读数据? 我们用"两个人出门"的例子,讲透背后的逻辑:

1. 安全写法的执行流程(无死锁)

就像两个人(orderStore 和 cartStore)先各自出门(完成初始化),再互相帮忙:

  1. 组件调用 useOrderStore() → orderStore 开始初始化:只做了一件事------"记下 cartStore 的地址"(const cartStore = useCartStore()),自己先完成初始化。

  2. 后续调用 checkout() 时:orderStore 带着"地址"去找 cartStore,此时 cartStore 早就初始化好了,能顺利拿到商品数据。

2. 新手踩坑写法(触发死锁)

如果在 setup 顶层直接读数据,就变成了两个人互相卡条件

js 复制代码
// ❌ 错误示例:order.js(千万别这么写!)
export const useOrderStore = defineStore('order', () => {
  const cartStore = useCartStore()
  // ❌ 致命错误:setup 顶层直接读取 cartStore 的数据
  const goods = cartStore.cartItems 

  function checkout() {
    console.log(goods)
  }

  return { checkout }
})

此时的执行流程就会"僵持住":

  1. orderStore 初始化时,要求"先拿到 cartStore 的商品数据,才能完成初始化"。

  2. 于是去调用 useCartStore(),让 cartStore 初始化。

  3. 如果 cartStore 也在顶层读 orderStore 的数据,就会变成:order 等 cart 给数据,cart 等 order 给数据,俩人都卡着不动 → 代码报错(死锁)。

四、进阶:两个 store 互相调用(依然安全)

如果购物车 store 也需要调用订单 store 的数据,只要遵守「函数内使用」的规则,完全没问题!

补全 cart.js,新增"查看订单状态"的方法:

js 复制代码
// stores/cart.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
// 导入订单 store
import { useOrderStore } from './order'

export const useCartStore = defineStore('cart', () => {
  const cartItems = ref([{ id: 1, name: '新手小白入门教程', price: 99 }])
  
  // ✅ 安全:在函数内调用订单 store
  function checkOrderStatus() {
    const orderStore = useOrderStore()
    // 假设 orderStore 有一个 orderStatus 状态
    console.log('当前订单状态:', orderStore.orderStatus)
  }

  function clearCart() {
    cartItems.value = []
  }

  return { cartItems, clearCart, checkOrderStatus }
})
js 复制代码
// stores/order.js 补充 orderStatus 状态
export const useOrderStore = defineStore('order', () => {
  const cartStore = useCartStore()
  // 新增订单状态
  const orderStatus = ref('未支付')

  function checkout() {
    console.log('下单商品:', cartStore.cartItems)
    orderStatus.value = '已支付'
  }

  return { checkout, orderStatus }
})

此时两个 store 互相调用,但因为所有"使用对方"的逻辑都在函数内,初始化阶段互不干扰,完全不会死锁!

五、汇总一下

  1. defineStore同步函数 ,其 setup 回调不能加 async(异步逻辑只能写在 action 里)。

  2. ✅ 跨 store 调用的核心:setup 顶层只存引用,函数内部才使用

  3. ❌ 禁止在 setup 顶层直接读取另一个 store 的 state/getters(必触发死锁)。

  4. ❌ 禁止在 setup 顶层使用 await(既不支持,也会导致初始化异常)。

5.4 Pinia 需要先挂载

javascript 复制代码
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia())  // 必须在 createApp 之后、mount 之前
app.mount('#app')

六、日常开发该怎么选?

  • 新项目(Vue 3):优先用 Pinia,尤其 Setup Store 风格,和 Composition API 很契合。
  • 老项目(Vue 2 + Vuex):如果项目稳定、迁移成本高,可以先不急着迁;要升级 Vue 3 时,顺带迁到 Pinia 更合适。
  • 团队习惯:如果团队已经统一用 Vuex 且运转良好,不必为了"新"而强行迁移,关键是统一和维护成本。

七、总结

维度 Vuex Pinia
概念数量 4 个(state/getters/mutations/actions) 3 个(state/getters/actions)
改数据方式 只能通过 mutation(同步) actions 直接改(同步/异步都可)
风格 偏"流程化" 更接近普通函数、Composition API
学习成本 中等 较低

一句话:Pinia 用更少的概念、更直接的方式完成同样的状态管理,而且和 Vue 3 配合更好。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

相关推荐
张拭心3 小时前
Android 17 来了!新特性介绍与适配建议
android·前端
徐小夕3 小时前
pxcharts-vue:一款专为 Vue3 打造的开源多维表格解决方案
前端·vue.js·github
Hilaku3 小时前
我会如何考核一个在简历里大谈 AI 提效的高级前端?
前端·javascript·面试
青青家的小灰灰3 小时前
React 反模式(Anti-Patterns)排查手册:从性能杀手到逻辑陷阱
前端·javascript·react.js
青青家的小灰灰3 小时前
告别 Prop Drilling:Context API 的陷阱、Reducer 模式与原子化状态库原理
前端·javascript·react.js
叶智辽3 小时前
【Three.js后期处理】如何让你的场景拥有电影级调色
前端·three.js
前端付豪3 小时前
Nest 项目小实践之前端注册登陆
前端·node.js·nestjs
wuhen_n3 小时前
Suspense:异步组件加载机制
前端·javascript·vue.js
大雨还洅下3 小时前
前端JS: ES6新特性
前端