1. 从 Vuex 到 Pinia:为什么前端状态管理非要折腾出一个新东西
很多人第一次听到 Pinia,第一反应都是:Vue 生态不是已经有 Vuex 了吗?怎么又冒出来一个状态管理库?而且名字还起得这么随意,大菠萝小菠萝,听着就不太正经。
先别急着吐槽,Pinia 这个名字其实是piña的谐音,西班牙语里菠萝的意思。Vuex 的作者也是 Pinia 的作者,同一个人,Evan You 团队核心成员 Eduardo San Martin Morote 主导开发的。他当初起这个名字的初衷特别朴素——Vuex 念起来像 "View X",而 Pinia 就像在说 "菠萝",菠萝又是那种看起来扎手但切开很甜的水果,正好暗示这个库"外面看着简单,用起来舒服"。
如果你现在还在用 Vuex 4 搭配 Vue 3 写项目,我强烈建议你花一个下午把 Pinia 迁移过来。这不需要争论,因为 Pinia 本身就是 Vuex 5 的设计蓝本。Vue 官方在 Vue 3 的生态里已经明确把 Pinia 作为官方推荐的状态管理方案,Vuex 进入维护模式,只修 bug,不再加新功能。
那 Pinia 到底解决了 Vuex 哪些痛点?
第一,TypeScript 支持是原生级的。Vuex 4 的 TypeScript 支持属于"能用但别扭"的水平,尤其是模块动态注册、getter 类型推导,写起来非常费劲,很多项目为了省事干脆用any。Pinia 从底层设计上就是为 TS 服务的,store 的 state、getters、actions 里的this类型全部自动推导,你在编辑器里写代码的时候,提示是全量的,基本不用手写任何类型体操。
第二,去掉了 mutations。这是 Pinia 最核心的架构变化。Vuex 要求你发异步请求必须走 actions,改 state 必须走 mutations,commit 一下、dispatch 一下,绕来绕去。背后的初衷是在 DevTools 里追踪每次 state 修改的痕迹,但实际项目里 90% 的人根本不会看每一次 mutation 日志,反而觉得这套流程啰嗦。Pinia 直接砍掉 mutations,actions 里可以同步改 state,也可以异步改,DevTools 照样能追踪到每个 action 的调用。
第三,store 即模块,不需要嵌套。Vuex 里的 module 嵌套在大型项目里是个深坑,命名空间namespaced: true一开,mapState、mapGetters 各种辅助函数参数经常对着文档翻半天。Pinia 里每个 store 都是独立的,通过defineStore定义一个,用的时候useStore()直接拿,天然隔离,不需要任何命名空间配置。
第四,没有全局单例污染。这一点对做 SSR 或测试的人来说特别重要。Vuex 是全局单例,服务端渲染时每个请求之间容易串状态,测试的时候也不方便隔离。Pinia 的 store 在使用时才实例化,可以随时 new 一个 Pinia 实例注入,做组件测试和 SSR 都很干净。
所以结论很简单:如果你要开新项目,直接用 Pinia,别再碰 Vuex 了。如果你在维护老项目,只要不是那种超大复杂嵌套 module 的 Vuex 代码,迁移成本通常一天就能搞定。
2. 从一个最小的计数器开始认识大菠萝的完整骨架
Pinia 的学习曲线是我见过最平缓的,没有之一。你可以完全不看文档,靠编辑器提示写出一个能跑的 store。
用 Vite 新建一个 Vue 3 项目之后,第一步自然是安装:
npm install pinia然后在入口文件里注册:
import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const app = createApp(App) app.use(createPinia()) app.mount('#app')就这三行,Pinia 就算接入你的项目了。createPinia()会创建一个 Pinia 实例,app.use()把它注册成 Vue 插件。这里有一个细节:在 Vue 3 里,createApp 和 app.use 的顺序不能反过来,必须先 createApp 再 use,这跟 Vue 2 里 new Vue 之后再 Vue.use 的习惯不太一样。
接着定义一个 store。新建src/stores/counter.ts:
import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', { state: () => ({ count: 0 }), getters: { doubleCount: (state) => state.count * 2 }, actions: { increment() { this.count++ }, async fetchCount() { const res = await fetch('/api/count') this.count = await res.json() } } })这里有几个点值得细说。
defineStore的第一个参数是 store 的唯一 id,第二个参数是一个配置对象。这个 id 是全应用唯一的,你用useCounterStore的时候,Pinia 内部是靠这个 id 去找到对应的 store 实例。在 DevTools 里看到的名字也是这个 id。
getters 和 Vuex 的 getters 有本质区别:Vuex 的 getters 是全局注册的,在不同组件里调$store.getters['moduleName/getterName']才能访问,而 Pinia 的 getters 直接是 store 上的属性,store.doubleCount就能取到。而且 Pinia 的 getters 支持this访问其他 getter:
getters: { doubleCount: (state) => state.count * 2, quadrupleCount(): number { return this.doubleCount * 2 } }注意,第二个 getter 不能写成箭头函数,因为箭头函数没有自己的this,只有普通函数才能通过this访问当前 store 上的其他 getter。
actions 里也一样,this指向 store 实例,所以你可以在 action 里直接改 state,调用其他 action,不用像 Vuex 那样通过 dispatch 去触发另一个 action。
组件里的使用方式:
<script setup lang="ts"> import { useCounterStore } from '../stores/counter' const store = useCounterStore() </script> <template> <p>{{ store.count }}</p> <p>{{ store.doubleCount }}</p> <button @click="store.increment()">+1</button> </template>就这么简单。你不需要 mapState 也不需要 mapActions,直接从 store 上取属性和方法就行。如果你想在模板里解构 store 的属性,需要用到storeToRefs,否则会丢失响应式:
<script setup lang="ts"> import { storeToRefs } from 'pinia' import { useCounterStore } from '../stores/counter' const store = useCounterStore() const { count, doubleCount } = storeToRefs(store) </script>3. Setup Store 写法:用 Composition API 的思维组织状态
上面的写法叫 Option Store,跟 Vue 2 那种 data/computed/methods 的组织方式很像。如果你已经习惯了 Vue 3 的<script setup>,或者你的项目里状态之间逻辑关联比较复杂,我推荐你用 Setup Store 的写法。
Setup Store 的特点是不传 state/getters/actions 对象,而是传一个函数,函数里用ref、computed、watch这些 Composition API 自由组合状态和逻辑,最后全部 return 出去:
import { ref, computed } from 'vue' import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', () => { const count = ref(0) const doubleCount = computed(() => count.value * 2) function increment() { count.value++ } async function fetchCount() { const res = await fetch('/api/count') count.value = await res.json() } return { count, doubleCount, increment, fetchCount } })这种写法最大的价值在于:你可以把 store 里的逻辑抽成 composable,也可以在 store 里使用watch监听自己的 state 变化,这在 Option Store 里很别扭。
举个例子,假设你的用户登录之后需要把用户信息同步到 localStorage,你会写:
import { ref, watch } from 'vue' import { defineStore } from 'pinia' export const useUserStore = defineStore('user', () => { const userInfo = ref<{ name: string; avatar: string } | null>(null) watch(userInfo, (val) => { if (val) { localStorage.setItem('user-info', JSON.stringify(val)) } else { localStorage.removeItem('user-info') } }, { deep: true }) function setUser(info: { name: string; avatar: string }) { userInfo.value = info } function logout() { userInfo.value = null } return { userInfo, setUser, logout } })这种方式把"状态 + 副作用"绑定在一起,写起来非常自然。
另外 Setup Store 里的getters就是computed,actions就是普通函数,所以this的推导完全交给 TypeScript,不存在 Option Store 里 getter 用箭头函数不能访问this的限制问题。
我之前在一个中后台项目里,把筛选条件、分页参数、列表数据、加载状态全放一个 store 里,用 Setup Store 的写法,一个 store 文件就是一个小型业务模块,清晰到同事接手的第二周就能在上面加功能。
4. 实战中真正高频使用的 Pinia 能力
计数器这种 demo 谁都会写,但在真实项目里,有几个 Pinia 的用法是高频到几乎每个页面都会遇到的。
4.1 跨组件共享状态:用户信息与权限
最常见的场景就是用户登录后把 token 存起来,然后到处判断用户有没有权限。用 Pinia 写的话:
export const useAuthStore = defineStore('auth', () => { const token = ref(localStorage.getItem('token') || '') const userInfo = ref<UserInfo | null>(null) const isLoggedIn = computed(() => !!token.value) async function login(params: LoginParams) { const { token: newToken, userInfo: info } = await api.login(params) token.value = newToken userInfo.value = info localStorage.setItem('token', newToken) } function logout() { token.value = '' userInfo.value = null localStorage.removeItem('token') } return { token, userInfo, isLoggedIn, login, logout } })然后路由守卫里可以直接调用它:
router.beforeEach((to) => { const authStore = useAuthStore() if (to.meta.requiresAuth && !authStore.isLoggedIn) { return { name: 'login' } } })这里要注意一个坑:在 Pinia 安装之前,不能调用任何useStore函数。也就是说router.beforeEach里的调用没问题,因为路由守卫是请求时才会执行,此时 Pinia 已经装好了。但如果你在main.ts里、app.use(createPinia())之前就导入一个组件,而这个组件里调用了useStore,就会报错 "getActivePinia was called with no active Pinia"。解决办法是把 store 的调用推迟到组件挂载或函数执行时。
4.2 子父组件通信的替代方案
Vue 3 里组件通信有 props、emit、provide/inject、事件总线,但有些场景用 store 更干净。比如一个弹窗组件需要在关闭时通知列表组件刷新,如果这两个组件相隔好几层,用 emit 一层层传是真的烦。
直接写一个listStore,弹窗关闭前调listStore.refreshFlag = true,列表组件用watch监听这个值变化然后拉新数据。这比什么都直接。
4.3 Pinia 和 localStorage 做持久化
Pinia 本身不提供本地存储持久化的能力,但在不引第三方库的情况下,自己写也不复杂。
最简单的做法是订阅 store 的$subscribe:
const cartStore = useCartStore() cartStore.$subscribe((mutation, state) => { localStorage.setItem('cart', JSON.stringify(state)) })$subscribe有点类似 Vuex 的 subscribe,但它有个好处:默认只在 store 内部 state 变化时触发,而且可以传{ detached: true }让订阅不随组件卸载而自动解除。如果你在组件里调用$subscribe,组件销毁时订阅会自动销毁,这样能避免内存泄漏。
初始化的时候再从 localStorage 里还原:
const savedCart = JSON.parse(localStorage.getItem('cart') || '{}') export const useCartStore = defineStore('cart', { state: () => ({ items: savedCart.items || [] }) })生产环境建议用现成的pinia-plugin-persistedstate,它支持按 store 配置是否持久化、自定义存储键名,还支持默认走 localStorage、定制 sessionStorage,功能比较完整。
4.4 在 Pinia 里访问路由
某些场景下 store 的 action 里需要跳转路由,或者读取当前路由信息。在 Pinia 外访问 router 实例有几种方式:
最稳妥的是在注册路由时把 router 挂在 Pinia 能访问的地方,比如在 store 里通过useRouter()调用。但注意,useRouter只能在组件里调用,在 store 的 action 里直接用是拿不到的——除非你把 router 传给 Pinia。
我比较推荐的做法是:在入口文件里先创建 router,再去创建 Pinia,这样在 setup store 里用useRouter()就能拿到:
import { createRouter } from 'vue-router' import { createPinia } from 'pinia' import App from './App.vue' const router = createRouter({ ... }) const pinia = createPinia() const app = createApp(App) app.use(pinia) app.use(router) app.mount('#app')然后在 store 里:
import { useRouter } from 'vue-router' export const useSomeStore = defineStore('some', () => { const router = useRouter() function goHome() { router.push('/') } return { goHome } })前提是 store 的 action 必须在使用 store 的组件上下文中执行,否则useRouter是拿不到当前组件实例的。
5. Pinia 3 带来了哪些值得升级的新东西
写这篇文章的时候 Pinia 3 已经发布了。很多人看到大版本号更新就觉得要大改,实际上 Pinia 3 的核心 API 完全没变,它主要是跟 Vue 的版本对齐,同时清理了一些内部过期实现,还有几个细节值得关注。
Pinia 3 的完整依赖要求是 Vue 3.5+。如果你还在用 Vue 3.4 或更早版本,装 Pinia 3 会带不动,npm 会给你报 peer dependency 冲突。好消息是 Pinia 2.x 在 Vue 3.5 下也能正常工作,不需要强制升级。
另一个值得注意的变化是 Pinia 3 明确废弃了对 Vue 2 的支持。Pinia 2 时代官方文档写的是"支持 Vue 2.7+ 和 Vue 3",很多老项目靠着 Vue 2.7 的 setup 语法硬上 Pinia 2。到了 Pinia 3,官方把 Vue 2 的兼容代码全部移除,这也算是一种态度的明确:新项目、新功能都默认往 Vue 3 这边走。
对开发者来说,从 2.x 升到 3.x 不需要改业务代码,打开控制台看到 Deprecation 警告按提示清除就行。真正要注意的是:如果你同时用了pinia-plugin-persistedstate之类的社区插件,升级 Pinia 之前先确认插件是否有对应的版本,否则可能出现包依赖冲突。
5.1 类型层面的增强:DefineStoreOptions 更干净了
老用户应该记得,Pinia 2.x 里defineStore的类型定义比较复杂,特别是当你需要传泛型的时候,编辑器提示经常飘出来一堆嵌套的 Options 类型。Pinia 3 把这些类型整理得清爽了一些,自定义扩展 store 属性时的类型提示更友好了。如果你用过createPinia()之后通过pinia.use()扩展属性,应该能明显感受到。
5.2 MapStores 和 Vuex 迁移辅助
Pinia 3 继续完善了从 Vuex 迁移的工具链,比如mapStores这个辅助函数,可以在 Options API 组件里把 store 映射成this.counterStore这样的形式,方便老项目的 Vuex 代码平滑迁移。
<script> import { mapStores } from 'pinia' import { useCounterStore } from '../stores/counter' export default { computed: { ...mapStores(useCounterStore) }, methods: { handleClick() { this.counterStore.increment() } } } </script>Options API 的老项目里,这个函数比 mapState 好用得多,因为整个 store 直接挂到this上,状态、getters、actions 都能直接访问,不用一个个映射。
6. 我从 Vuex 迁到 Pinia 的一个真实踩坑记录
之前接手过一个 Vue 3 + Vuex 4 的中型项目,几十个 module,嵌套了好几层。当时我拍板全量迁到 Pinia,理由是那个项目 TS 已经快被any塞满了,Vuex 的类型提示根本救不回来。
迁移的步骤很简单,我梳理下来就这几步:
第一步:安装 Pinia,注册插件。
npm install pinia入口文件注册:
import { createPinia } from 'pinia' app.use(createPinia())第二步:把 Vuex store 拆成多个 defineStore 文件。
原先的 Vuex module 是嵌套的,比如:
store: { modules: { user: userModule, cart: { modules: { items: cartItemsModule } } } }这种嵌套结构在 Pinia 里就直接拍平,每个 module 独立成一个useUserStore、useCartStore、useCartItemsStore。它们的跨模块访问靠互相导入调用,不需要命名空间配置。
第三步:组件里的 mapState、mapGetters、mapActions 全部改为直接调用 useStore。
这是工作量最大的部分,但基本都是机械替换,用正则跑几遍就能完成大部分。
整个迁移过程我实际花了两天,遇到的唯一一个硬坑是:同一页面上同时使用了多个 store,但组件实例销毁时,之前用 Option Store 的mapState映射出来的计算属性不生效了。
具体原因是 Vuex 的 mapState 会在内部帮你处理依赖收集,而 Pinia 直接返回响应式对象。如果我在 Options API 组件里直接写:
computed: { ...mapStores(useCounterStore) }这没问题,因为mapStores把整个 store 实例都挂到了this上,store 里的 getter 本身就是 computed,会自动追踪。
但如果我这样写:
computed: { count() { return this.counterStore.count } }也行,因为this.counterStore是响应式的,count 被访问时被依赖收集,count 变化时这个计算属性会重新计算。
真正出问题的是我一开始图省事,在 setup 函数里解构 store:
setup() { const counterStore = useCounterStore() return { ...counterStore } }这种全量解构会让 store 里的 state 丢失响应式,count 变了页面不更新。这是我最初踩到的一个误区。后来改成storeToRefs(counterStore)或直接用store.xxx的形式就正常了。
第四步:DevTools 验证。
迁移完去 Vue DevTools 里看 Pinia 标签页,能正常看到每个 store 的 state、getters、actions,以及 action 调用时间线,说明接入成功了。
7. 什么时候你还需要 Vuex,以及什么时候用 Pinia 反而要慎重
虽然我前面把 Vuex 批判了一通,但也不能说它一无是处。
如果你正在维护一个 Vue 2 的老项目,不打算升级 Vue 3,那 Pinia 2.x 虽然支持 Vue 2.7,但生态匹配度和社区例子的丰富度还是不如 Vuex。再加上 Vue 2.7 的 Vuex 4 已经很成熟,除非有必要,不必为了"新"而去迁。
另外如果你的项目非常依赖 DevTools 的 mutation 日志来追踪 bug——比如金融、医疗领域对数据变更审计要求极高的系统,那 Vuex 强制的 mutation 流程在审计上确实比 Pinia 更严格。Pinia 虽然每个 action 也留痕,但它不区分同步和异步,想看"谁在什么时候改了什么 state",得手动在 action 里打日志或者靠$subscribe。这种情况归 Vuex 管更稳。
还有一个场景要慎重:多端代码共享。如果你在做类似 Taro 小程序 + Web 端同一套逻辑的状态管理,请先去确认你用的框架对 Pinia 的支持情况。Taro 官方是以 Redux、Zustand 为主要推荐,Pinia 的适配往往要自己写。此时引入 Pinia 不是不行,但需要额外的工作量。
8. Pinia 的边界:什么时候别把东西全塞进 store
最后聊一个我觉得新手很容易走偏的点:把什么都塞进 store。
有人觉得"状态管理嘛,我页面里的数据都放 store",结果一个 store 里塞了十几个互不相关的 state,组件里随便哪个地方都能改,最后 bug 出现时根本定位不到是谁改的。
我的经验准则是两条:
一是只有需要跨组件共享、并且状态改变会影响多个组件渲染的数据,才放 store。单个组件内部 UI 临时的开关状态、输入框的值、某个弹窗的 visible,放组件自己的ref或reactive里就好。
二是跟后端接口强绑定的数据,如果只在一个页面里用,也可以不放 store。很多人的习惯是"所有接口数据都要进 store",这是把状态管理当成全局缓存了。如果一个接口的数据只被一个页面消费,那页面级的状态管理(比如一个 composable 里管理 loading/data/error)反而更清爽。只有当一个数据被多个页面或组件消费,或者需要在路由切换后保留,才值得放进 store。
比如购物车的商品列表,用户从商品页加入、从购物车页删除、结算页读取,这种明显跨页面共享的数据适合放 store。而某个列表页的筛选条件,只在列表页内变化、列表页内消费,它放 store 反而会让页面路由切换后还残留上次的筛选状态,下次进来还得手动重置,平白增加心智负担。
9. 小菠萝、大菠萝和组合式菠萝:一个简单的心智模型收尾
很多人记不住 Pinia 的三种 store 形态,我自己总结了一个特别土但管用的类比。
- Option Store(小菠萝):像小时候吃的那种一瓣一瓣切好的菠萝,整整齐齐,state 归 state、getters 归 getters、actions 归 actions,一眼能看清结构,适合简单场景。
- Setup Store(大菠萝):像一个完整的菠萝,中间有芯(ref 状态)、有果肉(computed)、有皮(watch 副作用),你可以自由组合哪块甜哪块酸,适合复杂业务。
- 组合式 store(菠萝拼盘):当你需要把多个 store 组合起来用的时候,就像拿几块不同品种的菠萝拼一个盘,互相搭配,各取所需。
你不需要从第一天就掌握所有细节。先用 Option Store 写一个小计数器,感受一下"定义 store、在组件里调用、DevTools 里看状态变化"这个完整链路。然后尝试把用户登录状态落进 store,做一次跨组件共享。等这些用熟了,再试 Setup Store 组合复杂逻辑。
写这篇东西的时候,我正好又看到自己半年前的那个计数器 store —— 它真的只有三行 state,一个 getter,一个 action。但就是从这个最最基础的菠萝开始,我把项目中所有 Vuex 的坑都填平了,也把 TypeScript 类型一点点捡了回来。状态管理库这种东西,选型的时候纠结半天,真正上手其实半天就够。