ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Pinia实战指南:Store拆分、响应式性能与Vuex迁移避坑

Pinia实战指南:Store拆分、响应式性能与Vuex迁移避坑 Pinia 现在已经是 Vue 官方钦定的状态管理方案Vuex 在 Vue 3 时代实际上已经进入了接管维护模式。我从 Vue 3 RC 阶段开始在真实项目里试用 Pinia到现在三年多主导过两个中型后台管理系统和一个偏重实时交互的协作平台的状态层重构。这篇不是把官方文档再念一遍而是想聊聊那些文档里不会重点强调、但你在生产环境一定会遇到的东西Store 怎么拆才算合理、Getter 的缓存到底是怎么一回事、响应式在什么场景下会把性能拖垮以及我踩过的几个坑。适合刚上手想少走弯路的同学也适合已经在用但担心没用标准的工程师。1. 为什么 Pinia 能成为默认选择从 Vuex 迁移的关键差异1.1 没有 mutations 之后业务代码发生了什么变化Pinia 对比 Vuex 最直观的变化是砍掉了 mutations。Vuex 坚持 mutations 同步修改 state、actions 处理异步逻辑目的是让 DevTools 能追踪每一次状态变更。但这个设计在实际开发里带来了恒定成本每写一个状态修改就要同时写 action 和 mutation 两遍团队大一点还要约定命名风格字符串匹配还容易打错。Pinia 把这两层合并了action 里直接操作 stateDevTools 依然能完整记录变更。背后的逻辑很简单只要变更仍然被响应式系统监听变更来源是 action 还是组件内部并没有那么重要重要的是让开发者可以看见变化。真正受益的不只是少写代码。Vuex 的 action 里 commit 一个 mutation关联关系是隐式的代码跳转靠工具链在字符串和类型之间来回穿梭而 Pinia 的 action 就是一个普通函数直接改 state不需要 dispatch 字符串匹配这对 TypeScript 是质的提升。函数调用就是函数调用IDE 能给出完整的类型推断和重命名提示重构时不用担心漏改魔改字符串。1.2 Options Store 与 Setup Store 的选型Pinia 保留了两种写法options store类似 Vuex 风格的 state/getters/actions 配置项和 setup store函数式像组件里的 setup 语法。我的建议是新项目一律 setup store。原因有三个第一setup store 可以组合复用逻辑把一个跨 store 的通用状态逻辑抽成普通 composable然后在多个 store 里使用第二用 ref/reactive/computed 写状态心智模型和组件内部状态完全一致不需要额外记一套 options 语义第三后续做依赖注入、动态生成 store 会更方便而 options store 本质上是把这套行为封装成$state的语法糖。但 options store 依然有价值它自动给你实现了$reset()setup store 默认没有这个方法。如果你团队习惯用$reset做状态初始化又不想手动实现那 options store 能省事。此外 options store 的 getter 定义方式在某些场景下可读性更好。选型没有绝对对错但我见过太多项目混用两种写法导致新人困惑——一个 store 库里既有 options 又有 setup风格完全统一不起来。建议团队定一个默认规范只在确实需要组合逻辑时使用 setup store。1.3 和 Vuex 对比的几个冷门细节除了 mutation 的消失还有几个平时容易忽略的差异Vuex 的 state 直接暴露在 store 根属性上Pinia 里 state 挂在$state上store.count与store.$state.count是同一份数据但前者是访问器。这个区别影响你如何使用watch和storeToRefs。Pinia 的 getter 底层是 computed所以它会自动缓存这个特性在性能章节会详细展开。Pinia 没有 modules 和 namespace 的概念每个 store 天然就是一个模块store 之间可以直接互相调用。这避免了 Vuex 里跨模块访问时写一长串路径的尴尬。Vuex 的严格模式strict: true在 Pinia 里没有了。Vuex 靠它防止开发期误操作Pinia 则认为响应式系统加 DevTools 已经足够不必再靠运行时告警增加心智负担。2. Store 设计的最佳实践工程化落地从目录到命名2.1 按业务域拆分 Store而不是按类型拆分我在不少项目里见过把整个应用状态塞进一个 store 的做法也见过按类型拆成 userStore、listStore、formStore 的做法。前者是灾难后者是伪模块化。合理的拆分维度是业务域用户会话、权限、购物车、订单、消息通知、主题偏好各自独立谁依赖谁就在 action 里显式调用对方。这样拆的好处是变更范围可控某个域的状态调整不会连带别的域的代码。举一个具体的判断标准如果几个字段总是成对出现并且在多个组件里会被同一段逻辑读取它们应该属于同一个 store如果一个字段只被一个组件用到它根本不应该放进全局 store留在组件里的 ref 就行。比如当前选中的表格行 id这种状态放进 store 会让后续维护的人误以为它是全局共享的实际它只服务一个页面放进 store 反而造成误导。拆分后还要注意目录约定。我一般这样放src/stores/ index.ts // createPinia 入口 modules/ user.ts cart.ts notification.ts index.ts // 统一导出 useXxxStore模块文件名和导出的 store 名保持强对应useUserStore就在user.ts里不要为了短文件名打破这个约定。团队协作时靠文件名定位 store 是最低成本的检索方式。2.2 怎么判断一个状态该不该放进 Store三步检验法判断一个状态要不要放进 store我有一套很笨但很实用的检验方法问三个问题这个状态会被两个以上毫无关联的组件读取吗这个状态的变更需要被全局监听吗比如未读消息角标刷新或路由跳转后这个状态还需要保留吗三个问题有一个为是才考虑放 store否则就用组件内 ref 或 provide/inject 解决。这里要特别警惕一种状态——服务端数据。如果你用 Pinia 去管理从接口拉取的用户列表、商品列表还要处理缓存失效、重新拉取、loading/error 生命周期那你面对的是缓存管理领域的问题不是状态管理领域的问题。业内主流做法是把这类 server state 交给专门的请求缓存库去管Pinia 只放 client state也就是跟服务器无关的、纯粹由用户交互产生的状态。这个分工能直接砍掉一半以上的 loading/error 样板代码。2.3 多 Store 协作实践对话场景的状态组织实例聊一个具体场景对话类应用。这类应用的状态天然是分层的会话列表、当前会话、消息流、未读数、输入框草稿。如果全都塞进一个 store消息一多整个 store 的响应式依赖都会跟着抖。我的建议是拆三个 store会话列表 store 负责会话摘要、未读数、排序当前会话 store 负责消息列表、分页加载状态、发送中的消息草稿状态留在页面组件里切走后用 keep-alive 配合恢复。拆开之后当前会话 store 的 action 需要读取会话列表 store 的某个字段怎么办直接在自己的 action 里调用另一个 storePinia 允许这样做export const useCurrentConversationStore defineStore(currentConversation, () { const messageList refMessageItem[]([]) const loading ref(false) async function fetchMessages(conversationId: string) { const conversationStore useConversationListStore() loading.value true try { const { data } await api.getMessages(conversationId) messageList.value data conversationStore.markRead(conversationId) } finally { loading.value false } } return { messageList, loading, fetchMessages } })这种跨 store 调用比 Vuex 时代舒服得多不需要 namespace 前缀类型也能正确推导。2.4 TypeScript 加持下的 Store 定义Pinia 的 TS 体验比 Vuex 强一大截。state 直接用 TS 接口约束action 的入参和返回值都会自动推导。有一个小细节值得注意setup store 里如果用refT[]不要忘了在泛型上标注数据类型否则 getter 里拿到的数组元素是 any后续字段访问完全失去提示。我一般在模块文件头部定义接口export interface MessageItem { id: string content: string createdAt: number from: user | assistant } export const useCurrentConversationStore defineStore(currentConversation, () { const messageList refMessageItem[]([]) // ... })这样在组件里store.messageList.map(item item.content)时IDE 能给出正确补全重构字段名时也不会出现魔法字符串穿帮。3. 容易忽略的性能陷阱响应式不是免费的3.1 解构正在悄悄破坏你的响应式新手最容易踩的坑是从 store 直接解构 stateconst { messageList, loading } useCurrentConversationStore()这样拿到的messageList是普通值后续 store 里的更新组件根本感知不到。正确用法是storeToRefsimport { storeToRefs } from pinia const store useCurrentConversationStore() const { messageList, loading } storeToRefs(store)但这里有个很多团队会卡壳的细节storeToRefs只对 state 和 getter 有效action 不在里面。如果你把 action 也放进storeToRefs拿到的会是 undefined。action 直接方法解构就行因为它不需要响应式。另一个容易被忽略的点storeToRefs返回的 ref 是只读的 getter refmessageList.value ...会直接报错。要修改 state 必须回到store.messageList或者调用 action。这个限制其实是个提醒不要在组件里到处直接给 store 赋值收敛到 action 里变更才有迹可循。3.2 Getter 缓存带参数的 getter 就是普通函数Pinia 的 getter 底层是 Vue 的 computed所以无参 getter 有缓存能力——依赖不变多次访问只重算一次。这在设计上天然比在组件里写 computed 更高效因为计算被提升到了 store 层多个组件共享同一份缓存结果。但带参数的 getter 就完全是另一回事了。文档里那句使用方法的 getter 不会被缓存是无数性能问题的来源。例如export const useMessageStore defineStore(message, () { const messages refMessageItem[]([]) const getMessageById (id: string) { return messages.value.find(item item.id id) } return { messages, getMessageById } })getMessageById每次调用都会做全量 find而且没有任何缓存。在列表组件里 v-for 大规模调用这种 getter性能会非常难看。如果数据量大正确做法是在 store 里维护一个 computed Map 索引const messageMap computed(() { const map new Mapstring, MessageItem() for (const msg of messages.value) { map.set(msg.id, msg) } return map }) const getMessageById (id: string) messageMap.value.get(id)构建索引的代价只在 messages 变化时发生一次查询退化为 O(1)。这是我在一个 5000 条消息的列表页里实测有效的优化方案v-for 渲染时间下降非常明显。另外要注意 getter 返回新对象的场景。computed(() messages.value.map(m m.from))这类 getter 本身会被缓存但一旦依赖变化会生成一个新数组下游组件如果 watch 它新旧值永远是不同引用。这种watch 一个返回新对象的 getter回调反复触发的陷阱排查起来非常隐蔽因为数据看起来没变但回调一直再跑。3.3 $subscribe 的触发机制与监听成本很多人把$subscribe当成 Vuex 里的 mutation 事件来用以为只在 action 里改 state 才触发。实际不是。Pinia 的$subscribe底层是对store.$state做 watch源码里默认就强制合并了deep: true也就是说无论你是通过 action 修改还是直接在组件里store.xxx 1以及修改深层嵌套字段回调都会触发。这个设计带来的好处是事件追踪足够细坏处是你订阅了一个包含大列表的 store又高频更新其中的小字段回调会被高频调用。如果回调里再做深拷贝、序列化或写 localStorage代价会迅速放大。我建议的用法是$subscribe只用于持久化、日志这类全局横向需求不要在业务组件里用它去驱动 UI 更新。业务层的响应式联动应该用computed或watch(() store.someField, ...)精确监听单字段而不是订阅整个 state 再手动 diff。还要记住$subscribe默认绑定到组件生命周期组件卸载后订阅自动清除。如果你是在模块顶层做全局监听需要传{ detached: true }store.$subscribe( (mutation, state) { // 例如 debounce 后写入 localStorage }, { detached: true, flush: post } )flush: post让回调在 DOM 更新后执行避免在同步阶段读到中间态。如果你要持久化强烈建议加 debounce否则每次 state 变化都同步调用 localStorage API性能影响肉眼可见。3.4 超大状态对象与 shallowRef 的取舍响应式系统把对象变成 Proxy 是有成本的。把一个 10 万条记录的大列表直接塞进 store 的ref([])Vue 会递归地把每一层都代理一遍初始化和内存占用都不可忽略。这种场景我有两个建议。第一如果列表只用于展示且数据从服务端拿到后极少原地修改用shallowRef包住它。shallowRef只让.value这一层响应式内部数组的成员变化不会被追踪但整体替换.value时依然能触发更新。配合不可变更新每次拉数据生成新数组就能既保持响应式又避开递归代理const messageList shallowRefMessageItem[]([]) function appendMessages(items: MessageItem[]) { messageList.value [...messageList.value, ...items] // 整体替换 }第二如果某些大对象不需要响应式能力比如静态配置、canvas 像素数据用markRaw标记后放进 state告诉 Vue 别代理这个对象。这能省下肉眼可见的内存和代理时间还能避免 Proxy 对某些第三方库内部标识符判断的干扰。这里必须提醒shallowRef的坑在于内部成员变更不会触发更新。如果你做了一半messageList.value[0].content ...UI 不会刷新排查起来很迷惑。所以用shallowRef必须配套整体替换的纪律或者在关键位置主动用一个版本号 ref 手动通知更新。3.5 watch 应该盯住哪一层组件里监听 store 状态时我见过这些写法watch(store, () ...) // 监听整个 store watch(() store.$state, () ...) // 监听整个 state watch(() store.messages, () ...) // 监听 messages 引用前两种都会对所有深层属性变化做出响应容易造成改了用户名消息列表相关的 watcher 也跑了一遍。第三种只盯住字段引用但如果你在乎的是字段内部某个嵌套值还需要深度选项。我的建议是watch 永远带精确 getter拿到你需要的最小依赖集。watch( () store.messageList.length, (newLen) { /* 列表长度变化时处理 */ } ) watch( () store.user?.profile?.name, (name) { /* 只关心用户名 */ } )强调这个是因为 Vue 的 watch 依赖收集是跟着 getter 执行路径走的getter 只读了哪些数据就只依赖哪些数据。这个特性用好了在大型应用里能省掉大量无意义的 watcher 触发这也是我每次代码 review 时必看的点。4. 实操从零搭一个高可维护的 Pinia 项目4.1 创建 Pinia 实例与模块划分先在入口处创建并挂载// src/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创建的是全局单例。组件里useStore()不需要显式传 pinia靠的是 app inject但在组件外使用 store路由守卫、工具模块、纯函数时调用时机早于app.use(pinia)就会报错这个问题在第五部分会详细讲。4.2 定义 Setup Store 与跨 Store 调用以用户会话为例这是一个完整的 setup store// src/stores/modules/user.ts import { defineStore } from pinia import { ref, computed } from vue export type Role admin | editor | viewer export interface UserProfile { id: string name: string email: string role: Role } export const useUserStore defineStore(user, () { const token ref() const profile refUserProfile | null(null) const isLoggedIn computed(() Boolean(token.value profile.value)) const displayName computed(() profile.value?.name ?? 未登录) async function login(payload: { email: string; password: string }) { const { data } await api.login(payload) token.value data.token profile.value data.profile } function logout() { token.value profile.value null } return { token, profile, isLoggedIn, displayName, login, logout } })所有需要响应式的字段都用 ref/computed 返回action 直接操作 state。跨 store 调用的关键点不要在 setup store 的函数体外访问另一个 store而要放在 action 内部调用。原因是 setup store 的初始化发生在首次useStore()时如果两个 store 在顶层互相调用会出现循环初始化问题。放到 action 里调用时两个 store 都已经初始化就没有这个问题了。4.3 组件里消费 Store 的推荐姿势模板里直接访问 store 属性是响应式的但组件里我更推荐配合storeToRefs使用script setup langts import { useUserStore } from /stores/modules/user import { storeToRefs } from pinia const userStore useUserStore() const { isLoggedIn, displayName } storeToRefs(userStore) function handleLogout() { userStore.logout() } /script template div p{{ displayName }}/p button v-ifisLoggedIn clickhandleLogout退出登录/button /div /template有个细节模板里每次访问userStore.token都会触发一次显式的 proxy 读取渲染函数里依赖收集也会更细。单次读取开销不大但如果你在长列表的每行里都访问 store 的某个字段把字段通过storeToRefs转到组件作用域后再通过 computed 或 props 传递渲染性能会明显更稳。这个优化在列表几百条时感觉不明显几千条时对比就出来了。还有一个团队协作层面的建议组件里要修改 store 状态永远通过 action。哪怕只是往数组里 push 一个新标签也写到 action 里。不是为了规范而规范而是当你在操作前后需要打日志、埋点、联动其他状态时action 是天然的收纳点如果直接在组件里store.tags.push(tag)这些扩展逻辑就只能散落在组件里以后别人想复用这个行为只能到处复制。4.4 订阅与 Action 拦截日志、埋点与中间件思路Pinia 没有内置 middleware 体系但$subscribe配合$onAction能实现大部分中间件能力。先看$onActionuserStore.$onAction(({ name, args, after, onError }) { console.log(action ${name} 被调用参数:, args) after((result) { console.log(action ${name} 完成结果:, result) }) onError((error) { console.error(action ${name} 失败:, error) }) })这个 API 适合做统一埋点和错误上报回调在 action 调用前触发after和onError是异步等待钩子。建议做成全局注册// src/stores/middleware.ts import { type Pinia } from pinia export function registerStorePlugins(pinia: Pinia) { pinia.use(({ store }) { store.$onAction(({ name, after, onError }) { const start performance.now() after(() { const duration performance.now() - start if (duration 100) { console.warn([store] ${store.$id}.${name} 耗时 ${duration.toFixed(2)}ms) } }) onError((error) { reportError(error, { storeId: store.$id, action: name }) }) }) }) }pinia.use是正规的中间件模式插件在 store 创建时执行可以给每个 store 注入额外属性或行为。这个能力还可以用来给所有 store 自动加$resetAll、统一初始化逻辑。持久化是另一个高频需求。最简版可以这样写pinia.use(({ store }) { const saved localStorage.getItem(pinia:${store.$id}) if (saved) { store.$patch(JSON.parse(saved)) } store.$subscribe((_mutation, state) { localStorage.setItem(pinia:${store.$id}, JSON.stringify(state)) }, { detached: true }) })注意$subscribe默认 flush 是pre同步写 localStorage 会造成频繁存储操作建议加 debounce 或只持久化必要字段。更完整的持久化方案可以直接用现成封装库但原理就是这样。5. 常见问题与排查实录5.1 storeToRefs 不生效的场景症状组件里storeToRefs解构出来的字段模板不更新。排查思路确认解构目标是不是 state 或 getter。如果你解构的是一个 setup store 里的普通函数返回值不是 ref/reactive/computed它不会响应式。另外千万不要对 action 使用storeToRefs那返回的是 undefined。还有人在 setup store 里把响应式字段包在readonly()里返回storeToRefs能正常处理但如果你在中间自己套了一层普通对象响应式链就断了。5.2 Setup Store 没有 $reset症状调用store.$reset()报错not a function。原因只有 options store 自动拥有$resetsetup store 没有内置实现。解决办法是手写一个同名 actionexport const useCounterStore defineStore(counter, () { const count ref(0) function $reset() { count.value 0 } return { count, $reset } })这样store.$reset()就能正常调用了。初始状态比较多时建议抽出一个initialState函数reset 时调用同一个函数避免初始化和重置逻辑各写一份。5.3 组件外使用 Store 报 no active Pinia症状在路由守卫、utils 模块里调用useUserStore()时控制台报错。原因调用 store 时 Pinia 实例还没有被设置为 active。解决方式有两种。第一种在app.use(pinia)之后调用并在调用处手动传入 pinia 实例。第二种最稳妥的搞法是把创建 pinia 的代码独立成模块保证应用和外部工具 import 同一个实例// src/stores/index.ts import { createPinia } from pinia export const pinia createPinia()然后在main.ts里app.use(pinia)外部模块里useUserStore(pinia)。注意不要在模块顶层直接调用useStore()要包在函数里确保调用时机在 pinia 创建之后。5.4 HMR 后状态丢失症状开发时改了 store 文件热更新后状态被重置。解决方式在 store 定义文件末尾加import { acceptHMRUpdate } from pinia if (import.meta.hot) { import.meta.hot.accept(acceptHMRUpdate(useUserStore, import.meta.hot)) }这样热更新会保留当前状态。团队协作时 HMR 重置会打断调试流程加上这个是好习惯。5.5 高频问题排查参考表现象可能原因解决方向模板里字段一直是旧值直接从 store 解构了 state改用 storeToRefs列表一长渲染明显卡顿state 存了大列表、深层代理开销、v-for 中重复调用带参 gettershallowRef 包裹 / 索引化查表 / 虚拟滚动watch 回调触发次数远超预期watch 监听层级太粗整个 store 或 $state改成精确取值的 getter组件卸载后订阅还在执行$subscribe 默认绑定组件生命周期全局监听时加 detached: truesetup store 无法 reset没有内置实现手写 $reset action路由守卫里 useStore 报错Pinia active 实例不存在创建独立 pinia 实例并传入用 shallowRef 后 UI 不刷新内部成员原地修改改为整体替换 value最后分享一点个人体会。Pinia 的上手成本极低低到很多团队第一天就能写出一堆能跑的 store但真正决定一个项目状态层健康程度的不是 API 熟练度而是你对两个问题的判断这个状态该不该全局化以及这个状态的响应式代价是不是被低估了。我在实际项目中吃亏的地方几乎都集中在这两处。如果你看完这篇只能记住两件事我希望是组件里少直接改 store 状态watch 和 getter 的粒度一定要精确。新项目起步时宁可多花半天理清楚 store 边界也不要等代码堆到几十个 store 之后再回头重构那时改一个字段的归属就要牵动十几个文件。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进