Vue 3 与 Pinia 状态管理实践:从 Vuex 迁移要重新想清楚的是状态怎么分

上个月给团队一个新中后台模块选状态管理方案,我们没有直接照搬 Vuex 4,而是先拿 Pinia 跑了一遍试点。Pinia 这半年明显成熟了不少——文档齐全、类型推导比 Vuex 4 好用得多,社区里讨论"要不要从 Vuex 迁过去"的帖子也变多了。我们组也在评估,但没有一刀切,老的 Vuex 模块暂时不动,新模块先切过去看效果。

用下来发现,从 Vuex 迁移到 Pinia,API 名字换一遍很快,状态组织方式要不要跟着重新想一遍,才是决定代码好不好维护的关键。以前很多项目把 store 当成"全局变量仓库",什么都往里面塞。换成 Pinia 后,如果这个习惯不改,代码依然会变得混乱。

状态管理的关键不是"用了哪个库",而是"哪些状态值得全局化"。

先区分三类状态

我通常会把前端状态分成三类:页面局部状态(弹窗开关、表单输入、当前 tab)、跨组件共享状态(用户信息、主题、权限、购物车)、服务端缓存状态(列表数据、详情数据、分页结果)。

不是所有状态都应该放进 Pinia。比如一个弹窗内部的表单值,只在当前组件使用,放到 refreactive 就够了。强行放进 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)falseisRef(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' },结果老用户浏览器里还躺着旧结构,读出来 themeundefined,页面直接渲染成一片空白。从那以后凡是落盘的数据我都带个 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)显式声明只持久化哪几个字段,否则像 loadingerror 这种临时态也会被存下来,刷新后 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 里塞了 MapSet、或者带循环引用的对象,直接会在水合时出问题,要么报错、要么客户端拿到的是普通对象而不是预期的 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 清完用户信息,还得把 cartpermission 顺手清掉。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 里会堆满各种 listdetailpagetotal,切换页面后还要考虑清理。

我的判断是:如果数据需要跨页面共享,或者多个组件同时依赖,可以进 store;如果只属于当前页面,就不要全局化。

如果团队已经引入了专门的数据请求缓存方案,就更要避免 Pinia 和请求缓存各管一份同样的数据。比如列表数据在请求层缓存一份,Pinia 里又存一份,刷新、失效、乐观更新都会变复杂。Pinia 更适合放业务状态和用户偏好,服务端数据缓存交给更专门的层处理,职责会清楚很多。

Pinia 的优势不是 API 少,而是它和 Vue 3 的响应式模型更贴近。用好它的关键,是先把状态分类,再决定哪些状态值得全局管理。

回到最开始那个新模块的试点:跑到现在,store 里没有出现表单草稿、没有出现列表分页这些只该活在页面里的东西,退登录时 usercartpermission 三个 store 各自清各自的,没有再出现"改了一个字段,另一块状态跟着乱"的情况。老的 Vuex 模块还留着没动,但至少新模块这条线,状态该放哪、什么时候该清,现在想得比之前清楚多了。