Vue 3 与 Pinia 状态管理实践:从 Vuex 迁移要重新想清楚的是状态怎么分
上个月给团队一个新中后台模块选状态管理方案,我们没有直接照搬 Vuex 4,而是先拿 Pinia 跑了一遍试点。Pinia 这半年明显成熟了不少——文档齐全、类型推导比 Vuex 4 好用得多,社区里讨论"要不要从 Vuex 迁过去"的帖子也变多了。我们组也在评估,但没有一刀切,老的 Vuex 模块暂时不动,新模块先切过去看效果。
用下来发现,从 Vuex 迁移到 Pinia,API 名字换一遍很快,状态组织方式要不要跟着重新想一遍,才是决定代码好不好维护的关键。以前很多项目把 store 当成"全局变量仓库",什么都往里面塞。换成 Pinia 后,如果这个习惯不改,代码依然会变得混乱。
状态管理的关键不是"用了哪个库",而是"哪些状态值得全局化"。
先区分三类状态
我通常会把前端状态分成三类:页面局部状态(弹窗开关、表单输入、当前 tab)、跨组件共享状态(用户信息、主题、权限、购物车)、服务端缓存状态(列表数据、详情数据、分页结果)。
不是所有状态都应该放进 Pinia。比如一个弹窗内部的表单值,只在当前组件使用,放到 ref 或 reactive 就够了。强行放进 store,只会让状态生命周期变长,后面更难清理。
我吃过一次亏。早期一个表单页,为了"方便父子组件传值",把整张表单的草稿都塞进了 store。结果用户填到一半退出,再进来发现上次的脏数据还在;换个客户重新填,又得手动 $reset。后来才反应过来,这种只在一个页面活着的状态,本来 reactive 一个对象就解决了,进 store 反而要替它操心"什么时候该清掉"。判断方法很简单:如果一个状态的生命周期天然跟着某个组件挂载/卸载走,它就不该进 store。
Pinia 更适合管理跨页面、跨组件、需要集中读写的状态。
一个基础 store
Pinia 的写法很直接:
1import { defineStore } from 'pinia' 2 3export const useUserStore = defineStore('user', { 4 state: () => ({ 5 profile: null as null | { 6 id: string 7 name: string 8 role: string 9 }, 10 loading: false, 11 }), 12 13 getters: { 14 isLogin: (state) => Boolean(state.profile), 15 isAdmin: (state) => state.profile?.role === 'admin', 16 }, 17 18 actions: { 19 async fetchProfile() { 20 this.loading = true 21 22 try { 23 this.profile = await request('/api/profile') 24 } finally { 25 this.loading = false 26 } 27 }, 28 }, 29})
组件里使用:
1<script setup lang="ts"> 2import { storeToRefs } from 'pinia' 3import { useUserStore } from '@/stores/user' 4 5const userStore = useUserStore() 6const { profile, loading } = storeToRefs(userStore) 7 8userStore.fetchProfile() 9</script>
这里推荐用 storeToRefs 解构 state 和 getter,避免直接解构丢失响应性。
storeToRefs 这个点新人特别容易踩。一开始我也图省事直接 const { profile } = userStore,模板里渲染没问题,等接口回来更新 profile 时视图就是不动——因为解构出来的是普通值,响应性早断了。但反过来,action(方法)不要用 storeToRefs 取,也不要解构后丢失 this,直接从 store 实例上调用:
1const userStore = useUserStore() 2const { profile, loading } = storeToRefs(userStore) // state / getter 3const { fetchProfile } = userStore // action 可以解构,方法不依赖响应性
这件事不用靠"感觉响应性断了"来判断,isRef 能给出确切答案。Pinia 的 state 整体是一个 reactive 对象,直接解构拿到的是普通值,而 storeToRefs 包出来的每个字段都是 ref:
1import { isRef, isReactive } from 'vue' 2import { storeToRefs } from 'pinia' 3 4const userStore = useUserStore() 5console.log(isReactive(userStore.$state)) // true:state 是 reactive 对象 6 7const { profile: plain } = userStore // 普通解构 8console.log(isRef(plain)) // false:丢了响应性 9 10const { profile } = storeToRefs(userStore) // storeToRefs 解构 11console.log(isRef(profile)) // true:是 ref,模板里会跟着更新
isRef(plain) 是 false、isRef(profile) 是 true,差别一目了然——前者后续不会随 store 更新,后者才会。
改 state 还有 $patch 和直接赋值两种写法,效果上都响应式,区别在于 $patch 能把多个字段的修改合并成一次提交(DevTools 里记成一条),而逐个赋值是多次:
1// 直接赋值:两次独立修改 2userStore.loading = true 3userStore.profile = data 4 5// $patch:合并成一次,DevTools 时间线里是单条记录 6userStore.$patch({ loading: false, profile: data })
顺带一提,装了 Vue DevTools 的话,它有专门的 Pinia 面板,每次 state 变化都会在时间线(timeline)里留一条记录,能看到是哪个 action 改的、改了哪些字段,甚至能直接在面板里手动改某个字段的值观察页面反应。调试"到底是谁动了这个状态"时,比满代码加 console.log 高效得多。之前 Vuex 也有类似的时间旅行调试,但 Pinia 这块做得更细,能按 store 分组看。
还有个隐蔽的坑:组件里调 useUserStore() 的时机。如果在 pinia 还没挂到 app 之前(比如某个模块顶层直接调用)就执行,会报 getActivePinia() 找不到。养成"在 setup 内部或函数内部调用"的习惯,基本就不会碰到。
在路由守卫、请求拦截器这类非组件场景里用 store,也要确认 Pinia 已经创建并挂载。我的习惯是把需要 store 的逻辑包成函数,在应用初始化之后再调用,而不是模块一加载就读 store。模块顶层调用看起来省事,但初始化顺序一变就会出问题。
setup store 更适合复杂逻辑
Pinia 也支持 setup 风格:
1import { computed, ref } from 'vue' 2import { defineStore } from 'pinia' 3 4export const useCartStore = defineStore('cart', () => { 5 const items = ref<Array<{ id: string; price: number; count: number }>>([]) 6 7 const totalCount = computed(() => 8 items.value.reduce((sum, item) => sum + item.count, 0) 9 ) 10 11 const totalPrice = computed(() => 12 items.value.reduce((sum, item) => sum + item.price * item.count, 0) 13 ) 14 15 function addItem(product: { id: string; price: number }) { 16 const current = items.value.find((item) => item.id === product.id) 17 18 if (current) { 19 current.count += 1 20 return 21 } 22 23 items.value.push({ 24 ...product, 25 count: 1, 26 }) 27 } 28 29 return { 30 items, 31 totalCount, 32 totalPrice, 33 addItem, 34 } 35})
如果项目已经大量使用 Composition API,setup store 会更自然。它也方便复用组合式函数。我现在新项目基本都默认 setup store,最大的好处是能把现成的 composable 直接拿进来用,比如一个 useWebSocket() 连接,可以在 store 里建立一次,多个组件共享同一个实例,而不用每个组件各连一遍。
但 setup store 有个反直觉的地方:它没法像 Options store 那样直接 store.$reset()。Options store 的 $reset 是靠重新执行 state() 工厂函数实现的,setup store 没有这个工厂,所以调用会直接抛错。如果业务上确实需要重置,得自己写一个:
1export const useCartStore = defineStore('cart', () => { 2 const items = ref<Array<{ id: string; price: number; count: number }>>([]) 3 // ...getter、action 省略 4 5 function reset() { 6 items.value = [] 7 } 8 9 return { items, /* ... */ reset } 10})
不过团队要统一风格。一个项目里 Options store 和 setup store 混着写不是问题,但如果同一类业务一会儿一种写法,维护成本会升高。我们组后来索性在 code review 约定:新 store 一律 setup 风格,老的 Options store 不主动重写,碰到再改。
action 里要统一错误处理
Pinia 的 action 很适合放异步请求,但不要让每个 action 都随便处理错误。
不推荐:
1async fetchProfile() { 2 try { 3 this.profile = await request('/api/profile') 4 } catch (error) { 5 alert('请求失败') 6 } 7}
这会让错误展示和状态管理耦合。更好的方式是请求层统一错误结构,action 决定是否继续抛出:
1async fetchProfile() { 2 this.loading = true 3 this.error = null 4 5 try { 6 this.profile = await request('/api/profile') 7 } catch (error) { 8 this.error = normalizeError(error) 9 throw this.error 10 } finally { 11 this.loading = false 12 } 13}
页面可以决定展示 toast、跳转登录,还是显示局部错误:
1try { 2 await userStore.fetchProfile() 3} catch (error) { 4 showToast(error.message) 5}
这样 store 不会变成 UI 提示中心。
还有个跟错误处理相关、但更容易被忽略的问题:并发请求的竞态。fetchProfile 这种还好,但像搜索联想、切 tab 拉列表这类场景,用户手快连点几次,后发的请求可能先回来、先回来的反而后回来,最后 store 里留下的是过期那一份数据。我现在的做法是在 action 里记一个请求标记:
1let requestSeq = 0 2 3async function search(keyword: string) { 4 const seq = ++requestSeq 5 const res = await request('/api/search', { keyword }) 6 // 只接受最新一次请求的结果,过期的直接丢掉 7 if (seq === requestSeq) { 8 list.value = res 9 } 10}
更彻底的方案是配合 AbortController 取消旧请求,但加个自增序号在大多数场景已经够用,改动也小。
持久化要克制
很多项目用 Pinia 后,会想把 store 自动同步到 localStorage。这件事很方便,但不是什么状态都适合存。
适合持久化的状态:主题、语言、用户偏好、购物车草稿、非敏感的筛选条件。
不适合持久化的状态:token 明文、权限菜单、用户敏感信息、接口列表缓存、一次性流程状态。
一个简单持久化例子:
1const theme = ref(localStorage.getItem('theme') || 'light') 2 3watch(theme, (value) => { 4 localStorage.setItem('theme', value) 5})
不要为了省事把整个 store JSON 化存起来。状态结构变更后,旧数据可能让页面异常。持久化数据要考虑版本和兼容。
1type PersistedTheme = { 2 version: 1 3 theme: 'light' | 'dark' 4}
我们线上真出过这种事故:之前主题配置存的是 { dark: true },某次重构改成了 { theme: 'dark' },结果老用户浏览器里还躺着旧结构,读出来 theme 是 undefined,页面直接渲染成一片空白。从那以后凡是落盘的数据我都带个 version,读的时候先校验,对不上就丢掉用默认值:
1function loadTheme(): 'light' | 'dark' { 2 try { 3 const raw = JSON.parse(localStorage.getItem('theme') ?? '') 4 if (raw?.version === 1 && (raw.theme === 'light' || raw.theme === 'dark')) { 5 return raw.theme 6 } 7 } catch { 8 // 解析失败直接走默认值,不要让脏数据把页面带崩 9 } 10 return 'light' 11}
如果用 pinia-plugin-persistedstate 这类插件,记得它默认是整个 state 全存。一定要用 paths(或 pick)显式声明只持久化哪几个字段,否则像 loading、error 这种临时态也会被存下来,刷新后 loading 卡在 true,界面就转圈转死了。
持久化还要注意多标签页同步。如果一个标签页退出登录,另一个标签页的用户状态不能还停留在已登录。可以监听 storage 事件,在关键字段变化时清理 store 或重新拉取用户信息。这个细节在后台系统里很常见:用户开着好几个管理页,一个页面退出后,其他页面继续请求接口就会批量 401。
SSR 和水合要小心
如果项目是 Nuxt、SSR 或者带服务端渲染的 Vue 应用,Pinia 还要注意跨请求污染。服务端不能让所有用户共享同一个全局 store,每个请求都应该创建独立的 Pinia 实例——这一点 Pinia 官方文档写得很明确,createPinia() 要在每次请求里重新调用,不能像客户端那样全局只建一次。Nuxt 3 目前还在小范围试用阶段,我们组暂时没上,但看了几个 SSR 示例项目,发现这个"每请求一个实例"的坑如果不注意,会导致 A 用户的登录态被 B 用户在并发请求里看到,属于比较吓人的一类 bug。
还有些状态只应该在客户端存在,比如窗口尺寸、localStorage 里的主题、用户当前滚动位置。服务端渲染时拿不到这些值,如果直接在 store 初始化时读取 window,会导致构建报错或水合不一致。
我一般会把这类状态延后到客户端挂载后再写入:
1function initClientPreference() { 2 if (typeof window === 'undefined') { 3 return; 4 } 5 6 theme.value = localStorage.getItem('theme') || 'light'; 7}
另外水合阶段还有个容易漏掉的点:服务端序列化 state 传给客户端时,只支持能被 JSON.stringify 安全还原的数据结构。如果 state 里塞了 Map、Set、或者带循环引用的对象,直接会在水合时出问题,要么报错、要么客户端拿到的是普通对象而不是预期的 Map。目前我们能确认没问题的是纯对象、数组、基础类型,复杂结构还是老实转成普通对象存。
Pinia 本身不难,难的是把状态生命周期想清楚:它是服务端能确定的,还是只能客户端确定;它能不能被持久化;它会不会跨用户、跨标签页、跨路由残留。
从 Vuex 迁移时的注意点
Vuex 迁移到 Pinia 时,最容易做成机械替换:
1state 还是 state 2getters 还是 getters 3actions 还是 actions 4mutations 删除
这样当然能跑,但不一定能变好。
更值得做的是重新审视模块该怎么拆。Vuex 4 里如果用了 modules 加命名空间(namespaced: true),访问一个 action 得写 dispatch('user/fetchProfile'),跨模块调用还得留意 { root: true }。这套命名空间机制本质上是在一个大 store 对象里模拟"多个 store",迁移到 Pinia 后可以直接把每个 namespaced module 对应成一个独立的 defineStore,命名空间前缀就是 store 的 id,不用再手写字符串路径去 dispatch。以前 Vuex 项目里常见一个很大的 app 模块,里面有用户信息、侧边栏、权限、路由、主题、缓存。迁移时可以拆成:
user:用户资料、登录状态permission:权限点、菜单theme:主题、布局偏好cart:购物车notification:通知状态
每个 store 只对一类业务负责。
拆完之后会遇到一个新问题:store 之间要互相调用。比如退出登录时,user store 清完用户信息,还得把 cart、permission 顺手清掉。Pinia 在这点上比 Vuex 舒服很多——直接在一个 store 的 action 里调用另一个 store 就行,不用 rootState、{ root: true } 那一套:
1// user store 的 setup 写法 2function logout() { 3 profile.value = null 4 // 直接 use 另一个 store,Pinia 会返回同一个单例 5 const cartStore = useCartStore() 6 const permissionStore = usePermissionStore() 7 cartStore.reset() 8 permissionStore.clear() 9}
唯一要注意的是别绕成环:A 的 action 调 B,B 的 action 又调回 A,逻辑上很容易自己把自己绕进死循环。真出现这种依赖,多半说明这两块状态压根就不该分开管,该考虑合并或者抽出第三个 store。
迁移期间还有个务实的做法:不必强求一次性把所有 Vuex 模块搬完。Pinia 和 Vuex 4 是可以在同一个 Vue 3 项目里共存的,两者互不冲突,可以先挑一两个职责单一、改动量小的模块试点,跑稳了再逐步扩大范围。我们现在就是这个状态:新模块全上 Pinia,老的几个 Vuex 模块先留着,等有精力重构再动。
不要把服务端缓存都放进 Pinia
很多 Vue 项目还没有统一使用类似 Query 的数据缓存方案,所以大家习惯把接口数据放进 store。
但列表页数据、搜索结果、分页结果,很多时候更适合留在页面或专门的数据请求层。否则 store 里会堆满各种 list、detail、page、total,切换页面后还要考虑清理。
我的判断是:如果数据需要跨页面共享,或者多个组件同时依赖,可以进 store;如果只属于当前页面,就不要全局化。
如果团队已经引入了专门的数据请求缓存方案,就更要避免 Pinia 和请求缓存各管一份同样的数据。比如列表数据在请求层缓存一份,Pinia 里又存一份,刷新、失效、乐观更新都会变复杂。Pinia 更适合放业务状态和用户偏好,服务端数据缓存交给更专门的层处理,职责会清楚很多。
Pinia 的优势不是 API 少,而是它和 Vue 3 的响应式模型更贴近。用好它的关键,是先把状态分类,再决定哪些状态值得全局管理。
回到最开始那个新模块的试点:跑到现在,store 里没有出现表单草稿、没有出现列表分页这些只该活在页面里的东西,退登录时 user、cart、permission 三个 store 各自清各自的,没有再出现"改了一个字段,另一块状态跟着乱"的情况。老的 Vuex 模块还留着没动,但至少新模块这条线,状态该放哪、什么时候该清,现在想得比之前清楚多了。