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 当派生值, 函数当操作。拿最典型的登录状态做例子:
代码块收起展开
// 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 }
})组件里用:
代码块收起展开
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 三段式:
代码块收起展开
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 取消旧请求, 这里再记一个更轻的版本号方案:
代码块收起展开
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 长这样, 见到能读懂即可:
代码块收起展开
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 对照
| Vuex | Pinia | 说明 |
|---|---|---|
| state | ref | 原始状态 |
| getter | computed | 派生状态 |
| mutation | 直接改或 $patch | Pinia 砍掉了这一层 |
| action | 普通函数 | 同步异步都行 |
| module + namespace | 多个独立 Store | Pinia 天然按 store 隔离 |
| mapState | storeToRefs | 组件响应式绑定 |
8. 从 Vuex 迁到 Pinia
接手老项目要迁移时, 按 module 逐个来, 别一次性重写: 一个 namespaced module 对应建一个 Pinia store; state 原样搬, getter 改 computed, action 改函数, mutation 的逻辑合并进 action 或调用处直接赋值; 组件从 mapXxx 改成 store + storeToRefs。
双写过渡期间必须明确唯一真源, 两边各存一份同名状态是事故温床。
9. Store 测试
Store 是脱离组件就能测的纯逻辑, 测起来很舒服:
代码块收起展开
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: 每个字段谁在读谁在写, 能被算出来的副本字段删掉。
- 刷新、退出、换账号三连, 确认敏感状态无残留。