Vuex 到 Pinia:Vue 状态管理的进化之路
从 Vuex 到 Pinia,不仅是换了一个状态管理库,更是 Vue 生态从 Options API 时代到 Composition API 时代的一次设计哲学升级。本文将从 Vuex 的设计缺陷出发,解析 Pinia 为什么更好,以及如何从 Vuex 迁移到 Pinia。
为什么需要状态管理
组件树的通信困境
在没有状态管理时,Vue 组件之间的状态共享靠 props/emit 逐层传递:
App ← 全局状态放哪里?
/ \
Header Main ← Header 需要用户信息,Main 也需要
/ \
Sidebar Content ← Sidebar 需要主题,Content 需要文章列表
/
Comments ← Comments 需要用户信息和文章 ID
状态跨层级传递时的痛点:
- Prop drilling:中间层组件被迫传递它不关心的 prop
- 兄弟通信:需要事件总线或 provide/inject
- 状态同步:多个组件需要共享并同步修改同一份状态
Flux 架构与 Vuex
受 Redux / Flux 思想启发,Vue 团队推出了 Vuex,核心理念是「单一状态树 + 单向数据流」:
Actions → Mutations → State → View
↑ |
└──────── Dispatch ──────────────┘
- State:全局唯一的响应式数据源
- Mutations:唯一允许修改 State 的同步函数
- Actions:处理异步逻辑,然后 commit mutation
- Getters:派生状态(类似 computed)
Vuex 的设计缺陷
1. Mutation 是多余的
// Vuex
const store = {
state: {
count: 0
},
mutations: {
increment(state) {
state.count++
}
},
actions: {
async incrementAsync({ commit }) {
await fetch('/api/increment')
commit('increment') // 必须通过 mutation 修改
}
}
}
// 使用
store.commit('increment') // 同步
store.dispatch('incrementAsync') // 异步
为什么必须有 mutation?Vuex 的设计参考了 Redux 的 reducer 必须是纯函数。但 Vue 的响应式系统本身就不要求同步修改——state.count++ 就是完全合法的响应式操作。Mutation 这一层带来的只是心智负担:
- 简单修改要写 mutation + 调用 commit
- 字符串类型的 mutation 名容易拼错,没有类型推导
- DevTools 的时间旅行调试实际很少用到
2. 模块嵌套太复杂
// Vuex modules
const store = new Vuex.Store({
modules: {
user: {
namespaced: true,
state: () => ({ profile: null }),
actions: {
async fetchProfile({ commit }) { ... }
},
modules: {
permissions: {
namespaced: true,
state: () => ({ list: [] }),
getters: {
canEdit: state => state.list.includes('edit')
}
}
}
}
}
})
// 使用时路径超长
store.dispatch('user/permissions/fetchList')
store.getters['user/permissions/canEdit']
3. TypeScript 支持差
Vuex 4 的类型支持需要大量 ModuleTree<RootState> 类型声明,且 action/mutation 的参数类型推导几乎为零:
// 需要 Root State 类型
interface RootState {
user: UserState
}
// 每个 module 需要定义 state 类型
interface UserState {
profile: User | null
}
// action 的 context 没有类型推导
const actions = {
fetchProfile(context) { // context 是 any
// context.commit 的 payload 也没有类型
}
}
// 要获得类型安全,需要安装 vuex-smart-module 或手写大量类型体操
4. 没有 tree-shaking
Vuex 的所有功能都是整体引入的,即使用不到 module、hot-reload,也会打包进去。
Pinia:重新设计
设计哲学
Pinia 由 Vue 核心团队成员 Eduardo San Martin Morote 开发,也是 Vue Router 4 的作者。Pinia 的核心理念:
- 不要 mutation:直接修改 state 或用 action 修改
- 不要嵌套模块:每个 store 是扁平的,用 ID 区分
- TypeScript 优先:完善的类型推导,零配置
- Composition API:store 定义就是 composable 函数
Store 定义对比
// ===== Vuex =====
const userModule = {
namespaced: true,
state: () => ({
profile: null as User | null,
token: ''
}),
getters: {
isLoggedIn: state => !!state.token
},
mutations: {
SET_PROFILE(state, profile) {
state.profile = profile
},
SET_TOKEN(state, token) {
state.token = token
}
},
actions: {
async login({ commit }, { username, password }) {
const { token, profile } = await api.login(username, password)
commit('SET_TOKEN', token)
commit('SET_PROFILE', profile)
}
}
}
// ===== Pinia =====
export const useUserStore = defineStore('user', () => {
// state
const profile = ref<User | null>(null)
const token = ref('')
// getters
const isLoggedIn = computed(() => !!token.value)
// actions
async function login(username: string, password: string) {
const res = await api.login(username, password)
token.value = res.token
profile.value = res.profile
}
return { profile, token, isLoggedIn, login }
})
使用对比
// Vuex
import { useStore } from 'vuex'
const store = useStore()
store.state.user.profile // 无类型提示
store.getters['user/isLoggedIn'] // 字符串路径
store.dispatch('user/login', { // 字符串路径
username: 'admin',
password: '123'
})
// Pinia
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
userStore.profile // ✅ 完善类型提示
userStore.isLoggedIn // ✅ 直接访问
userStore.login('admin', '123') // ✅ 直接调用,参数类型安全
userStore.token = 'xxx' // ✅ 可以直接修改 state
Pinia 的两种写法
Options Store(传统写法)
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Eduardo'
}),
getters: {
double: (state) => state.count * 2,
// 访问其他 getter
doublePlusOne(): number {
return this.double + 1
}
},
actions: {
increment() {
this.count++
},
async fetchCount() {
this.count = await api.getCount()
}
}
})
Setup Store(Composition API 推荐)
export const useCounterStore = defineStore('counter', () => {
// state
const count = ref(0)
const name = ref('Eduardo')
// getters
const double = computed(() => count.value * 2)
const doublePlusOne = computed(() => double.value + 1)
// actions
function increment() {
count.value++
}
async function fetchCount() {
count.value = await api.getCount()
}
return { count, name, double, doublePlusOne, increment, fetchCount }
})
两种写法效果等价,但 Setup Store 的优势:
- 可以使用任何 Composition API(
watch、watchEffect) - 可以调用其他 composable(如 VueUse)
- 更灵活的私有状态(不 return 就是私有的)
Pinia 进阶用法
跨 Store 调用
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const profile = ref<User | null>(null)
function setProfile(p: User) {
profile.value = p
}
return { profile, setProfile }
})
// stores/cart.ts
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
async function checkout() {
// 直接调用其他 store
const userStore = useUserStore()
if (!userStore.profile) {
throw new Error('请先登录')
}
await api.checkout({
userId: userStore.profile.id,
items: items.value
})
}
return { items, checkout }
})
重置状态
const store = useCounterStore()
// 一键重置到初始状态(Options store 自动支持,Setup store 需手动实现)
store.$reset()
// Setup store 手动实现 reset
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const initialCount = 0
function $reset() {
count.value = initialCount
}
return { count, $reset }
})
批量修改状态
// $patch:批量修改,一次触发更新
store.$patch({
count: store.count + 1,
name: 'New Name'
})
// $patch 函数写法(适合复杂逻辑)
store.$patch((state) => {
state.count++
state.items.push({ id: 1, name: 'new' })
state.user.profile = newProfile
})
订阅状态变化
// 订阅 state 变化
const unsubscribe = store.$subscribe((mutation, state) => {
console.log('State changed:', mutation.type, state)
// 持久化到 localStorage
localStorage.setItem('cart', JSON.stringify(state.items))
})
// 订阅 action 执行
const unsubscribeAction = store.$onAction(({
name, // action 名称
store, // store 实例
args, // action 参数
after, // action 成功后的回调
onError // action 失败后的回调
}) => {
console.log(`Action ${name} started`)
after((result) => {
console.log(`Action ${name} succeeded`, result)
})
onError((error) => {
console.error(`Action ${name} failed`, error)
})
})
// 清理订阅
unsubscribe()
unsubscribeAction()
持久化插件
// plugins/pinia-persist.ts
import { PiniaPluginContext } from 'pinia'
export function persistPlugin({ store }: PiniaPluginContext) {
// 从 localStorage 恢复
const saved = localStorage.getItem(store.$id)
if (saved) {
store.$patch(JSON.parse(saved))
}
// 订阅变化,自动保存
store.$subscribe((_, state) => {
localStorage.setItem(store.$id, JSON.stringify(state))
})
}
// main.ts
import { createPinia } from 'pinia'
const pinia = createPinia()
pinia.use(persistPlugin)
或者使用 pinia-plugin-persistedstate:
import { createPersistedState } from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(createPersistedState())
// 在 store 中声明持久化
export const useUserStore = defineStore('user', {
state: () => ({ token: '', profile: null }),
persist: {
key: 'app-user',
storage: sessionStorage,
pick: ['token'] // 只持久化 token
}
})
SSR 兼容(Nuxt)
// Nuxt 中 Pinia 自动注册
// stores/counter.ts
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
return { count }
})
// 任意组件中直接使用
const counter = useCounterStore()
// server-side 初始化
// plugins/init.server.ts
export default defineNuxtPlugin(async () => {
const userStore = useUserStore()
userStore.profile = await $fetch('/api/me')
})
从 Vuex 迁移到 Pinia
迁移策略
- 保留 Vuex,逐步添加 Pinia store
- 每次迁移一个 module
- 迁移完后移除 Vuex
迁移示例
// ===== 原始 Vuex module =====
const userModule = {
namespaced: true,
state: () => ({ profile: null, token: '' }),
getters: {
isLoggedIn: state => !!state.token,
isAdmin: state => state.profile?.role === 'admin'
},
mutations: {
SET_USER(state, { profile, token }) {
state.profile = profile
state.token = token
},
LOGOUT(state) {
state.profile = null
state.token = ''
}
},
actions: {
async login({ commit }, credentials) {
const res = await api.login(credentials)
commit('SET_USER', res)
},
async logout({ commit }) {
await api.logout()
commit('LOGOUT')
}
}
}
// ===== 迁移后的 Pinia store =====
export const useUserStore = defineStore('user', () => {
// state
const profile = ref<User | null>(null)
const token = ref('')
// getters
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => profile.value?.role === 'admin')
// actions
async function login(credentials: LoginCredentials) {
const res = await api.login(credentials)
profile.value = res.profile
token.value = res.token
}
async function logout() {
await api.logout()
profile.value = null
token.value = ''
}
return { profile, token, isLoggedIn, isAdmin, login, logout }
})
// ===== 组件迁移 =====
// Before (Vuex)
import { useStore } from 'vuex'
import { computed } from 'vue'
const store = useStore()
const isLoggedIn = computed(() => store.getters['user/isLoggedIn'])
const login = () => store.dispatch('user/login', { username, password })
// After (Pinia)
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
const { isLoggedIn } = storeToRefs(userStore) // 保持响应性
const login = () => userStore.login(username, password)
迁移对照表
| Vuex | Pinia |
|---|---|
state: () => ({}) |
const x = ref() |
getters: {} |
const x = computed(() => ...) |
mutations: {} |
直接在 action 中修改 x.value = ... |
actions: {} |
function x() { ... } |
store.commit('mutation') |
直接修改 store.x = ... |
store.dispatch('action') |
直接调用 store.x() |
store.getters['module/name'] |
store.name |
store.state.module.x |
store.x |
mapState/mapGetters |
storeToRefs(store) |
mapActions |
解构 action 函数 |
Pinia 为什么没有 Mutation
Vuex 要求通过 mutation 修改状态,主要目的有两个:
- DevTools 时间旅行:记录每次 mutation,可以回到任意状态
- Mutation 必须同步,确保状态变化可预测
Pinia 的取舍:
- DevTools 仍然可以追踪 state 变化(通过
reactive的深响应实现) - 时间旅行功能很少被使用,不值得为此增加心智负担
- Vue 的响应式系统本身就是同步追踪的,不需要额外约束
Pinia 放弃 mutation 是一种「实用主义」设计:用 10% 的功能损失换取 90% 的开发体验提升。
总结
| 维度 | Vuex 4 | Pinia |
|---|---|---|
| API 风格 | Options API + Mutation | Composition API |
| 模块化 | 嵌套 modules + namespace | 扁平 store + ID |
| TypeScript | 差,需大量类型声明 | 优秀,零配置类型推导 |
| 体积 | ~7KB | ~3KB |
| Mutation | 必需 | 移除 |
| SSR | 需要手动处理 | 开箱即用 |
| DevTools | ✅ | ✅ |
| Composition API | 通过 setup 写法支持 | 原生设计 |
如果你的项目还在用 Vuex,建议新功能用 Pinia,逐步迁移旧代码。如果是新项目,毫不犹豫选 Pinia。如果用 Nuxt 3+,Pinia 已经是内置的状态管理方案了。
Comments | 0条评论