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、Actions
Pinia 的 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 @click="userStore.login({ name: '张三', password: '123456' })">登录</button> <button @click="userStore.logout()">退出</button> </template>注意一个细节:在组件里必须先调用useUserStore()拿到 store 实例,才能读取属性。这跟 Vuex 里直接this.$store不一样,很多人刚上手会忘记这一步,结果报错说 store 未定义。原因其实很简单——Pinia 依靠的是 Vue 的依赖注入机制,必须通过这个函数"激活"上下文才能拿到数据仓库。
2.2 组合式写法:跟 setup 语法同频的更灵活方案
选项式写法结构清晰,但如果你项目里大量使用 Vue 3 的组合式 API(ref、computed、watch),Pinia 还提供了一种组合式的 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 只管自己的事。
以我最近做的一个电商后台系统为例,拆成了这些 Store:
useUserStore:用户登录信息、权限角色、偏好设置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 B,Store 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 pinia@2 # Vue 2.6 及以下的老项目 npm install pinia@2 @vue/composition-api6.2 安装和注册:Vue 3 用 app.use,Vue 2 用 Vue.use
Vue 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 / actions,mutations 里的方法直接搬到 actions 即可。 - 第三步:修改组件里的调用方式。Vuex 是
this.$store.state.user.name,Pinia 是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: true |
| Vue 2 项目新增 state 字段不生效 | defineProperty 无法拦截新属性 | 预先声明全部字段,或用$patch整体替换 |
| Store 引用循环报错 | A Store 调 B Store,B 又调 A | 抽公共逻辑到新 Store,或把调用放到 action 内延迟执行 |
| Vuex module 嵌套过深 | 迁移时没扁平化 | 拆分多个 Pinia Store,用 getters 组织派生数据 |
写到这里,关于 Pinia 我已经把从选型到实战再到迁移的关键点都讲透了。这个库最让我满意的地方,是它把状态管理这件"很重的概念"拆解成了极简心智模型——不需要记规则、不需要绕弯子,写代码的思路跟业务逻辑本身顺着走就行。你如果也在做状态管理选型或者准备迁移,这个方向我觉得值得优先考虑。