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 里直接操作 state,DevTools 依然能完整记录变更。背后的逻辑很简单:只要变更仍然被响应式系统监听,变更来源是 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 里调用另一个 store,Pinia 允许这样做:
export const useCurrentConversationStore = defineStore('currentConversation', () => { const messageList = ref<MessageItem[]>([]) 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 里如果用ref<T[]>,不要忘了在泛型上标注数据类型,否则 getter 里拿到的数组元素是 any,后续字段访问完全失去提示。我一般在模块文件头部定义接口:
export interface MessageItem { id: string content: string createdAt: number from: 'user' | 'assistant' } export const useCurrentConversationStore = defineStore('currentConversation', () => { const messageList = ref<MessageItem[]>([]) // ... })这样在组件里store.messageList.map(item => item.content)时,IDE 能给出正确补全,重构字段名时也不会出现魔法字符串穿帮。
3. 容易忽略的性能陷阱:响应式不是免费的
3.1 解构正在悄悄破坏你的响应式
新手最容易踩的坑是从 store 直接解构 state:
const { messageList, loading } = useCurrentConversationStore()这样拿到的messageList是普通值,后续 store 里的更新组件根本感知不到。正确用法是storeToRefs:
import { storeToRefs } from 'pinia' const store = useCurrentConversationStore() const { messageList, loading } = storeToRefs(store)但这里有个很多团队会卡壳的细节:storeToRefs只对 state 和 getter 有效,action 不在里面。如果你把 action 也放进storeToRefs,拿到的会是 undefined。action 直接方法解构就行,因为它不需要响应式。
另一个容易被忽略的点:storeToRefs返回的 ref 是只读的 getter ref,messageList.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 = ref<MessageItem[]>([]) 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 Map<string, 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 = shallowRef<MessageItem[]>([]) 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 = ref<UserProfile | 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 lang="ts"> 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-if="isLoggedIn" @click="handleLogout">退出登录</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能实现大部分中间件能力。
先看$onAction:
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) }) })这个 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 自动拥有$reset,setup store 没有内置实现。解决办法是手写一个同名 action:
export 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 中重复调用带参 getter | shallowRef 包裹 / 索引化查表 / 虚拟滚动 |
| watch 回调触发次数远超预期 | watch 监听层级太粗(整个 store 或 $state) | 改成精确取值的 getter |
| 组件卸载后订阅还在执行 | $subscribe 默认绑定组件生命周期 | 全局监听时加 detached: true |
| setup store 无法 reset | 没有内置实现 | 手写 $reset action |
| 路由守卫里 useStore 报错 | Pinia active 实例不存在 | 创建独立 pinia 实例并传入 |
| 用 shallowRef 后 UI 不刷新 | 内部成员原地修改 | 改为整体替换 value |
最后分享一点个人体会。Pinia 的上手成本极低,低到很多团队第一天就能写出一堆能跑的 store,但真正决定一个项目状态层健康程度的,不是 API 熟练度,而是你对两个问题的判断:这个状态该不该全局化,以及这个状态的响应式代价是不是被低估了。我在实际项目中吃亏的地方,几乎都集中在这两处。如果你看完这篇只能记住两件事,我希望是:组件里少直接改 store 状态,watch 和 getter 的粒度一定要精确。新项目起步时宁可多花半天理清楚 store 边界,也不要等代码堆到几十个 store 之后再回头重构,那时改一个字段的归属,就要牵动十几个文件。