尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Pinia 状态管理实战:从 Vuex 迁移到 Vue 3 的最佳方案

发布时间:2026/9/20 6:37:11

资讯中心
01
ARTICLE

Pinia 状态管理实战:从 Vuex 迁移到 Vue 3 的最佳方案

Pinia 状态管理实战:从 Vuex 迁移到 Vue 3 的最佳方案
1. 为什么是 Pinia先搞清楚它解决了什么问题1.1 用 Vuex 时的那些别扭就是 Pinia 的起点先聊点真实的。我自己早期写 Vue 项目用的还是 Vuex 3 配 Options API。当时最让我难受的不是功能不够而是“改一个状态要跨三个文件”state 里定义变量mutations 里写同步修改actions 里再包装一层异步逻辑组件里还要 mapState、mapMutations 一层层映射。代码量翻倍不说每次加一个字段都要在脑子里过一遍完整链路稍不留神就忘了在 mutations 里补对应的 commit。Vue 3 发布之后Composition API 的出现让组件代码组织方式发生了很大变化但 Vuex 4 在 Vue 3 下的体验依然带着旧时代的惯性mutations 还是要写、模块嵌套还是要靠 namespaced、TypeScript 类型推导依然是玄学。那时候我就在想状态管理这件事真的需要搞这么复杂吗Pinia 就是在这种情况下出现的。它和 Vuex 最本质的区别在于Pinia 默认就是 Composition API 风格天然按模块拆分 Store而且彻底抛弃了 mutations直接在 actions 里写同步和异步逻辑。用一句话概括体验差异从 Vuex 切到 Pinia你会觉得“这不就应该是 Vue 原本的状态管理方式吗”。1.2 Pinia 与 Vuex 的核心差异对照很多人面试或者做技术选型时喜欢背差异点但光背结论没用你得知道每个差异背后解决的是什么实际问题。下面这张表是我自己整理的核心对比后面再逐个解释。对比维度Vuex 4Pinia状态修改方式必须经过 mutationcommit 触发直接修改 state或调用 action没有 mutation异步逻辑放在 action提交 mutation 完成修改直接放在 action内部可直接改 state模块化modules 嵌套 namespaced 控制命名空间每个 store 独立 defineStore天然模块化TypeScript 支持类型推导较弱需要大量额外类型定义基于 Composition API类型推导自然、完整插件机制有但实现复杂插件系统轻量专注持久化、日志等场景Devtools 支持支持但操作繁琐支持好操作纬度更直观服务端渲染SSR支持配置较繁琐支持初始化逻辑更简洁学习成本概念多state/mutations/actions/getters/modules概念少state/getters/actions重点解释几个关键差异。第一取消了 mutations。Vuex 强推 mutations 的初衷是希望所有状态变化都能被 Devtools 追踪到但实际开发中这往往变成一种形式主义你依然可以在 action 里疯狂改 state只不过必须走一遍 commit 的流程。Pinia 选择了信任开发者直接改 state 也能被 Devtools 监听配合 $patch 方法还能做批量更新追踪。从结果上看代码量减少了效率却更高。第二模块化从“嵌套”变成了“平铺”。Vuex 里模块多了之后namespaced 和嵌套层级会让 getters、actions 的引用路径变得特别长而 Pinia 每个模块就是一个独立的 useStore用完即走模块之间还可以相互调用对方的 store类型上也是天然推导的。这种设计更接近“一个业务域一个文件”的直觉。第三TypeScript 的支持优势。Vuex 需要你手动声明 ModuleS, R 泛型还要维护各种辅助函数的类型映射Pinia 因为是 defineStore 包裹整个状态定义state、getters、actions 的类型可以自动推导出来尤其在 VSCode 中悬停到 store 上的每个属性类型都清清楚楚重构字段的时候友好太多了。1.3 新老项目到底怎么选别再纠结了我的建议非常简单分三种情况。如果是新项目不管 Vue 3 还是 Vue 2Pinia 2.x 支持 Vue 2.7直接用 Pinia不需要犹豫。Vue 官方文档里 Pinia 也已经被定位为默认状态管理方案生态上该有的插件都有了。如果是已有 Vuex 项目别急着大规模重写。Vuex 4 在 Vue 3 项目里依然稳定可用如果你的状态结构不算太复杂、团队已经习惯了 Vuex 的写法硬迁移反而会引入新的回归风险。更稳妥的做法是在新写的模块或新页面中使用 Pinia与 Vuex 并存一段时间等积累足够信心后再逐步替换。如果是纯 Vue 2 老项目且升级 Vue 3 在近期没有计划那继续用 Vuex 3 也完全没问题没必要为一个状态库单独承担升级成本。技术选型永远要考虑团队维护成本而不是单纯追求“新”。2. 跑通第一个 Demo从安装到 Store 落地2.1 安装与项目初始化Pinia 的安装非常直接npm 或 yarn 选一个就行推荐 npm。npm install pinia如果你用的是 Vue 3 Vite 的项目模板安装完之后需要在入口文件里注册插件。这里以 main.ts 为例import { createApp } from vue import { createPinia } from pinia import App from ./App.vue const app createApp(App) const pinia createPinia() app.use(pinia) app.mount(#app)这里有一个容易被忽略的细节createPinia() 必须在 app.use(pinia) 之后才能被页面组件正常使用。如果你在某个工具函数中直接引入 useCounterStore而这个函数在 pinia 注册之前被调用就会报类似“getActivePinia() was called but there was no active Pinia”的错误。常见的场景是路由守卫、工具模块、非组件文件中使用 store后面讲坑的时候我会单独展开。如果你用的是 Vue 2.7安装方式一样但需要在 Vue 插件初始化时把 Pinia 插件传进去细节可以看官方文档的 Vue 2 安装说明。注册完成之后你可以先清空项目里默认的 HelloWorld 组件我们准备写第一个真正意义上的 Pinia 计数器。2.2 用 defineStore 定义第一个 Store在 src 目录下新建一个 stores 文件夹创建一个 counter.ts 文件import { defineStore } from pinia import { ref } from vue export const useCounterStore defineStore(counter, () { const count ref(0) const step ref(2) function increment() { count.value step.value } function reset() { count.value 0 } return { count, step, increment, reset } })这就是 Pinia 的 Setup Store 写法。你可能会发现它几乎就是一个普通的 Composition 函数用 ref 定义状态、用普通函数定义方法、最后统一 return。没错Pinia 的 Setup Store 的核心设计就是这个思路——你不需要学习任何额外的状态定义语法只需要会写 setup() 就够了。顺便说一个命名规范use 开头加业务名例如 useCounterStore、useUserStore、useCartStore。这样在组件中一看到名字就知道它是一个 Store 的实例和普通 composable 函数区分开。除了 Setup StorePinia 还支持 Options Store 写法类似 Vuex 那种对象式定义。我个人的建议是如果你的团队还没有完全切换到 Composition API可以先采用 Options Store 过渡否则优先 Setup Store因为它的表达能力更强也更容易在内部复用 composable 函数。2.3 组件消费 Storesetup 语法下的正确姿势现在在组件里消费这个 store。以一个简单的 Vue 3 单文件组件为例template div classcounter p当前计数{{ counter.count }}/p button clickcounter.increment增加/button button clickcounter.reset重置/button /div /template script setup langts import { useCounterStore } from /stores/counter const counter useCounterStore() /script在 setup 语法里我们直接调用 useCounterStore() 拿到 store 实例然后就可以像访问响应式对象一样访问它的 state 和 actions。这里有个新手容易踩的雷直接从 store 上解构 state 会丢失响应性。script setup langts import { useCounterStore } from /stores/counter const counter useCounterStore() const { count } counter // 错误count 变成普通值不再响应 /script正确做法是用 storeToRefs 解构 state 和 getters普通的函数actions直接解构即可import { storeToRefs } from pinia import { useCounterStore } from /stores/counter const counter useCounterStore() const { count } storeToRefs(counter) const { increment, reset } counterstoreToRefs 的原理是专门处理响应式对象的解构保证 count 仍然是一个 Ref。这个细节后面在避坑章节还会再提到因为它是使用 Pinia 时最常见的错误之一。3. 核心 API 逐个拆解state / getters / actions3.1 state定义、重置与批量更新state 就是 Store 里的数据仓库。在 Setup Store 中我们统一用 ref 或 reactive 来定义最终 return 出去的就是响应式状态。日常开发中state 最常见的操作有三类。第一类直接修改。在组件或 action 里直接赋值即可counter.count 10这会立即触发视图更新。注意如果 state 里定义的是数组直接 push、splice 这类变更方法也能正常触发响应式更新因为 Pinia 底层把 state 包在 reactive 里完全遵循 Vue 3 的响应式规则。第二类批量更新使用 $patch。比如要一次改多个字段或者要同时更新对象的多个属性counter.$patch({ count: 100, step: 5, }) // 也支持函数式写法用于更复杂的逻辑 counter.$patch((state) { state.count 1 state.step state.count 100 ? 1 : 2 })$patch 的好处是 Devtools 会把一次 patch 记录为单次事件调试时可以看到明确的快照差异。如果连续多次直接赋值Devtools 可能会把每次改动记录为独立事件造成信息噪音。第三类重置状态调用 $reset。拿到 store 实例后直接执行counter.$reset()但要提醒一下$reset 在 Options Store 里开箱即用在 Setup Store 里是不存在的因为 Pinia 无法知道你的初始状态是什么。Setup Store 里想实现重置只能自己定义一个 reset 函数手动恢复初始值export const useCounterStore defineStore(counter, () { const count ref(0) const step ref(2) function reset() { count.value 0 step.value 2 } return { count, step, reset } })3.2 getters派生状态的正确打开方式getters 相当于 Store 里的计算属性用于从 state 派生出具有一定计算逻辑的新值。和组件里 computed 的区别是getters 只存在于 Store 内部这样当多个组件都需要同一个派生状态时就不需要重复写计算逻辑。在 Setup Store 中getter 直接用 computed 实现import { defineStore } from pinia import { ref, computed } from vue export const useCounterStore defineStore(counter, () { const count ref(0) const doubleCount computed(() count.value * 2) const isLarge computed(() count.value 100) return { count, doubleCount, isLarge } })组件中使用的时候const counter useCounterStore() const { doubleCount, isLarge } storeToRefs(counter)关于 getters 的使用有几个要点。第一getter 可以依赖另一个 getter。比如const doubleCount computed(() count.value * 2) const quadrupleCount computed(() doubleCount.value * 2)第二getter 可以接收参数返回一个函数用于需要传参的计算场景。比如按 id 过滤列表const getUserById computed(() (id: number) { return users.value.find((user) user.id id) })但要注意这种参数化 getter 一旦被调用结果不再被缓存相当于每次调用都重新计算。所以只适合数据量小或者不追求性能的场景大数据量过滤还是应该放在组件里用 watch 或 computed 做缓存。第三getters 的典型用途是把接口返回的数据做清洗和格式化。比如后端给的时间戳在前端统一格式化为日期字符串放在 getter 里最合适。这样既避免了在多个组件里重复写格式化逻辑也让视图层模板保持简洁。3.3 actions把异步逻辑放进来actions 是 Pinia 里处理业务逻辑的核心位置尤其是异步操作。在 Vuex 时代我经常纠结一个问题异步请求的结果到底应该放在 action 里 commit 两次还是一次为了配合 Devtools 事件追踪很多团队习惯 commit 一个“请求开始”事件和一个“请求成功/失败”事件代码臃肿得不行。在 Pinia 里完全没有这个问题直接在 action 里写异步流程最后一步给 state 赋值即可。一个典型例子从后端拉取用户信息。import { defineStore } from pinia import { ref } from vue import { getUserInfo } from /api/user export const useUserStore defineStore(user, () { const userInfo refUserInfo | null(null) const loading ref(false) async function fetchUserInfo(userId: number) { loading.value true try { userInfo.value await getUserInfo(userId) } catch (error) { console.error(获取用户信息失败, error) throw error } finally { loading.value false } } return { userInfo, loading, fetchUserInfo } })组件中调用时const userStore useUserStore() userStore.fetchUserInfo(123)有意思的是action 之间也可以自由调用。比如登录成功后要拉取用户信息再初始化购物车async function login(payload: LoginParams) { const token await loginApi(payload) localStorage.setItem(token, token) await fetchUserInfo(payload.userId) await cartStore.initCart() }在组合式写法中action 内部除了可以直接修改 state 之外还可以await其他 store 的 action或者调用其他工具的异步函数。这种自由组合的方式让 Store 更像是一个业务逻辑的聚合层例如我可以把当前页面的共享状态和操作逻辑统一封装进一个 usePageStore组件只负责“表达”业务细节全下沉到 Store 里。一个值得提醒的细节action 命名尽量用动词短语比如 fetchUserInfo、submitOrder。因为 Pinia 的 action 其实就是一个普通函数在 Devtools 中会以 action 名称追踪事件如果命名太随意排查问题时事件列表根本没法看。3.4 Store 之间的互相调用Pinia 最让我喜欢的地方之一就是 store 与 store 之间可以互相引用而且没有模块化的包袱。使用方式非常直觉化在某个 store 的 action 内调用另一个 store 的方法。举一个电商场景的例子一个 cart store 和一个 user store。// stores/cart.ts import { defineStore } from pinia import { ref } from vue import { useUserStore } from ./user export const useCartStore defineStore(cart, () { const items refCartItem[]([]) async function fetchCart() { const userStore useUserStore() items.value await getCartByUserId(userStore.userInfo!.id) } return { items, fetchCart } })这里有一个关键点在 setup 函数内部使用另一个 store 时直接调用 useUserStore() 就可以。因为 store 被创建之后会注册到一个全局的 Pinia 实例上这种跨模块调用在 TypeScript 下也能拿到完整类型不会有路径混乱的问题。不过要注意循环依赖两个 store 互相引用彼此的 action 时业务上要确认它不会无限调用下去。我自己遇到过 cart 调用 user、user 又调用 cart 的循环依赖最后流程翻车排查半天才发现是初始化顺序问题。建议把跨 store 的调用限制在 action 层不用在 setup 顶层做复杂的互引。4. 工程化落地模块化拆分与状态持久化4.1 多模块拆分的组织模式项目变大之后不可能把所有的状态都堆在一个 store 里。Pinia 的模块化思路比 Vuex 更接近“一个业务域一个文件”的组织方式你只需要在 stores 目录下创建对应的 store 文件即可。我的习惯是按业务领域拆分而不是按页面拆分。例如一个电商后台项目我会分成stores/user.ts用户信息、登录状态、权限stores/cart.ts购物车数据、增减购物车逻辑stores/order.ts订单列表、订单详情、下单流程stores/app.ts全局 UI 状态侧边栏折叠、主题等拆分的原则是一个 store 里的 state 应该在业务上具有内聚性可以一起更新、一起失效。不要把“用户选择的城市”放在“商品列表”的 store 里如果两个 store 之间频繁互调说明边界没切对。在 store 文件数量多了之后还可以在 stores/index.ts 里统一导出export { useUserStore } from ./user export { useCartStore } from ./cart export { useOrderStore } from ./order这样组件里引用就变成import { useUserStore, useCartStore } from /stores阅读起来会更清楚也方便后期统一做改造。4.2 持久化方案手写 vs 插件状态管理最绕不开的一个需求就是状态持久化比如用户 token、主题设置、购物车数据要在刷新后保留。最简单的做法是用 watch 手动同步到 localStorageimport { watch } from vue const STORAGE_KEY user-info export const useUserStore defineStore(user, () { const userInfo refUserInfo | null(JSON.parse(localStorage.getItem(STORAGE_KEY) || null)) function setUserInfo(info: UserInfo) { userInfo.value info } watch(userInfo, (value) { localStorage.setItem(STORAGE_KEY, JSON.stringify(value)) }, { deep: true }) return { userInfo, setUserInfo } })这种方式的优点是依赖少、逻辑透明、可控性高缺点是每个需要持久化的 store 都要重复写一遍类似的 watch 逻辑字段一多就容易漏水。如果你需要持久化的 store 不止一两个推荐用官方社区维护的插件 pinia-plugin-persistedstate。安装后只需要在 store 定义时加一个 persist 配置export const useUserStore defineStore( user, () { const userInfo refUserInfo | null(null) return { userInfo } }, { persist: { key: user-info, storage: localStorage, }, } )这个插件会自动监听 store 的变化按 key 存储并在初始化时自动恢复。它还支持 paths 配置只持久化部分字段避免把一些临时状态也塞进 localStorage。用了持久化插件之后有一个新问题初始化时从 localStorage 读取的旧数据结构可能和后端返回的新结构不一致。我的建议是永远把持久化当缓存看待组件里使用 userInfo 之前做一层默认值兜底不要直接信任缓存数据。4.3 配合 Devtools 和测试把调试做舒服Pinia 和 Vue Devtools 的集成做得很到位不过你需要确认两件事。第一浏览器装了 Vue Devtools 对应的版本。Pinia 的调试面板会显示在 Devtools 的 Pinia 标签页里包含每个 store 的 state、getters、actions还会记录 action 的调用时间和参数。![纯文本示意不渲染图片]第二如果想在非组件文件中调用 store 进行测试或逻辑复用需要先手动注册活跃的 pinia 实例。比如在路由守卫中使用 storeimport { createPinia } from pinia import { useUserStore } from /stores/user const pinia createPinia() router.beforeEach(async (to) { const userStore useUserStore(pinia) if (!userStore.token to.path ! /login) { return /login } })这里的关键是 useUserStore(pinia) 传入 pinia 实例。当然实际项目中通常入口文件已经 app.use(pinia) 了如果路由守卫文件是在创建 app 之后注册的直接 useUserStore() 不带参数也能工作但如果你把路由守卫抽到了独立模块且模块加载时 Pinia 还没有注册就必须显式传递 pinia 实例。关于测试Pinia 和 Vitest 搭配非常自然。测试时创建独立的 pinia 实例用 setActivePinia 激活然后调用 store 里的 action 验证状态变化。这种方式不依赖真实接口和浏览器环境跑起来又快又稳。import { setActivePinia, createPinia } from pinia import { useCounterStore } from /stores/counter describe(counter store, () { beforeEach(() { setActivePinia(createPinia()) }) it(increment increases count by step, () { const store useCounterStore() store.increment() expect(store.count).toBe(2) }) })5. 常见问题与避坑记录全是真实踩过的5.1 解构丢响应性storeToRefs 救场前面已经提到过直接从 store 解构 state 会让响应性丢失。这是 Pinia 使用中排名第一的翻车场景。看一段典型的错误代码script setup langts import { useUserStore } from /stores/user const userStore useUserStore() const { userName } userStore // 直接解构userName 变成普通字符串 function handleClick() { userStore.fetchUser() // 此时 userName 不会随着 fetchUser 的赋值变化 } /script正确写法import { storeToRefs } from pinia const userStore useUserStore() const { userName } storeToRefs(userStore)值得说明的是storeToRefs 对 state 和 getters 都有效但对 action 是无效的因为 action 本身就是普通函数不需要响应式包装。所以解构 action 时直接用原来的方式即可const { fetchUser } userStore5.2 Options Store 与 Setup Store 的选择Pinia 提供了两种定义方式Options Store 和 Setup Store。我自己的项目里两种都写过给你一点真实的选型感受。Options Store 写法export const useUserStore defineStore(user, { state: () ({ userInfo: null as UserInfo | null, loading: false, }), getters: { isLoggedIn: (state) !!state.userInfo, }, actions: { async fetchUser(userId: number) { this.loading true try { this.userInfo await getUserInfo(userId) } finally { this.loading false } }, }, })Setup Store 写法前面已经展示过。两种方式功能上是等价的我建议团队背景是 Vuex 或 Options API 风格可以先用 Options Store认知成本低。新项目或者团队已经熟练 Composition API直接上 Setup Store代码组织更自由逻辑复用更方便。不管哪种方式都不要在 store 里存放组件实例或者 DOM 引用Pinia 的定位是管理业务状态和逻辑把 UI 状态交给组件内部处理否则 store 的复用性会大打折扣。5.3 $subscribe 与 $onAction监听的高级用法除了用 watch 监听某个 statePinia 还提供了两个更贴近状态管理语义的 API。$subscribe 用于监听 state 的变化类似 Vuex 的 subscribe。它的优势是即使你不确定具体哪个字段变了也能拿到整体的变化上下文const userStore useUserStore() userStore.$subscribe((mutation, state) { console.log(触发方式, mutation.type) console.log(变化的 storeId, mutation.storeId) localStorage.setItem(user-info, JSON.stringify(state)) })这里的 mutation.type 有两种direct 表示直接修改patch object 和 patch function 表示通过 $patch 修改。如果你希望 store 发生任何变化时都触发一个切面逻辑比如埋点、同步缓存用 $subscribe 比手写多个 watch 更干净。$onAction 用于监听 action 的调用时机可以拿到 action 名称、参数、返回值、错误信息等userStore.$onAction(({ name, args, after, onError }) { console.log(开始调用 action${name}参数, args) after(() console.log(action ${name} 执行完成)) onError((error) console.error(action ${name} 执行失败, error)) })这个 API 非常适合在团队里做统一的行为日志采集比如记录用户修改了哪些状态、调用了哪些接口比在每一个 action 里埋点要省事得多。5.4 非组件文件中误用getActivePinia is undefined这是一个非常常见的问题尤其是项目里把 store 用于路由守卫、接口请求工具函数、纯工具库时。错误场景示例// utils/auth.ts import { useUserStore } from /stores/user export function isAuthenticated() { const userStore useUserStore() // 在这里调用时 Pinia 还没有激活 return !!userStore.token }这个工具函数如果在组件中调用通常没问题但如果它被一个模块加载阶段执行的代码调用比如路由配置、拦截器初始化Pinia 实例还没有注入就会报错。解决方案有两种。第一种是显式传入 pinia 实例import { createPinia } from pinia const pinia createPinia() export function isAuthenticated() { const userStore useUserStore(pinia) return !!userStore.token }第二种更推荐把 store 的使用推迟到函数真正执行的时候。大多数情况下我们不需要在模块加载阶段读取状态只需要在事件触发时读取。这样既能避免初始化顺序问题也符合依赖注入的最佳实践。5.5 其他高频坑汇总写下几个我在社区和实际项目中高频遇到的问题整理成一张速查表问题现象原因解决方案Vue Devtools 里看不到 Pinia 面板安装了不支持 Pinia 的旧版 Devtools升级到最新版 Devtools$reset 报不存在Setup Store 没有内置 $reset手动实现 reset 函数Setup Store 中 this 含义不明确写法混用试图用 this 访问状态统一使用闭包变量不要用 thislocalStorage 中的数据是旧结构持久化缓存未清理或未做兼容初始化时做数据清洗合并新旧结构组件间状态不同步误用普通变量代替 storeToRefs 解构统一用 storeToRefs 解构 state/getters多个页签之间状态不同步localStorage 事件未监听使用 window.addEventListener(storage, handler) 同步接口并发导致 store 状态覆盖多个 action 同时写入同一个 state 字段在 action 内做请求合并或竞态取消判断关于接口并发覆盖这个问题值得再强调一句。如果你的页面同时发起多个请求都写入 userStore 的同一字段后返回的结果可能覆盖先返回的结果。更稳妥的做法是在 action 内部记录一个请求序号或者在请求发起前做一次取消控制判断响应是否仍是当前请求避免竞态条件影响数据正确性。色。6. 经验总结个人向从 Vuex 切到 Pinia我自己最大的感受其实不是代码变少了而是心智负担变少了。以前写状态管理脑子里要装着一套“命令传输协议”做什么都要先想怎么 commit、怎么分发。现在更像是直接在一个响应式对象上写业务逻辑对外暴露的就是一个可调用的函数和可读的状态组件里去消费它没有任何心智负担。如果你正在学 Pinia我建议不要只是看文档而是找一个真实的小项目把至少一个页面的数据请求、表单交互、路由参数共享都放进 store 里重写一遍。只有真正动手写过一遍你才会理解为什么 Pinia 会被 Vue 官方当作默认的状态管理方案。最后分享一个小技巧新项目创建时我通常会把 Pinia、Vue Router、ESLint Prettier、Vitest 一次性搭好。状态管理和路由属于项目地基地基稳了后面的开发效率会高很多。别等到页面写了一半再来回头补状态管理那个时候改造成本就高了。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。