news 2026/9/9 6:22:13

从 Vuex 到 Pinia:Vue 3 状态管理新选择与迁移实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从 Vuex 到 Pinia:Vue 3 状态管理新选择与迁移实战

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 对象,而是传一个函数,函数里用refcomputedwatch这些 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就是computedactions就是普通函数,所以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 独立成一个useUserStoreuseCartStoreuseCartItemsStore。它们的跨模块访问靠互相导入调用,不需要命名空间配置。

第三步:组件里的 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,放组件自己的refreactive里就好。

二是跟后端接口强绑定的数据,如果只在一个页面里用,也可以不放 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 类型一点点捡了回来。状态管理库这种东西,选型的时候纠结半天,真正上手其实半天就够。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 6:20:48

多普勒效应与信号与系统:时变时延、频谱搬移及MATLAB仿真

简介&#xff1a;面向西电通信工程学院“信号与系统”课程大作业的资料包&#xff0c;聚焦多普勒效应在通信系统中的应用分析与建模。内容围绕多普勒效应原理、信号处理模拟及实验验证展开&#xff0c;可帮助学习者完成从理论推导、算法设计到结果分析的全过程。包内共有6个文件…

作者头像 李华
网站建设 2026/9/9 6:18:23

hermes-agent:轻量级语义路由中间件,专为边缘AI协同设计

1. 项目概述&#xff1a;一个被严重低估的轻量级智能体调度中枢“hermes-agent”这个词最近在技术社区里冒头的频率越来越高&#xff0c;但多数人看到它第一反应是——这又是个新出的LLM wrapper&#xff1f;还是某个大厂内部代号&#xff1f;其实都不是。我去年底在帮一家做工…

作者头像 李华
网站建设 2026/9/9 6:17:58

数字孪生工厂模拟平台验证测试实战:模型校验、数据链路与虚实同步

自打接手数字孪生工厂模拟平台的验证测试&#xff0c;我就知道这活儿跟以前测普通信息系统不一样。前两年聊数字孪生&#xff0c;更多是概念验证、演示Demo&#xff0c;测起来还能靠人工点点看个效果。到了2026年&#xff0c;数字孪生工厂模拟平台已经真正落到产线规划、调度优…

作者头像 李华
网站建设 2026/9/9 6:17:58

用设计文档取代代码:SMART重塑ML性能建模

1. 开篇&#xff1a;当“写代码”不再是 ML 性能建模的核心这两年做大模型和 ML 系统优化的人&#xff0c;基本都撞上过同一个痛点&#xff1a;性能建模库。说白了&#xff0c;就是用一个可计算的模型去预估某个算子、某个 kernel、某段融合逻辑在真实硬件上的运行时间、显存占…

作者头像 李华
网站建设 2026/9/9 6:16:43

Chrome扩展实现本地1024维视觉向量检索

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 6:14:59

MATLAB汽车运动学仿真教程:用单车模型模拟车辆行驶过程

做汽车运动学仿真这件事&#xff0c;听起来门槛不低&#xff0c;但其实上手路径比多数人想的要直。很多人一听到“MATLAB 汽车模型运动学仿真&#xff0c;模拟车辆行驶过程”就先想到各种轮胎力、悬挂、整车动力学&#xff0c;其实从项目名字里的“运动学”三个字就能判断&…

作者头像 李华