Pinia与Vuex状态管理

Pinia 与 Vuex 状态管理

先摆痛点。props/emits 解决的是父子通信, 但真实项目里很快会遇到两种传不动的场景: 兄弟组件想共享数据, 只能把状态提升到共同父级再一层层传下来; “当前登录用户”这种几十个页面都要读的数据, 难道每个页面都从根组件穿透传下来? 状态管理库就是给这些”全局共享数据”安个家。
Pinia 是 Vue 3 的官方方案, 角色很像后端的全局单例 Bean: 应用里只有一份, 谁需要谁注入。

1. 冷静: 先判断需不需要 Store

我的教训是别把 Store 当默认选项, 什么都往里塞的项目最后就是一个全局大杂烩。状态归属的判断顺序: 组件自己的 (弹窗开关、输入草稿) 就放组件里; 父子共用的提升到父级; 能用 URL 表达的 (页码、筛选) 进路由 query (03 篇讲过); 剩下那些真正跨页面、生命周期长、多处读写的 (用户、权限、购物车), 才进 Store。

2. Pinia Setup Store: 主力写法

Setup Store 的写法和组件里的组合式 API 完全一致: ref 当 state, computed 当派生值, 函数当操作。拿最典型的登录状态做例子:

代码块TS · 51 行收起展开
// stores/auth.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { getCurrentUser, login } from '@/api/auth'   // 请求层封装, 05 篇讲

interface User {
  id: number
  name: string
  roles: string[]
}

// 'auth' 是这个 store 的全局唯一 id
export const useAuthStore = defineStore('auth', () => {
  const token = ref(localStorage.getItem('token') ?? '')   // 刷新后从本地恢复
  const user = ref<User | null>(null)
  const loading = ref(false)

  const isLoggedIn = computed(() => Boolean(token.value && user.value))

  async function signIn(account: string, password: string) {
    if (loading.value) return          // 防重复提交
    loading.value = true
    try {
      const result = await login({ account, password })
      token.value = result.token
      localStorage.setItem('token', result.token)
      await loadCurrentUser()
    } catch (error) {
      clearSession()                   // 失败回滚, 不留半登录状态
      throw error
    } finally {
      loading.value = false
    }
  }

  async function loadCurrentUser() {
    user.value = await getCurrentUser()
  }

  function clearSession() {
    token.value = ''
    user.value = null
    localStorage.removeItem('token')
  }

  function hasAnyRole(required: string[]) {
    return required.some(role => user.value?.roles.includes(role))
  }

  return { token, user, loading, isLoggedIn, signIn, loadCurrentUser, clearSession, hasAnyRole }
})

组件里用:

代码块TS · 3 行收起展开
const auth = useAuthStore()
const { user, loading } = storeToRefs(auth)   // 解构 state/getter 必须走它, 否则丢响应式
await auth.signIn(account.value, password.value)   // 函数直接调, 不用包

storeToRefs 这个坑在快速上手篇立过 flag: 它和 reactive 解构丢响应式是同一件事。函数解构不受影响, 因为函数本来就没有”响应”这回事。

3. Option Store: 另一种皮

Pinia 也支持配置对象风格, state/getters/actions 三段式:

代码块TS · 9 行收起展开
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    double: state => state.count * 2
  },
  actions: {
    increment() { this.count++ }
  }
})

和组件的 Options vs Composition 对比一个道理: 结构固定 vs 组合灵活。团队选一种统一就好, 我自己用 Setup Store, 和组件写法零切换成本。

4. Store 里的异步与竞态

action 直接写 async 就行, 但列表查询这种高频异步要防乱序: 先发的慢请求后返回, 把新结果盖掉。01 篇用 AbortController 取消旧请求, 这里再记一个更轻的版本号方案:

代码块TS · 12 行收起展开
let latestRequest = 0

async function loadTasks(query: TaskQuery) {
  const requestId = ++latestRequest    // 每次调用领一个递增号
  loading.value = true
  try {
    const result = await fetchTasks(query)
    if (requestId === latestRequest) tasks.value = result.items   // 只有最新一次有权写结果
  } finally {
    if (requestId === latestRequest) loading.value = false
  }
}

思路和乐观锁的版本号检查一样: 写之前先确认自己还是最新的。

5. 持久化的边界

哪些状态要在刷新后活下来? 我的原则是最小化: token 和用户偏好这类值得存, 其他的刷新丢了就丢了, 重新请求是常态。存的时候记住几条:

  • localStorage 里的东西任何页面脚本都读得到, 密码和高敏感信息永远不进去。
  • token 放 localStorage 还是 HttpOnly Cookie 是后端安全方案说了算, Cookie 方案还得配 CSRF 防护。
  • 持久化的数据结构会演化, 带上 version 字段留好迁移余地, 别永远相信旧结构能直接用。

6. Vuex: 需要读得懂的历史方案

Vuex 是 Pinia 之前的官方状态库, 新项目不选它, 但存量项目遍地都是, 读懂是刚需。它的核心是一条强制的单向修改链:

flowchart LR
    V[View] -->|dispatch| A[Action]
    A -->|commit| M[Mutation]
    M --> S[State]
    S --> V
    S --> G[Getter]
    G --> V

概念对号: state 是数据, getter 是派生值, mutation 是唯一允许改 state 的同步函数, action 负责异步流程并通过 commit 调 mutation。
为什么多此一举拆出 mutation? 为了让 Devtools 能在每次 mutation 时刻拍状态快照, 异步放进 mutation 快照就乱了。
这套仪式感换来的是可追踪, 代价是样板代码翻倍, 这正是 Pinia 把 mutation 整层砍掉的原因。

典型的 namespaced module 长这样, 见到能读懂即可:

代码块TS · 22 行收起展开
export const taskModule = {
  namespaced: true,
  state: () => ({ items: [], loading: false }),
  getters: {
    unfinished: state => state.items.filter(item => !item.done)
  },
  mutations: {
    SET_ITEMS(state, items) { state.items = items },
    SET_LOADING(state, value) { state.loading = value }
  },
  actions: {
    async fetch({ commit }, query) {
      commit('SET_LOADING', true)
      try {
        const result = await fetchTasks(query)
        commit('SET_ITEMS', result.items)
      } finally {
        commit('SET_LOADING', false)
      }
    }
  }
}

组件侧配套的 mapState/mapGetters/mapActions 一族, 是 Options API 时代把 store 成员映射进组件的胶水。

7. Pinia 与 Vuex 对照

VuexPinia说明
stateref原始状态
gettercomputed派生状态
mutation直接改或 $patchPinia 砍掉了这一层
action普通函数同步异步都行
module + namespace多个独立 StorePinia 天然按 store 隔离
mapStatestoreToRefs组件响应式绑定

8. 从 Vuex 迁到 Pinia

接手老项目要迁移时, 按 module 逐个来, 别一次性重写: 一个 namespaced module 对应建一个 Pinia store; state 原样搬, getter 改 computed, action 改函数, mutation 的逻辑合并进 action 或调用处直接赋值; 组件从 mapXxx 改成 store + storeToRefs。
双写过渡期间必须明确唯一真源, 两边各存一份同名状态是事故温床。

9. Store 测试

Store 是脱离组件就能测的纯逻辑, 测起来很舒服:

代码块TS · 14 行收起展开
import { createPinia, setActivePinia } from 'pinia'

beforeEach(() => setActivePinia(createPinia()))   // 每个用例一个干净的 pinia 实例

it('clears session', () => {
  const auth = useAuthStore()
  auth.token = 'test-token'
  auth.user = { id: 1, name: 'Ada', roles: ['admin'] }

  auth.clearSession()

  expect(auth.token).toBe('')
  expect(auth.user).toBeNull()
})

高价值的测试点在异步 action 的失败回滚、权限判断、并发竞态这些真会出事的地方, 别把力气花在测 getter 等于几上。

10. 动手验收

  • 用 Pinia 完成登录、用户加载、权限判断、退出清理一整条链。
  • 制造两个乱序返回的列表请求, 验证最终状态属于最新那次查询。
  • 审一遍自己项目的 Store: 每个字段谁在读谁在写, 能被算出来的副本字段删掉。
  • 刷新、退出、换账号三连, 确认敏感状态无残留。

延伸阅读