今年年初接手维护一个 Vue 3 中后台项目时,我做的第一件事不是优化业务代码,而是把项目里那股 Vuex 老味道擦掉,整体换成了 Pinia 状态管理。说实话,当时团队里还有同事觉得我是在折腾——Vuex 用得好好的,干嘛要换?但等项目从 20 多个 Vuex modules 的 store 重构完成、TypeScript 全量跑通之后,反对的声音基本就消失了。这篇文章不是拿 Pinia 做概念科普,而是我把两个真实项目从 Vuex 迁移到 Pinia 的全过程记录,包括核心 API 怎么用、组合式 store 和选项式 store 怎么选、还有我踩过的几个坑。适合已经会用 Vue 3 但还没正式用过 Pinia 的朋友,以及正在纠结要不要迁移的团队参考。
1. 为什么我在项目里把 Vuex 换成了 Pinia
1.1 Vuex 的槽点不是 API 不够,是心智负担太重
先说一个很现实的问题:Vuex 在 Vue 2 时代确实是唯一正规军,但它的设计目标包含了大量"为了大型应用准备的防御性机制"。最典型的就是 mutation 机制。Vue 官方当年为了让状态变更可追踪,强制要求所有修改必须通过 mutation 提交,于是你写一个最简单的登录态切换,得拆成 action 里发异步请求、commit 一个 mutation、mutation 里改 state、getters 里再包一层派生数据。四个文件来回跳,代码量翻倍,实际做的事情就是给token赋一次值。
更难受的是 modules 的命名空间。项目一复杂,store 目录下全是modules/user.js、modules/cart.js,然后组件里写this.$store.dispatch('user/login')、mapGetters('cart/totalPrice')。字符串路径一旦写错,报错信息又臭又长,排查全靠肉眼。TypeScript 配合更是灾难,this.$store.state默认是个超级宽泛的类型,想拿精确类型得自己写一堆Module包装和类型断言。
这不是说 Vuex 不能用,很多老项目跑得好好的。但对于新项目来说,这些成本其实是可以直接砍掉的。
1.2 Pinia 设计的三个关键取舍
Pinia 最早是 Vue 生态圈里一个实验项目,作者是 Evan You 团队成员 Eduardo San Martin Morote,后来被官方收纳为 Vue 3 的默认状态管理方案。它没有延续 Vuex 的老路子,而是做了三个非常果断的设计决策,恰好打在我最痛的几个点上。
第一个决策是砍掉 mutation。Pinia 里没有 mutation 的概念,action 直接改 state。状态可追踪这件事靠 Devtools 和$patch来做,而不是靠强制写冗余代码。我下面的代码里你会看到,改状态真的就是一个函数的事。
第二个决策是 store 扁平化。Pinia 不搞嵌套 modules,每个 store 通过defineStore独立声明,store 之间需要组合就直接在 action 里调用对方的 store,本质上是组合式编程。这样目录结构非常简单,一个文件一个 store,也没有 namespaced 的字符串前缀。
第三个决策是 TypeScript 优先。Pinia 的 API 在设计时就是围绕类型推导展开的,state、getters、actions 的类型基本不需要手动标注,IDE 里直接有完整的代码提示和类型检查。这正是 Vuex 最弱的地方。
我用一个表格概括两者最直接的使用差异:
| 对比项 | Vuex | Pinia |
|---|---|---|
| 状态修改 | mutation 提交,action 调 mutation | action 直接操作 state |
| 模块化 | 嵌套 modules + namespaced 字符串 | 独立 defineStore,扁平结构 |
| TypeScript | 需要大量类型包装和断言 | 类型天然推导,开箱即用 |
| 组件中使用 | mapState / mapGetters / mapActions 辅助函数 | 直接调用 useStore,storeToRefs 解构 |
| 调试体验 | Devtools 一般,命名空间混乱 | Devtools 插件直观,store 维度清晰 |
2. Pinia 核心三件套:state、getters、actions 的配合套路
2.1 defineStore 与最简单的计数器 store
先看一个最小可运行的例子。Pinia 的核心入口就一个defineStore,第一个参数是 store 的唯一 id,第二个参数可以是选项式对象,也可以是组合式函数。为了跟 Vuex 对比,我先用选项式写法:
// stores/counter.js import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', { state: () => ({ count: 0, name: 'Eduardo' }), getters: { doubleCount: (state) => state.count * 2 }, actions: { increment() { this.count++ } } })在组件里用的时候,先创建一个 store 实例,然后直接访问 state 和 action:
<script setup> import { useCounterStore } from '@/stores/counter' const counterStore = useCounterStore() // 直接读 state console.log(counterStore.count) // 调用 action counterStore.increment() // 也可以直接赋值,这就是 Pinia 的风格:没有 mutation 约束 counterStore.count = 10 </script>注意这里的关键点:useCounterStore()必须在组件调用setup之后执行,因为 Pinia 需要拿到当前组件的 app 上下文。而 store 实例本身是reactive对象,所以读取counterStore.count天然具有响应性。
2.2 getters 不是简单的计算属性,它还承担了"派生数据集中地"的职责
getters 在 Pinia 里对应 Vue 的 computed。它接收两个可选参数,可以拿到 state,也可以通过this访问整个 store 实例,所以 getters 之间可以互相调用:
export const useCounterStore = defineStore('counter', { state: () => ({ count: 0 }), getters: { // 用 state 参数,适合纯函数风格的派生 doubleCount: (state) => state.count * 2, // 用 this 访问 store 实例,可以调用其他 getter doublePlusOne() { return this.doubleCount + 1 }, // 也可以在 getter 里返回一个函数,用于带参数的派生 multiplyBy: (state) => (factor) => state.count * factor } })第三个用法经常被忽略,但实战非常有用。比如列表页的过滤条件需要传入不同关键词,如果你写成普通 getter,每次都要为不同关键词定义一个新 getter,很蠢。返回函数的 getter 能让你在模板里直接写store.filteredList('active'),并且它内部依然是基于响应式 state 的,state 变化时函数的返回值也会更新。
2.3 actions 为什么不需要 mutation:本质上是"自由的函数" + 响应式直接赋值
Vuex 里 action 不能直接改 state,必须 commit mutation。Pinia 直接把这一层废掉了,action 就是一个普通函数,内部的this指向整个 store 实例。这意味着你可以直接写数值、直接 push 数组、直接改对象属性。配合异步操作,action 的价值就真正凸显出来了:
export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: null, loginLoading: false }), actions: { async login(payload) { this.loginLoading = true try { const { data } = await request.post('/auth/login', payload) this.token = data.token this.userInfo = data.userInfo // 持久化到 localStorage,刷新恢复 localStorage.setItem('token', data.token) } finally { this.loginLoading = false } }, logout() { this.token = '' this.userInfo = null localStorage.removeItem('token') } } })对比 Vuex 的写法,这里少了commit('SET_TOKEN', data.token)、少了SET_USER_INFO、SET_LOADING这几个 mutation 函数,代码量直接少一半。而且异步函数直接写进 action 里,try/finally控制 loading 状态也比之前清晰得多。
组件里调用 action 也很直观:
const userStore = useUserStore() await userStore.login({ username, password })有心的读者会发现,action 的this不太符合 arrow function 的习惯。是的,如果 action 写成箭头函数,this就会丢失 store 实例的绑定,所以我建议 action 一律用普通函数语法,getters 里需要访问this的地方也用普通函数。
2.4 storeToRefs:解构时保留响应性的唯一正规姿势
这是新手最容易踩的坑。如果你在组件里这样写:
const { count, increment } = useCounterStore()你会发现count变成了一个普通值,它不再响应式了。原因是 Pinia 的 store 实例本质是一个reactive对象,解构会剥夺响应式代理。解决方法是storeToRefs:
import { storeToRefs } from 'pinia' const { count, doubleCount } = storeToRefs(useCounterStore()) const { increment } = useCounterStore()注意storeToRefs只能解构 state 和 getters,actions 本身就是普通函数,不需要也不应该包一层 ref,所以 actions 直接通过 store 实例解构即可。count拿到的是一个 ref,在模板里用的时候会自动解包,但在 JS 里操作要用count.value。
3. 实战迁移:把一个登录态模块从 Vuex 搬进 Pinia
3.1 原始 Vuex 代码长什么样
我拿当时项目里最典型的用户模块举例。原始代码结构是这样的:
// store/modules/user.js export default { namespaced: true, state: () => ({ token: localStorage.getItem('token') || '', userInfo: null, permissions: [] }), getters: { isLoggedIn: (state) => !!state.token, hasPermission: (state) => (permission) => state.permissions.includes(permission) }, mutations: { SET_TOKEN(state, token) { state.token = token }, SET_USER_INFO(state, userInfo) { state.userInfo = userInfo }, SET_PERMISSIONS(state, permissions) { state.permissions = permissions } }, actions: { async login({ commit }, payload) { const { data } = await request.post('/auth/login', payload) commit('SET_TOKEN', data.token) commit('SET_USER_INFO', data.userInfo) commit('SET_PERMISSIONS', data.permissions) localStorage.setItem('token', data.token) return data }, async fetchUserInfo({ commit }) { const { data } = await request.get('/auth/me') commit('SET_USER_INFO', data) return data }, logout({ commit }) { commit('SET_TOKEN', '') commit('SET_USER_INFO', null) commit('SET_PERMISSIONS', []) localStorage.removeItem('token') } } }组件里调用是这样:
this.$store.dispatch('user/login', payload) this.$store.getters['user/isLoggedIn'] this.$store.commit('user/SET_TOKEN', token)这段代码的毛病已经很明显了:每新增一个字段,要动四个地方,字符串路径user/isLoggedIn安全性极低,写着写着就容易拼错而不自知。
3.2 迁移后的 Pinia store
搬进 Pinia 之后,逻辑完全变了:
// stores/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: null, permissions: [] }), getters: { isLoggedIn: (state) => !!state.token, hasPermission: (state) => (permission) => state.permissions.includes(permission) }, actions: { async login(payload) { const { data } = await request.post('/auth/login', payload) this.token = data.token this.userInfo = data.userInfo this.permissions = data.permissions localStorage.setItem('token', data.token) return data }, async fetchUserInfo() { const { data } = await request.get('/auth/me') this.userInfo = data return data }, logout() { this.token = '' this.userInfo = null this.permissions = [] localStorage.removeItem('token') } } })组件里的调用方式变成了:
import { useUserStore } from '@/stores/user' const userStore = useUserStore() await userStore.login(payload) // 读 getters console.log(userStore.isLoggedIn) // 带参数 getter console.log(userStore.hasPermission('admin:edit'))对比下来你会发现,除了名字变了一下,整体心智模型从"对象 + commit"变成了"普通对象 + 方法调用",这几乎就是普通人直觉里状态管理应该有的样子。
3.3 迁移过程中需要同步处理的目录和 main.js
迁移不光是改 store 文件本身,还有两个配套动作。一个是创建stores目录并集中导出,另一个是调整 main.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')我建议新建目录src/stores/,内部按业务域拆文件:user.js、cart.js、order.js。公共类型如果多,可以放types.js或者直接在 store 文件里定义再导出。Pinia 没有 modules 的概念,所以不要再用一个index.js包裹所有模块,直接每个文件一个 store 就行。
另外要注意,创建 Pinia 实例createPinia()后要调用app.use()注册,组件里才能正常使用useStore。如果你在还没app.use(pinia)的时候就去调useStore,会得到一个报错:getActivePinia was called with no active Pinia。这个我后面会专门讲。
3.4 跨 store 调用:比 Vuex 的 rootGetters 更好维护的组合方式
Vuex 里跨模块读取状态,用的是rootGetters['user/isLoggedIn']这样的路径字符串,一点类型保障都没有。Pinia 的做法是直接在 action 里 import 另一个 store:
// stores/cart.js import { defineStore } from 'pinia' import { useUserStore } from './user' export const useCartStore = defineStore('cart', { state: () => ({ items: [] }), getters: { totalPrice: (state) => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0), checkoutDisabled() { // 在 getter 里也可以调用其他 store const userStore = useUserStore() return this.totalPrice <= 0 || !userStore.isLoggedIn } }, actions: { async checkout() { const userStore = useUserStore() if (!userStore.isLoggedIn) { throw new Error('请先登录') } // 业务逻辑... } } })这种写法的好处是:依赖关系是显式的,你在文件顶部就能看到useUserStore这个依赖;类型推导也完全正常;没有字符串魔法。唯一要注意的是别形成循环依赖,比如 A store 的 action 调 B store,B store 的 action 又调 A store。这种情况一般通过把公共状态抽到第三个 store 来规避。
4. 组合式 store 与选项式 store,我最终怎么选
4.1 两种写法的完整对比
Pinia 从 2.0 开始支持了类似 Composition API 的 setup store 写法。defineStore的第二个参数传一个函数,函数内部用ref、computed、function来定义状态、派生数据和操作,最后 return 出去:
// stores/user.js 组合式写法 import { ref, computed } from 'vue' import { defineStore } from 'pinia' export const useUserStore = defineStore('user', () => { // state const token = ref(localStorage.getItem('token') || '') const userInfo = ref(null) const permissions = ref([]) // getters const isLoggedIn = computed(() => !!token.value) const hasPermission = computed(() => (permission) => permissions.value.includes(permission)) // actions async function login(payload) { const { data } = await request.post('/auth/login', payload) token.value = data.token userInfo.value = data.userInfo permissions.value = data.permissions localStorage.setItem('token', data.token) return data } async function fetchUserInfo() { const { data } = await request.get('/auth/me') userInfo.value = data return data } function logout() { token.value = '' userInfo.value = null permissions.value = [] localStorage.removeItem('token') } return { token, userInfo, permissions, isLoggedIn, hasPermission, login, fetchUserInfo, logout } })这个写法跟选项式最核心的区别是:状态必须自己用ref包裹,getters 用computed,函数就是 actions。看起来代码量差不多,但它有一个选项式做不到的优势——可以在 store 内部使用任何组合式函数,比如useStorage、useDebounceFn,甚至可以定义临时变量而不需要暴露出去。
4.2 我在实际项目中是怎么分工的
我的经验是:当一个 store 主要是"数据容器 + 简单的 CRUD 操作"时,用选项式;当 store 有比较复杂的业务逻辑、需要组合多个来源的数据、或者要复用其他 composables 时,用组合式。
比如权限 store、用户 store 这种结构相对固定的,选项式一眼能看全,团队成员上手快。而类似"订单流程"这种 store,里面有表单状态、步骤状态、接口调用、倒计时、错误处理一堆逻辑,组合式就能把所有逻辑按功能模块归拢得更清晰,而不是被 state/getters/actions 三段式解剖开。
4.3 组合式 store 的 $reset 问题
选组合式之前必须知道一个坑:选项式 store 自带$reset()方法,可以直接把 state 恢复为初始值;但组合式 store 因为 state 是动态 return 的,框架无法保存一份初始快照,所以$reset方法是不可用的。
我当时的解决方案是在 store 内部手动实现一个 reset 函数:
export const useOrderStore = defineStore('order', () => { const steps = ref([]) const currentStep = ref(0) const submitted = ref(false) function reset() { steps.value = [] currentStep.value = 0 submitted.value = false } return { steps, currentStep, submitted, reset } })这个方案虽然没有框架级的$reset方便,但胜在显式可控。如果你对一个 setup store 调用了$reset,运行时不会有报错,但也不会做任何事,很容易造成"我明明调了 reset 怎么状态没清"的幻觉,这个一定得注意。
5. 容易翻车的几个细节:$patch、$subscribe 与响应式丢失
5.1 store 解构与响应式丢失:最隐蔽的 bug 来源
我在 2.4 里提过storeToRefs的问题,但实际项目里比这个更隐蔽的是在"组件选项式 API"里使用 Pinia 时的解构陷阱。如果你用的是 Options API 的setup()返回或者mapStores,要格外注意。
比如在setup()里直接把 store 返回给模板:
export default { setup() { const userStore = useUserStore() return { // 这样返回是安全的,因为返回的是 reactive 的 store 实例 userStore } } }但如果你图省事这样写:
export default { setup() { const { token, login } = useUserStore() return { token, login } } }模板里显示token就完全不会更新。这种 bug 最坑的地方在于"初始值是对的",页面刷新后第一次渲染有值,等你在另一个组件里改了 token,这里纹丝不动。排查半天才会想到是解构丢失了响应性。
5.2 $patch 的两个形态:对象式与函数式
Pinia 的$patch是官方推荐的多字段更新方式。它有两种形态。第一种传对象:
userStore.$patch({ token: data.token, userInfo: data.userInfo })但对象式有个限制:如果 state 里有数组字段,你要用splice、push这类方法时,纯对象表达不了。比如修改购物车 items 数组,对象式会写成{ items: [...userStore.items, newItem] },这在复杂嵌套结构下效率低也容易出错。这时候用函数式:
cartStore.$patch((state) => { state.items.push(newItem) state.total = state.total + newItem.price })函数式能拿到 state 参数,直接进行数组操作和计算赋值,直观得多。两个方法都支持批量更新,只触发一次响应式更新,这点比逐个赋值要省性能。项目里如果存在"一次修改多个字段"的逻辑,我建议统一用$patch,这样 Devtools 里可以看到明确的一次变更记录。
5.3 $subscribe:监听状态变化别在组件里裸用 watch
如果你需要在状态变化时触发一些副作用,比如保存到数据库、同步到其他系统,用 store 的$subscribe更合适:
userStore.$subscribe((mutation, state) => { // mutation.type 可能是 'direct'、'patch object'、'patch function' console.log(mutation.type, mutation.payload) localStorage.setItem('userState', JSON.stringify(state)) })注意$subscribe默认是浅层的,执行一次调用后就不再跟随;如果 state 里有嵌套对象,需要传{ deep: true }选项。另外$subscribe默认在组件里会跟随组件的卸载而自动销毁,但如果你是全局注册的监听,记得在onUnmounted里手动调用返回的停止函数,避免内存泄漏。
5.4 getters 里使用其他 store 的时机问题
我在 3.4 里写了 getter 里能调用其他 store,但有个时序坑:如果两个 store 在初始化阶段互相引用,可能触发初始化顺序问题。Pinia 的官方建议是,store 之间互相调用尽量放在 action 或者 getter 执行体内,不要放在 state 初始化的顶层。因为 state 初始化是在defineStore时执行的,此时 Pinia 实例可能还没完全准备好。
我遇到过的一个典型情况是,A store 的 state 需要根据 B store 的某个状态来初始化。当时我直接在state函数里写了useBStore(),结果在 SSR 或某些组件生命周期下报错。正确做法是先用默认值初始化 state,在 onMounted 或某个 action 里再基于 B store 的状态去赋值。
6. Pinia 的编码体验与其他细节:Devtools、插件与新人建议
6.1 Devtools 调试比 Vuex 舒服在哪
Pinia 配套的 Vue Devtools 插件已经非常成熟。在 Devtools 的 Pinia 面板里,每个 store 单独一棵树,state、getters、actions 分栏展示。最有价值的是时间旅行调试:你可以在 Action 列表里看到每一次 action 触发前后的状态 diff,状态改了什么一目了然,还能直接回滚到某一时刻。
实际调 bug 的时候,我几乎都是靠 Devtools 的 Action 时间线定位问题的。比如有个表单数据在提交前被莫名修改,我直接在 Pinia 面板里看 action 记录,发现是某个watchEffect里触发了orderStore.updateDraft(),一分钟就锁定真凶,不用再打一堆 console.log。
6.2 插件机制:做持久化、做鉴权中间件都行
Pinia 的插件机制可能很多人还不知道。createPinia()返回的实例可以use一个插件函数,插件函数里可以对所有 store 做统一增强。我之前写过一个简单的持久化插件,不用在每个 store 里手动读写 localStorage 了:
function persistPlugin({ store }) { const saved = localStorage.getItem(store.$id) if (saved) { store.$patch(JSON.parse(saved)) } store.$subscribe( (mutation, state) => { localStorage.setItem(store.$id, JSON.stringify(state)) }, { deep: true } ) } const pinia = createPinia() pinia.use(persistPlugin)当然现在社区里已经有现成的pinia-plugin-persistedstate,功能更全、支持自定义 key 和存储方式,没必要重复造轮子。但理解插件机制还是有好处的,比如判断用户是否登录后统一拦截 action、接口失败后统一抛错等,都适合放在这个层面处理。
6.3 给准备上手 Pinia 的新人几条实在建议
第一,新项目直接上 Pinia 就好,不需要犹豫。它是 Vue 官方默认的状态管理方案,生态位置在未来很长一段时间内都是稳定的。第二,不要为了用 Pinia 而把所有状态都塞进去,组件内联状态、跨组件共享但低频的状态,优先考虑 composables 或 provide/inject,store 不应该变成一个"垃圾桶"。第三,如果团队里有新手,先让他们把选项式 store 用熟,再去碰组合式 store,因为选项式的结构更规整,心智压力小。
另外提一句打包体积的体感:Pinia 本身不到 2KB(不算 Vue 运行时),我项目里从 Vuex 换过来之后,打包体积几乎没有变化,但开发期的类型检查和代码跳转效率提升非常明显。Vuex 4 虽然还在维护,但新特性基本都集中在 Pinia 上,这是一个客观趋势。
最后再说一个我反复踩过的坑:Pinia 的版本升级。如果你在 Vue 2 项目里通过@2.7的 Vue 兼容层来用 Pinia,或者项目里同时存在 Vuex 和 Pinia,一定要看官方文档的安装说明,别用错了包名。总的来说,Pinia 状态管理的上手成本非常低,核心 API 几个小时就能掌握,真正决定项目质量的,是 store 怎么拆分、状态怎么命名、边界怎么界定。这些小细节只能靠项目实践慢慢积累,希望我这篇迁移记录能帮你少走几步弯路。