从Vuex到Pinia:Vue状态管理新选择,少写代码更高效
1. 为什么我从 Vuex 换到了 Pinia一次少写五百行代码的体验先说结论如果你是 Vue 开发者现在做新项目直接选 Pinia别犹豫。这不是什么激进推荐而是 Vue 官方都已经钦定的路线——Vue 3 的官方文档里Pinia 就是默认的状态管理库。如果你还在 Vuex 里纠结 module 嵌套、mutations 那一大堆样板代码这篇分享应该能帮你省下不少头发。Pinia 这个名字你可能已经听过它的核心定位是 Vue 的状态管理方案。所谓状态管理说白了就是解决多个组件之间共享数据这个刚需登录信息、用户资料、购物车列表、主题配置这些东西如果每个组件都自己维护一份等着你的就是数据不同步、改动到处飞、调试靠玄学的灾难现场。Pinia 把这些共享数据集中起来做成一个个 Store状态仓库组件该读就读、该改就改数据流清晰可控。Pinia 在设计上最打动我的是两件事第一它彻底砍掉了 mutations动作和状态修改都在 actions 里完成这意味着你要写的心智负担和代码量直接减半第二它天然支持 Vue 2 和 Vue 3老项目想迁移不用推倒重来。这篇文章我会从基础概念、核心特性、多 Store 实战到持久化方案完整跑一遍我在真实项目里的用法和踩过的坑里面的代码都是直接抄过线的带注释、带原理说明希望能帮你少走点弯路。2. Pinia 基础篇Store 究竟是什么怎么定义和上手2.1 认识 Store 的三个核心成员State、Getters、ActionsPinia 的 Store 本质上就是一个用defineStore定义的数据仓库里面有三类成员理解成数据的三个管家就行State仓库里存放的数据本体相当于一个响应式的全局对象。组件里可以直接读取也可以修改Pinia 没有 Vuex 那种必须通过 mutation 才能改的约束。Getters基于 State 派生的数据相当于计算属性。比如购物车列表存的是原始商品数据要算总价就在 getter 里做多处组件共用这个逻辑不用各自写一遍。Actions修改数据的动作方法相当于执行者。可以在这里处理异步请求、业务逻辑然后更新 State。因为是普通方法所以你在里面await接口、写try/catch都很自然。我用一个最常见的用户状态模块来演示最基础的写法// stores/user.js import { defineStore } from pinia export const useUserStore defineStore(user, { // 选项式写法结构上跟 Vuex 很接近上手最快 state: () ({ name: , age: 0, token: , isLoggedIn: false }), getters: { // 注意getters 里的方法可以直接用 this 访问当前 store displayName: (state) state.name || 未登录游客 }, actions: { // 这里支持异步登录逻辑直接放进来 async login(payload) { const res await api.login(payload) this.token res.token this.name res.name this.isLoggedIn true }, logout() { this.token this.name this.isLoggedIn false } } })这个文件写完之后在任何组件里都能这样用script setup import { useUserStore } from /stores/user const userStore useUserStore() /script template p当前用户{{ userStore.displayName }}/p button clickuserStore.login({ name: 张三, password: 123456 })登录/button button clickuserStore.logout()退出/button /template注意一个细节在组件里必须先调用useUserStore()拿到 store 实例才能读取属性。这跟 Vuex 里直接this.$store不一样很多人刚上手会忘记这一步结果报错说 store 未定义。原因其实很简单——Pinia 依靠的是 Vue 的依赖注入机制必须通过这个函数激活上下文才能拿到数据仓库。2.2 组合式写法跟 setup 语法同频的更灵活方案选项式写法结构清晰但如果你项目里大量使用 Vue 3 的组合式 APIref、computed、watchPinia 还提供了一种组合式的 Store 写法自由度更高。本质上就是把 Store 定义成一个函数函数内部你可以用任何组合式 API 来组织逻辑// stores/counter.js import { defineStore } from pinia import { ref, computed } from vue export const useCounterStore defineStore(counter, () { // state用 ref 定义 const count ref(0) // getters用 computed 定义 const doubleCount computed(() count.value * 2) // actions用普通函数定义 function increment() { count.value } async function fetchAndSet() { const res await fetch(/api/count) count.value res.number } // 必须 return 出去组件才访问得到 return { count, doubleCount, increment, fetchAndSet } })组合式写法最舒服的地方在于你可以在 Store 里使用watch、computed甚至别的 Store把复杂业务逻辑封装得干干净净。还能把一个 Store 当做一个业务模块来组织跟Vuex module对比一下少掉的不只是 mutations还有 module 之间嵌套引用时那层让人头疼的命名空间。提示两种写法在同一个项目里可以混用defineStore的第一个参数是唯一 ID保证不重复就行。实际项目中我比较推荐以选项式为主、组合式为辅因为选项式的结构对新人更友好看代码一目了然。3. 核心特性拆解响应式、解构、订阅、DevTools 这些让你少掉头发的设计3.1 storeToRefs解构出来还能保持响应性这是 Pinia 使用频率最高、也最容易踩坑的一个点。在组件里直接解构 store会丢失响应性script setup import { useUserStore } from /stores/user const userStore useUserStore() // 这样解构state 变成一次性快照改了 store 里的值页面不会更新 const { name, isLoggedIn } userStore /script原因很简单userStore是一个通过reactive包装过的响应式对象解构出来的name变成了一个普通字符串失去了依赖追踪。解决方案是使用 Pinia 提供的storeToRefs方法script setup import { storeToRefs } from pinia import { useUserStore } from /stores/user const userStore useUserStore() // 使用 storeToRefs 解构保持响应性 const { name, isLoggedIn } storeToRefs(userStore) // 注意actions 不能解构必须直接从 store 上拿 const { login, logout } userStore /script这里有个容易混淆的点storeToRefs只能处理 state 和 getters 的响应性actions 是普通函数不需要响应包装直接解构没问题。如果你把 actions 也放进storeToRefs里返回值会被转成 ref调用时就得写.value反而搞复杂了。3.2 $patch 批量更新与直接赋值的取舍Pinia 允许直接给 state 赋值这是它相对 Vuex 最爽的一点。但频繁修改多个属性时直接赋值会触发多次更新通知性能上有损耗、代码也乱。Pinia 提供了$patch方法来做批量修改// 方式一直接赋值简单但多次触发更新 userStore.name 张三 userStore.age 30 userStore.token abc123 // 方式二$patch 传入对象一次性更新 userStore.$patch({ name: 张三, age: 30, token: abc123 }) // 方式三$patch 传函数适合带逻辑的批量修改 userStore.$patch((state) { state.name 张三 state.age 1 state.token abc123 })我在项目里的习惯是单属性更新直接赋值多属性联动更新用$patch遇到要计算新值的逻辑比如累加用函数形式。$patch 还有个隐性优势——它会把整个更新过程合并成一次通知$subscribe的监听回调只会执行一次对性能敏感的场景帮助很明显。3.3 $subscribe 和 $onAction状态变化的监听拦截跨组件通信之外有时候你需要在状态变化时做一些额外操作比如把变动同步到 localStorage、上报埋点、联动刷新其它模块。Pinia 提供了两个监听方法// $subscribe监听 state 变化类似 watch 的效果 userStore.$subscribe((mutation, state) { // mutation 里有事件相关的信息state 是当前最新状态 console.log(状态变了, mutation.type, mutation.payload) localStorage.setItem(user_cache, JSON.stringify(state)) }) // $onAction监听 actions 的执行生命周期 userStore.$onAction(({ name, args, after, onError }) { console.log(action ${name} 被调用, args) after((result) { console.log(action ${name} 执行完成结果, result) }) onError((error) { console.error(action ${name} 执行失败, error) }) })这两个方法在调试复杂交互时特别好用。有一次我排查一个用户资料莫名其妙被清空的线上问题就是靠$onAction打印调用日志最终发现是一个组件的watch在 store 初始化时把空数据写进去了。这类跨组件、跨时机的隐形数据流问题没有监听手段排查起来只能靠猜。3.4 DevTools时间旅行调试Vuex 的地狱级痛点被解决了状态管理最怕的就是状态怎么变成这样的Vuex 时代调试器经常卡顿、状态树结构复杂难读。Pinia 对 Vue DevTools 的支持是目前最佳的那个——安装 Vue DevTools 后侧边栏直接展示每个 store 的实时状态可以看 actions 调用历史支持时间旅行点击任意历史状态页面瞬间回退到那个时刻。这个功能在开发复杂交互页面时就是救命稻草。之前做一个条件组合筛选的报表页多级联动筛选条件把状态改得一团乱我用时间旅行一步步定位到是某个异步请求返回后重复赋值导致的。没有这个工具光靠console.log得折腾一整天。提示在 Vue2 项目里使用 Pinia需要确保 DevTools 版本适配旧浏览器环境Vue2.6 及以下还需要先安装vue/composition-api后面适配章节我会详细说。4. 多 Store 实战项目大了怎么拆分和互相协作4.1 按业务领域划分不要把 Store 当成全局垃圾桶很多从 Vuex 迁移过来的项目有个通病把所有数据塞进一两个大 Store结果 Store 越来越臃肿改一处带动几十处。Pinia 的多 Store 设计就是为了治这个问题——按业务领域拆成小而独立的模块每个 Store 只管自己的事。以我最近做的一个电商后台系统为例拆成了这些 StoreuseUserStore用户登录信息、权限角色、偏好设置useCartStore购物车商品列表、金额计算、结算状态useProductStore商品列表、筛选条件、分页信息useOrderStore订单查询、订单详情、订单状态流转useAppStore全局 UI 状态侧边栏开合、主题模式、语言环境划分的原则是高内聚、低耦合一个 Store 内的状态更新逻辑彼此相关不同 Store 之间尽量少依赖。这样做最大的回报是维护成本直线下降——改购物车逻辑不用在庞大的全局状态里翻找新成员接手项目看代码也不需要通读全篇才能动手。4.2 Store 之间互相调用在 action 里直接用别的 store多 Store 难免要互相协作比如用户购买商品后购物车 Store 要读取用户 Store 里的会员等级来计算折扣。Pinia 的解决方案非常自然在 action 方法里直接调用其他 Store 的实例即可// stores/cart.js import { defineStore } from pinia import { useUserStore } from /stores/user import { useProductStore } from /stores/product export const useCartStore defineStore(cart, { state: () ({ items: [] }), getters: { totalPrice(state) { const userStore useUserStore() const productStore useProductStore() const rawTotal state.items.reduce((sum, item) { const product productStore.products.find(p p.id item.productId) return sum (product ? product.price * item.quantity : 0) }, 0) // 会员打 9 折非会员不打折 return userStore.isVip ? rawTotal * 0.9 : rawTotal } }, actions: { async checkout() { const userStore useUserStore() if (!userStore.isLoggedIn) { throw new Error(请先登录) } // 提交订单逻辑... } } })理解这个机制很关键Pinia 的 Store 实例是注册在全局实例上的在 action 或 getter 里调用useUserStore()可以直接获取到同一个单例对象数据永远是最新的。但要注意循环引用问题——如果 Store A 在 action 里调用 Store BStore B 又在 action 里调用 Store A会形成死循环。我遇到过一次最后通过在其中一个 Store 里使用markRaw或者把公共逻辑抽到第三个 Store 解决的。4.3 组合式 Store复用业务逻辑的进阶玩法除了按领域拆分Pinia 的组合式写法还能实现逻辑复用的层级。比如项目里有好几个模块都需要分页加载列表数据这套逻辑页码状态、加载状态、数据列表、加载下一页的动作。与其在每个 Store 里复制粘贴不如抽一个通用组合式 Store 工厂// stores/usePaginatedList.js import { defineStore } from pinia import { ref, computed } from vue export function createPaginatedListStore(id, fetchApi) { return defineStore(id, () { // 页码、页大小、列表数据、加载状态 const page ref(1) const pageSize ref(20) const list ref([]) const total ref(0) const loading ref(false) const hasMore computed(() list.value.length total.value) async function loadMore() { if (loading.value || !hasMore.value) return loading.value true try { const res await fetchApi({ page: page.value, pageSize: pageSize.value }) list.value.push(...res.list) total.value res.total page.value 1 } finally { loading.value false } } function reset() { page.value 1 list.value [] total.value 0 } return { page, pageSize, list, total, loading, hasMore, loadMore, reset } }) }使用的时候分别创建出自己的 Store// stores/orderList.js import { createPaginatedListStore } from ./usePaginatedList import { fetchOrders } from /api/order export const useOrderListStore createPaginatedListStore(orderList, fetchOrders)这个模式在 Taro/H5 双端项目里尤其好用——相同的列表逻辑在多个小程序页面复用少写重复代码这种话说一百遍都不如直接抽一个工厂函数来得实在。5. 持久化全实战刷新不丢数据从 localStorage 到 plugin 方案5.1 为什么需要持久化刷新页面状态全丢的问题Pinia 的 Store 默认是基于内存的页面一刷新所有状态归零。这在很多场景下是不可接受的登录后刷新要求重新登录、表单填到一半刷新内容全没、购物车商品刷新就清空。解决方案是持久化——把状态同步到浏览器的 localStorage 或 sessionStorage 中页面加载时读回来。持久化的核心有两点什么时候写、什么时候读。写入时机一般是 state 发生变化时通过$subscribe监听读取时机一般是在 Store 初始化时从 localStorage 初始化 state。5.2 手写持久化用 $subscribe 实现一个简单的同步方案很多项目其实不需要引入额外的持久化库自己写也花不了多少时间。我最初的项目就是直接在 Store 里写的// stores/user.js import { defineStore } from pinia const STORAGE_KEY pinia_user export const useUserStore defineStore(user, { state: () { // 读取本地缓存作为初始值 const cached JSON.parse(localStorage.getItem(STORAGE_KEY) || null) return { name: cached?.name || , age: cached?.age || 0, token: cached?.token || , isLoggedIn: cached?.isLoggedIn || false } }, actions: { // 统一的保存方法在关键操作后调用 saveToStorage() { localStorage.setItem(STORAGE_KEY, JSON.stringify({ name: this.name, age: this.age, token: this.token, isLoggedIn: this.isLoggedIn })) }, // 清空缓存登出时用 clearStorage() { localStorage.removeItem(STORAGE_KEY) } } })这个方案够用但有个隐患如果多个组件都修改了 store 的 state你很容易忘记调用saveToStorage导致缓存没更新。更稳妥的做法是结合$subscribe让每次 state 变化都自动同步// main.js 或应用的初始化文件里 import { useUserStore } from /stores/user const userStore useUserStore() userStore.$subscribe((mutation, state) { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)) })注意$subscribe的写法要在拿到 store 实例之后注册。如果你把这段写到 Store 定义文件里请把它放在useUserStore()被调用后否则 Pinia 会警告你store 尚未被激活。5.3 使用 pinia-plugin-persistedstate两条命令搞定持久化自己手写方案灵活但项目大了每个 Store 都要写一遍存储逻辑也很繁琐。此时用社区成熟的插件更省心我推荐pinia-plugin-persistedstate它上手极快npm install pinia-plugin-persistedstate然后在入口文件里注册// main.js import { createPinia } from pinia import piniaPluginPersistedstate from pinia-plugin-persistedstate const pinia createPinia() pinia.use(piniaPluginPersistedstate) app.use(pinia)定义 Store 时加一个persist配置export const useUserStore defineStore(user, { state: () ({ name: , age: 0, token: }), // 只需要加这一行 persist: true })默认情况下它会把这个 Store 的整个 state 以storeId为 key 存入 localStorage。如果你想自定义存储方式比如用 sessionStorage或者只存部分字段可以传配置对象export const useUserStore defineStore(user, { state: () ({...}), persist: { key: custom_user_key, // 自定义存储 key storage: sessionStorage, // 换成 sessionStorage pick: [token, name] // 只持久化指定字段 } })我在实际项目中主要用pick这个配置——有些状态比如临时计算值、弹窗开关根本不需要持久化存了反而占空间、还可能恢复出脏数据。把持久化范围精确控制住是避免很多诡异 bug 的好习惯。5.4 深度定制把持久化插件写成自己的 utils 函数虽然第三方插件方便但有些团队会追求更可控的方案。我后来就把持久化逻辑封装成了一个可复用的工具函数既能统一处理加密、版本号校验又能在多个 Store 间保持一致的存储策略// utils/persist.js export function createPersistentStore(storeId, storage localStorage) { // 返回一个持久化配置对象供 Store 的 persist 字段使用 return { key: myapp_${storeId}, storage, beforeHydrate: () { console.log(正在恢复 ${storeId} 的状态) }, afterHydrate: (ctx) { console.log(${storeId} 状态恢复完成当前数据, ctx.store.$state) } } }实际上手写持久化插件比想象中复杂的地方在于水合hydration——从存储中读数据回填到 Store 的时机要正确否则可能出现状态被覆盖、组件渲染时数据还没恢复等问题。我建议新手先直接用成熟插件等项目跑顺了再去定制自己的方案。提示持久化时注意不要存储敏感信息比如密码、完整的身份证号。localStorage 存的是明文任何人打开开发者工具都能看。生产环境建议对敏感字段做脱敏处理或者改用后端会话管理。5.5 IndexedDB 场景localStorage 装不下大体积数据怎么办localStorage 的容量一般是 5MB 左右而且强制同步读取存大对象时会阻塞主线程。如果你要用 Pinia 管理上传的图片 base64、离线缓存的大量列表数据建议改用 IndexedDB。这块内容属于持久化的进阶方向我这里给一个大致的思路细节以后可以单独写一篇展开。基本做法是Pinia 的 state 只保留必要的小字段和索引信息真实的大对象数据存储在 IndexedDB 里Store 的 actions 负责读写 IndexedDB页面初次加载时异步获取数据回填。这套方案在做离线可用的 H5 应用或者 Electron 桌面应用时特别实用本质上是把持久化从同步缓存升级成了客户端数据库。6. Vue2 / Vue3 适配实战老项目迁移的完整流程和坑位汇总6.1 适配前置条件Vue 2.7 与 Vue 2.6 的区别Pinia 官方支持 Vue 2 和 Vue 3但适配方式略有不同。Vue 2 里有一个分水岭版本Vue 2.7。如果你用的 Vue 2.7本身内置了 Composition API安装 Pinia 后直接可用非常省心。如果你还在 Vue 2.6 及以下的老版本必须先安装vue/composition-api插件否则 Pinia 的 store 会因为找不到组合式 API 而报错。# Vue 2.7 以上的项目 npm install pinia2 # Vue 2.6 及以下的老项目 npm install pinia2 vue/composition-api6.2 安装和注册Vue 3 用 app.useVue 2 用 Vue.useVue 3 和 Vue 2 的注册方式有一个看似细小的差别但搞错了整个应用都不会工作。Vue 3 的入口文件// Vue 3 入口 main.js 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)Vue 2 的入口差异在于项目没有createApp而是直接往 Vue 实例上挂// Vue 2 入口 main.js以 Vue 2.7 为例 import Vue from vue import { createPinia } from pinia import App from ./App.vue const pinia createPinia() new Vue({ pinia, // 注意Vue 2 是通过实例选项注入而不是 app.use render: h h(App) }).$mount(#app)这段代码第一次写时很容易踩坑在 Vue 2 项目里用app.use(pinia)是没有效果的必须把pinia实例作为选项传给根实例。6.3 Options API 和 Composition API 在 Vue 2 里的使用姿势Vue 2 项目里更多的是 Options API 语法这时候访问 store 的姿势跟 Vue 3 的setup有些不一样。在 Vue 2 组件里需要在created钩子里先获取 store 实例再传递到模板script import { useUserStore } from /stores/user export default { data() { return { // 这里不能直接调用 useUserStore()因为 setup 还没构建 userStore: null } }, created() { // 必须在实例创建后才能拿到 store this.userStore useUserStore() }, computed: { displayName() { return this.userStore ? this.userStore.displayName : } }, methods: { handleLogin() { this.userStore.login(this.loginForm) } } } /script如果项目里已经用了 Vue 2.7 的组合式 API那两者可以混着来在setup()里直接调useUserStore()就行。但不能在data里初始化 store——我试过会直接报getActivePinia was called with no active Pinia错误原因就是 Pinia 需要在一个有活动实例的上下文里被调用。6.4 Vue 2 响应式差异defineProperty 和 Proxy 的持久化陷阱Vue 2 的响应式底层是Object.defineProperty跟 Vue 3 的 Proxy 有本质区别。这个差异在 Pinia 持久化项目中会引出几个容易被忽略的问题第一新增属性不会响应式。如果你在 Vue 2 项目里给 state 对象动态添加新字段vue 不会追踪它页面渲染不到。Vue 3 用 Proxy 没有这个问题。解决方案是预先在 state 中声明所有字段或者用 Vue 2 的Vue.set方法。第二数组索引修改失效。Vue 2 对数组的索引变化无法深度监听this.items[0] newItem不会触发更新在 Pinia 里也一样受限。建议使用$patch配合新数组替换的方式// Vue 2 Pinia 里更新数组的正确姿势 cartStore.$patch((state) { const newItems [...state.items] newItems[0] newItem state.items newItems })第三DevTools 的时间旅行在 Vue 2 项目里可能不准。因为 Vue 2 的响应式劫持方式对某些边界情况的追踪不完整导致部分状态变化没有被记录。遇到这种情况别慌先在组件里打印确认数据再决定是否升级到 Vue 3。6.5 从 Vuex 迁移到 Pinia 的操作步骤和注意事项老项目从 Vuex 迁移到 Pinia不建议大改代码推倒重来而是分步骤平滑过渡第一步安装 Pinia 并注册到入口文件Vuex 暂时保留两者可以共存。第二步把最常用的 Store如 user、cart从 Vuex module 迁移到 Pinia Store。Vuex 里有state / mutations / getters / actions对应迁移到 Pinia 是state / getters / actionsmutations 里的方法直接搬到 actions 即可。第三步修改组件里的调用方式。Vuex 是this.$store.state.user.namePinia 是this.userStore.name需要把每个组件里的引用逐一替换。我建议这里优先用storeToRefs解构少改模板里的绑定路径。第四步全部迁移完移除 Vuex 相关代码和依赖。迁移过程中最需要注意的是module 嵌套扁平化。Vuex 支持modules: { a: { namespaced: true }}这种嵌套结构Pinia 建议每个命名空间都是独立 Store 文件访问路径短、调用方式统一。嵌套太深的数据结构要靠 getters 来解开不要在 Store 之间层层引用。7. 实操体验一整个流程跑下来的心得与避坑清单开发这么多年从 Vuex 时代一路试到 Pinia如果在实际项目里让我总结什么场景最适合用 Pinia我会说几乎所有需要跨组件共享状态的中大型 Vue 项目都比 Vuex 更值得选用。特别是团队里有新人的情况Pinia 的 API 简单直观稍微看几个例子就能上手不需要先啃一遍 mutations/actions/getters 的职责边界和约束规则。分享两个我在项目中长期积累的使用习惯其一所有 Store 文件的命名用驼峰 领域前缀比如useUserStore、useCartStore统一风格后 IDE 的自动补全和全局搜索都痛快很多。其二每个 Store 的 actions 尽量保持单一职责比如fetchUserInfo就只拉取用户信息并更新 state不要在 action 里又弹 toast 又调别的接口出问题的时候日志都难分析。再把避坑清单汇总一份都是我踩过的或者看别人踩过的写在这里权当参考问题现象原因解决方案调用 useStore 报 no active Pinia入口文件没有正确注册 pinia 实例检查app.use(pinia)或 Vue2 的new Vue({ pinia })解构 state 后页面不更新直接解构导致失去响应式用storeToRefs解构持久化后刷新数据恢复失败state 初始值里没有从 localStorage 读取在 state 初始化时读缓存或配置persist: trueVue 2 项目新增 state 字段不生效defineProperty 无法拦截新属性预先声明全部字段或用$patch整体替换Store 引用循环报错A Store 调 B StoreB 又调 A抽公共逻辑到新 Store或把调用放到 action 内延迟执行Vuex module 嵌套过深迁移时没扁平化拆分多个 Pinia Store用 getters 组织派生数据写到这里关于 Pinia 我已经把从选型到实战再到迁移的关键点都讲透了。这个库最让我满意的地方是它把状态管理这件很重的概念拆解成了极简心智模型——不需要记规则、不需要绕弯子写代码的思路跟业务逻辑本身顺着走就行。你如果也在做状态管理选型或者准备迁移这个方向我觉得值得优先考虑。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →