如果有人问我Vue 项目里的状态管理现在到底选 Vuex 还是 Pinia我的回答一向很干脆新项目直接 Pinia老项目也值得花时间迁过来。去年我把一个中型后台管理系统从 Vuex 整体迁到 Pinia前后折腾了两天中间踩了不少坑也把模块化拆分、持久化、跨组件通信这些日常用到手软的能力彻底摸了一遍。这篇文章不是文档翻译也不是概念复读是我在真实业务场景里过滤过一遍之后觉得最值得你知道的东西。如果你正准备上手 Pinia或者已经在用但总感觉模块化做得不够舒服、刷新页面状态就丢、store 越写越乱那这篇文章应该能给你一些可以落地的东西。我会从为什么换掉 Vuex 讲起然后一步步拆解 store 怎么写、模块怎么拆、持久化怎么上最后把我踩过的一个生产环境事故完整复盘给你看。1. 从 Vuex 迁移过来当天我为什么决定不再回去1.1 Vuex 让我憋屈的四个点先别急着说我喜新厌旧。Vuex 在那个时代确实解决了组件通信的痛点但用到后面尤其在维护一个三四个人协作的中型项目时它是真的让人难受。第一是 mutations 和 actions 的职能分裂。更新状态必须 commit mutation有异步逻辑就必须再过一层 action于是你写任何功能几乎都是双份代码。一个简单的设置用户信息操作我需要在 store 里定义一个 setUserInfo 的 mutation再定义一个同名或类似名的 action然后组件里 dispatch。这个流程本身不算复杂但项目一大满屏都是这些机械重复的样板代码。第二是命名空间的胶水代码。Vuex 里开启 namespaced 之后mapState、mapActions 都要写模块路径字符串比如 mapActions(user/profile, [updateAvatar])。一旦路径写错开发和排错就是一场噩梦。而且字符串没法被 IDE 正确跳转重命名模块时全局替换的酸爽谁用谁知道。第三是 TypeScript 支持约等于没有。Vuex 的 state 想在组件里获得完整的类型推断要写一堆 module 增强代码。在大型项目里这基本等于劝退。我见过不少项目为了迁就 Vuex 把 TS 强行降级成AnyScript最后类型保护形同虚设。第四是模块嵌套越深状态流越难追踪。父模块引用子模块子模块又引用兄弟模块的状态一个页面操作可能要跨两三个模块协作你根本分不清数据是被谁改的。1.2 Pinia 把状态管理拉回了写代码的直觉用上 Pinia 之后上面这些憋屈点几乎一夜之间消失了。它没有 mutations组件里可以直接调用 actionaction 里直接改 state。你不再需要为同一个逻辑写两遍代码。更重要的是Pinia 的 store 是扁平化的每个 store 只有一个唯一 id彼此之间相互独立。需要共享状态时直接在 action 里引入另一个 store 来用不需要设计复杂的模块嵌套关系。这个设计哲学说白了就一句话把状态管理做回一个普通的响应式对象让 store 变成一个专门存放数据和业务逻辑的类。代码对比最直观。Vuex 里一个计数器的完整状态流要这样写// Vuex const store createStore({ state: { count: 0 }, mutations: { increment(state) { state.count } }, actions: { increment({ commit }) { commit(increment) } } })Pinia 里是这样// Pinia import { defineStore } from pinia export const useCounterStore defineStore(counter, { state: () ({ count: 0 }), actions: { increment() { this.count } } })同样一个功能代码少了一半逻辑还更清晰。而且你会发现Pinia 的写法非常接近直接在组件里定义一个 reactive 对象再配几个函数的直觉。如果你写过组合式 API上手 Pinia 几乎没有学习成本。那次迁移之后我的直观感受是Vuex 把简单的事情复杂化了而 Pinia 把复杂的事情简化到了它本来应该有的样子。这也是我后面所有项目都锁死 Pinia 的根本原因。2. Options Store 还是 Setup Store选型背后的真实考量Pinia 的 defineStore 有两种写法官方文档分别叫 Options Store 和 Setup Store。很多新手会纠结选哪个其实它们没有绝对的优劣关键看你的项目背景和团队习惯。2.1 Options StoreVuex 用户最平滑的过渡带Options Store 长得和 Vuex 很像state 是一个返回对象的函数getters 对应 Vuex 的 gettersactions 对应 Vuex 的 actions只是砍掉了 mutations。如果你是从 Vuex 迁过来的团队用这种写法几乎不需要心智转换改造成本最低。import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ name: , roles: [] }), getters: { isAdmin: (state) state.roles.includes(admin) }, actions: { async fetchUser() { const res await fetch(/api/user) this.name res.name this.roles res.roles } } })注意这段代码里getter 我用箭头函数写所以拿的是 state 参数action 我用普通函数写所以内部可以 this 访问整个 store 实例。这是 Options Store 里最容易写错的地方。getter 里如果要用到 this就不能用箭头函数。2.2 Setup Store把 store 当组件写组合式 API 的自由Setup Store 则是把 store 当成一个 setup 函数来写里面直接用 ref、computed、普通函数最后 return 出去。这种写法和 Vue 3 的组合式 API 风格完全统一适合新项目也适合那些已经在组件里用惯了 setup 语法的团队。import { ref, computed } from vue import { defineStore } from pinia export const useCartStore defineStore(cart, () { const items ref([]) const totalPrice computed(() items.value.reduce((sum, item) sum item.price * item.count, 0) ) function addItem(item) { items.value.push(item) } function clear() { items.value [] } return { items, totalPrice, addItem, clear } })这里有几个细节要注意。第一ref 声明的变量在 return 之后组件里拿到的 store.items 是自动解包后的值第二computed 返回的 totalPrice 是只读的外部不能直接给它赋值第三函数不用写成箭头函数普通函数就行但要记住 getter 和函数都必须在 return 里暴露否则组件拿不到。如果一个 store 逻辑比较复杂Setup Store 可以让你像写组件一样使用 watch、computed 这些 API灵活性比 Options Store 高不少。我个人的习惯是有复杂派生状态或者需要 watch 其他状态的场景用 Setup Store简单状态用 Options Store 也足够清爽。2.3 选型建议什么情况选什么不建议一个项目里混用两种风格团队规则统一比个人偏好更重要。我的实践原则是这样老项目从 Vuex 迁移用 Options Store降低改动风险和 review 成本全新项目、团队已经熟练组合式 API用 Setup Store如果团队里新人多Options Store 的上手门槛更低因为属性结构清晰state 和 actions 一目了然。其实两种写法最终在 devtools 里的表现是一样的选型影响最大的是团队协作时的可读性而不是运行性能。所以不要在这个问题上消耗太多精力定一个约定执行下去。3. 模块化不是拆文件夹Pinia 全局状态设计的正确打开方式很多人在 Vuex 时代就听过模块化但实际做的时候只是把 store 文件按页面拆开每个页面建一个模块。到了 Pinia这种思路要彻底转变。3.1 按业务域拆分而不是按页面拆分页面是状态的使用方不是状态的归属方。一个用户信息首页要用、购物车要用、订单页要用如果你按页面拆 store同一个用户数据会被拷贝成好几份然后你就要花大量精力去维护数据同步。正确做法是按业务域拆。比如一个电商项目最合理的拆分是 user用户信息、cart购物车、order订单、notify通知、product商品。每个 store 只关心自己的领域页面组件从这些 store 里各取所需。举个例子订单页需要展示用户地址、购物车商品和下单状态它要做的是同时引入 useUserStore、useCartStore、useOrderStore而不是自己维护一份订单页全部状态。这样拆完之后每个 store 都职责单一数据只有一份源头任何页面修改了 user 信息其他地方拿到的永远是最新的。3.2 跨 Store 调用userStore 和 cartStore 的协作模型Pinia 跨 store 调用非常直接在 action 里引入另一个 store 实例就行。比如用户退出登录时我们不光要清 user 信息还要清空购物车import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: , userInfo: null }), actions: { async login(credentials) { // 登录逻辑 }, logout() { const cartStore useCartStore() cartStore.clear() this.token this.userInfo null } } })跨 store 调用要在 action 函数内部调用 useCartStore()不要在模块顶部直接调用。原因有两点一是避免模块加载顺序带来的副作用二是在服务端渲染场景下如果 store 实例还没创建就直接 useStore会拿到一个不完整的实例。这个习惯大家一定要养成所有 useXxxStore 都发生在函数体内。3.3 模块化之后头痛的循环依赖怎么破模块拆细之后A store 的 action 会用到 B storeB store 又反过来用 A store这在业务里非常常见。比如 userStore.logout 要通知 cartStore.clearcartStore 在支付成功后又可能要更新 userStore 的积分字段。在 Vuex 时代模块互相引用容易踩循环依赖的坑。Pinia 因为 store 是运行时按函数调用来创建的循环依赖反而没那么多雷。只要坚持在 action 内部调用 useStore就能安全破解。export const useAStore defineStore(a, { actions: { doSomething() { const bStore useBStore() bStore.doSomethingElse() } } }) export const useBStore defineStore(b, { actions: { doSomethingElse() { const aStore useAStore() aStore.doSomething() } } })这个场景下只要两个 action 不是在初始化 store 时同步调用运行起来没任何问题。但如果把 useBStore 提到模块顶层比如const bStore useBStore()那 B store 初始化到一半时可能根本拿不到轻则警告重则直接报错。我见过有人因为这个把 store 拆了又合其实 root cause 只是调用时机不对。4. 刷新页面就丢状态持久化的三种落地姿势全局状态存在内存里一刷新就全部归零这是 SPA 的天然特性。做后台系统时用户的登录 token、偏好设置这些必须跨刷新保留所以持久化躲不掉。4.1 最朴素的手写 localStorage 方案最直接的做法就是在 action 里手动同步 localStorageimport { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo) || null) }), actions: { setToken(token) { this.token token localStorage.setItem(token, token) }, setUserInfo(info) { this.userInfo info localStorage.setItem(userInfo, JSON.stringify(info)) }, logout() { this.token this.userInfo null localStorage.removeItem(token) localStorage.removeItem(userInfo) } } })这个方案优点是没有额外依赖代码一看就懂。缺点是字段一多每个 action 里都要手动写 localStorage 的读和写很容易漏掉。而且如果忘了在 state 初始化时读缓存刷新后 store 就空了。4.2 pinia-plugin-persistedstate 插件的正确姿势我更推荐直接用 pinia-plugin-persistedstate这个插件是 Pinia 官方推荐的持久化方案之一配置起来非常简单。先在入口文件安装import { createPinia } from pinia import piniaPluginPersistedstate from pinia-plugin-persistedstate const pinia createPinia() pinia.use(piniaPluginPersistedstate)然后在 store 里开启 persistexport const useUserStore defineStore(user, { state: () ({ token: , userInfo: null }), persist: true })默认情况下它会把整个 state 持久化到 localStorage 里key 就是 store 的 id。如果只想存部分字段或者想指定存储 key可以用对象配置export const useUserStore defineStore(user, { state: () ({ token: , userInfo: null, theme: light }), persist: { key: my-app-user, pick: [token, userInfo] } })pick 表示只持久化这几个字段theme 不会被存下来。这个配置在真实项目里非常实用因为不是所有状态都需要跨刷新保留像一些临时筛选条件、表单草稿存不存反而无所谓存多了反而占空间。顺带提一句Persist 插件支持 storage 参数可以改成 sessionStorage也可以写一个自定义 storage 对象比如统一加前缀版本号。这个后面讲版本迁移时会用到。4.3 持久化的坑时序、版本、缓存污染用插件只是解决了怎么写的问题真正麻烦的是怎么读。我总结过三个高频坑。第一个是持久化恢复的时序问题。插件在 store 初始化时会同步把 localStorage 里的值合并回 state所以你不需要担心异步读取导致页面先渲染空数据的问题。但如果你在设计 store 时给 state 设置了默认空数组而缓存里存的是旧数据插件会以缓存覆盖默认值这在多数场景下是符合预期的但要注意如果缓存结构变了老的缓存数据可能污染新版本。我处理的方式是在 persist 配置里加一个版本号字段key 直接带上版本比如my-app-user-v2。发布新版本时手动更新 key就相当于做了一次缓存迁移。第二个是敏感信息的存放。localStorage 里的数据可以被页面内的任何脚本读取所以 token 这类凭证放不放 localStorage 要想清楚。如果项目对安全要求高更稳妥的是把 token 放在 httpOnly cookie 里Pinia 只存一些非敏感的 UI 状态。这个决策要尽早做不要上线以后再来补。第三个是多标签页之间的同步问题。localStorage 本身有 storage 事件但 Pinia 不会自动监听这个事件去更新 store。如果你有多个标签页同时操作同一个 storeA 标签页改了数据B 标签页的 state 还是旧的。我的方案是在入口组件里监听 storage 事件然后手动更新对应的 storewindow.addEventListener(storage, (e) { if (e.key my-app-user-v2) { const userStore useUserStore() userStore.$patch(JSON.parse(e.newValue)) } })这个方案能覆盖大部分场景但要注意别在事件回调里做太重的事storage 事件在跨标签页通信时频率不低patch 一下 state 就够了。5. 全局状态管理的进阶细节getter、响应式与批量更新基础用熟之后真正影响开发效率的是那些边角细节。这里梳理几个我几乎每天都会碰到的点。5.1 Getter 会缓存但别在 getter 里做副作用Getter 本质上是 computed有缓存机制依赖的 state 不变多次访问不会重复计算。所以把复杂派生逻辑放在 getter 里性能上是划算的。比如购物车总价不需要每次渲染都重新 reduce放在 getter 里就自动缓存了。getters: { totalPrice(state) { return state.items.reduce((sum, item) sum item.price * item.count, 0) } }但有两条纪律要遵守。第一getter 里不要修改任何 state因为它应该是纯计算函数第二不要在里面发请求、写日志这类副作用操作。监听状态变化该用 $subscribe而不是在 getter 里做手脚。我见过有人把埋点写在 getter 里结果状态没变时反复切换页面还不断触发排查起来非常痛苦。5.2 storeToRefs 的坑与正确用法从 store 里解构 state 时直接解构会丢失响应性const { count } userStore // 会丢失响应性必须用 storeToRefs 包一层import { storeToRefs } from pinia const { count, userName } storeToRefs(userStore)但要注意action 不需要也不能用 storeToRefs直接解构即可。因为 action 是普通函数不存在响应性问题const { increment, fetchUser } userStore还有个容易踩的细节storeToRefs 返回的 count 是一个 ref模板里会自动解包但如果你在 JS 逻辑里要用它必须写 count.value。很多人刚切换过来会在这里懵一下。如果不想处理 ref可以直接用 store.count这样永远是响应式的只是没有解构的灵活。5.3 $subscribe 和 $onAction全局状态的监听器$subscribe 用来监听 state 的变化可以直接拿到 mutation 类型和新的 stateuserStore.$subscribe((mutation, state) { console.log(state 变化:, mutation.type, mutation.payload) // mutation.type: direct | patch object | patch function })它的用途很多比如做持久化、埋点、状态变化日志。我一般会在开发环境开启一个全局的 $subscribe把每个 store 的变化都打到控制台这样可以在功能调试时看到完整的状态变更时间线。$onAction 则是监听 action 的调用能在 action 执行前后做逻辑处理甚至可以拿到传给 action 的参数userStore.$onAction(({ name, args, after, onError }) { const startTime Date.now() console.log(action 开始: ${name}, args) after((result) { console.log(action 完成: ${name}耗时 ${Date.now() - startTime}ms, result) }) onError((error) { console.error(action 失败: ${name}, error) }) })这个能力非常适合做用户行为埋点不用去每个 action 里手动塞埋点代码。我给团队封装过一个统一的埋点调度器就是基于 $onAction 做的只要约定好 action 命名规范埋点代码零侵入。需要注意的是$subscribe 和 $onAction 都会返回一个停止监听的函数组件销毁时记得调用避免内存泄漏。6. 调试体验和 TS 支持被低估了很多人把 Pinia 的优势停留在更简单的 API上其实调试体验和 TypeScript 支持才是它提升开发效率的大头。6.1 Vue Devtools 里 Pinia 的时间旅行调试Pinia 和 Vue Devtools 的集成做得相当好。打开 Devtools 的 Pinia 面板你能看到每个 store 的当前 state、getters 和 actions 的历史记录。最爽的是时间旅行调试状态被改坏了可以直接拖动时间线回退到上一个快照定位是哪一步改坏了状态。我在排查一个问题时经常会同时打开 state 面板和数据流面板一边操作页面一边观察 state 的变化。相比以前在 Vuex 里靠 console.log 打点这个体验是代差级的。而且 Pinia 的 action 记录是带参数的你能看到某个 action 被谁触发、传了什么参数这比看堆栈还直观。如果你还没装 Vue Devtools建议项目一开始就装上并在开发环境养成状态变化先看 devtools的习惯。很多宣称的灵异事件到最后都是对状态变化时机理解不清造成的而时间旅行能帮你快速还原现场。6.2 TS 类型推断带来的重构信心Pinia 对 TypeScript 的支持是自然且完整的。defineStore 会自动推断 state 的类型action 的 this 类型也自动绑定到 store 实例。组件里引入 store 之后 IDE 能直接提示所有 state、getters、actions 的名称和类型。最典型的一个场景是重构。以前用 Vuex 时把 user.roles 改成 user.roleList你很有可能漏改某个页面直到运行时报错才发现。在 Pinia 里这种改动只要 store 里定义变了所有引用位置都会被 IDE 直接标红改完一遍编译再过一遍基本不会漏。我甚至给团队定过一条规矩新项目必须上 TS不允许用 any 代替 store 类型。因为 Pinia 把 TS 的体验做得足够好你不需要写任何额外类型体操就能获得全链路的类型安全没理由不用。7. 我在生产环境踩过的 Pinia 的坑一次完整排查最后分享一个真实事故。这个坑不是 Pinia 本身的 bug而是架构设计不合理引发的连锁故障但排查过程很能反映 Pinia 的调试思路值得完整复盘。7.1 问题现象某次发布后线上用户反馈在商品详情页停留一段时间后再进入购物车发现购物车被清空了。这个 bug 不是必现偶尔出现所以一度很难定位。最开始我怀疑是购物车接口返回了空数据后来加日志发现清空动作根本不是接口触发的而是某个 action 被调用导致的。7.2 完整排查链路我在开发环境复现了一下然后打开 Vue Devtools 的 Pinia 面板盯着 cart store 的 action 记录。经过几次操作终于捕捉到一次clearCart被调用的记录。再看调用来源发现是某个全局的resetAllStoreaction 里调了cartStore.clear()而resetAllStore又被商品详情页的一个定时器逻辑触发了。继续往下挖 root cause。原来这个项目早期为了省事写了一个一键重置所有状态的 action最初只在用户退出登录时调用。后来有人觉得登录态变更时也应该重置就顺手在商品详情页的某个状态监听里绑了一段逻辑。结果这个逻辑在某些时序下被触发而且当时写的时候没有做任何条件判断导致整个购物车被无差别清空。修复方案并不复杂。第一把resetAllStore拆成各个 store 自己的resetactionuser 只清 usercart 只清 cart第二业务组件禁止直接调用全局重置只能调用和自己业务相关的 store 的 reset第三在代码 review 规则里加了一条任何 store 的 action 不允许无参数直接清空所有状态。7.3 沉淀下来的几条使用纪律这个事故给我敲了警钟也让我总结出几条 Pinia 的使用纪律现在一直贴在团队文档里每个 store 只对自己的状态负责不要提供一键重置所有这种跨领域便利 action跨 store 调用要明确方向user 调 cart 可以但不要在 user 里直接篡改 cart 的深层 stateaction 命名要具体clearCart 就只清购物车resetAll 这种名字天然就是隐患持久化字段用 pick 白名单不要全量持久化减少缓存污染面组件里禁止直接修改多层嵌套的 state一律走 action否则 devtools 里看到的是 direct 变更根本没有业务语义。如果你也刚上手 Pinia或者已经在生产环境用了很久希望这些内容能帮你少走一些弯路。状态管理这个事工具只是把复杂度摆在了该在的地方真正决定代码质量的还是拆分边界和使用纪律。Pinia 给了我们一个足够顺手的底座剩下的就靠团队把规则立住了。
